using NSec.Cryptography; namespace DodoSSH.Crypto.Tests; /// /// The vault key grant tuple and its signature. See docs/crypto.md §7.3. /// /// /// Sealing a vault key is anonymous-sender, so without this signature a server could fabricate a grant /// containing a key of its own choosing and the recipient would unwrap it happily. Most of these tests /// are therefore about a signature failing to transfer between contexts it should not. /// public sealed class GrantStatementCodecTests { private static readonly Guid VaultA = Guid.Parse("0192f0c8-1111-7c3d-8e4f-5a6b7c8d9e0f"); private static readonly Guid VaultB = Guid.Parse("0192f0c8-2222-7c3d-8e4f-5a6b7c8d9e0f"); private static readonly Guid Alice = Guid.Parse("0192f0c8-3333-7c3d-8e4f-5a6b7c8d9e0f"); private static readonly Guid Bob = Guid.Parse("0192f0c8-4444-7c3d-8e4f-5a6b7c8d9e0f"); private static readonly DateTimeOffset GrantedAt = DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_123); [Fact] public void Encode_IsDeterministic() { Encode().ShouldBe(Encode()); } [Fact] public void Encode_BeginsWithTheDomainLabel() { var encoding = Encode(); encoding.AsSpan(0, GrantStatementCodec.Label.Length) .SequenceEqual(GrantStatementCodec.Label) .ShouldBeTrue(); } [Theory] [InlineData("vault")] [InlineData("generation")] [InlineData("kind")] [InlineData("grantee")] [InlineData("granteeFingerprint")] [InlineData("wrappedKey")] [InlineData("granter")] [InlineData("granterFingerprint")] [InlineData("keyLogHead")] [InlineData("grantedAt")] public void ChangingAnyField_ChangesTheEncoding(string field) { var baseline = Encode(); var altered = field switch { "vault" => Encode(vaultId: VaultB), "generation" => Encode(keyGeneration: 2), "kind" => Encode(kind: GrantPurpose.Recovery), "grantee" => Encode(granteeUserId: Bob), "granteeFingerprint" => Encode(granteeFingerprint: Fingerprint(0xB0)), "wrappedKey" => Encode(wrappedKey: Bytes(80, 0x99)), "granter" => Encode(granterUserId: Bob), "granterFingerprint" => Encode(granterFingerprint: Fingerprint(0xC0)), "keyLogHead" => Encode(keyLogHead: Fingerprint(0xD0)), "grantedAt" => Encode(grantedAt: GrantedAt.AddMilliseconds(1)), _ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unknown field."), }; altered.ShouldNotBe(baseline); } [Fact] public void AbsentAndPresentKeyLogHeads_EncodeDifferently() { // The presence byte, without which a grant carrying no head and one carrying 32 zero bytes // would be indistinguishable. Encode(keyLogHead: default).ShouldNotBe(Encode(keyLogHead: new byte[32])); } [Fact] public void TheSignatureCoversOnlyTheWrappedKeysDigest() { // So a verifier can check who issued a grant without holding the vault key. The encoding is // 32 bytes of digest regardless of how large the wrapped key is. var small = Encode(wrappedKey: Bytes(48, 0x11)); var large = Encode(wrappedKey: Bytes(4096, 0x11)); small.Length.ShouldBe(large.Length); small.ShouldNotBe(large); } [Fact] public void AFreshSignature_Verifies() { using var key = CreateSigningKey(); var grant = Encode(); var signature = GrantStatementCodec.Sign(key, grant); signature.Length.ShouldBe(CryptoSpec.SignatureSize); GrantStatementCodec.Verify(PublicKeyBytes(key), grant, signature).ShouldBeTrue(); } [Fact] public void ASignatureOverAnotherGrant_DoesNotVerify() { // The property that matters: a grant for one vault cannot be replayed onto another. using var key = CreateSigningKey(); var signature = GrantStatementCodec.Sign(key, Encode(vaultId: VaultA)); GrantStatementCodec.Verify(PublicKeyBytes(key), Encode(vaultId: VaultB), signature) .ShouldBeFalse(); } [Fact] public void ASignatureFromAnotherGranter_DoesNotVerify() { using var key = CreateSigningKey(); using var other = CreateSigningKey(); var grant = Encode(); var signature = GrantStatementCodec.Sign(other, grant); GrantStatementCodec.Verify(PublicKeyBytes(key), grant, signature).ShouldBeFalse(); } [Fact] public void AKeyStatementSignature_DoesNotVerifyAsAGrant() { // Context separation. Without it a signature produced in one role could be presented in // another, which is the whole reason each context string exists. using var key = CreateSigningKey(); var grant = Encode(); var wrongContext = DshSignatures.SignKeyStatement(key, grant); GrantStatementCodec.Verify(PublicKeyBytes(key), grant, wrongContext).ShouldBeFalse(); } [Theory] [InlineData(0)] [InlineData(31)] [InlineData(33)] public void AMalformedPublicKey_ReturnsFalseRatherThanThrowing(int length) { using var key = CreateSigningKey(); var grant = Encode(); var signature = GrantStatementCodec.Sign(key, grant); GrantStatementCodec.Verify(new byte[length], grant, signature).ShouldBeFalse(); } [Fact] public void Encode_RejectsAnUnspecifiedKind() { Should.Throw(() => Encode(kind: GrantPurpose.Unspecified)); } [Fact] public void Encode_RejectsAnEmptyWrappedKey() { Should.Throw(() => Encode(wrappedKey: [])); } [Fact] public void Encode_RejectsAWrongLengthFingerprint() { Should.Throw(() => Encode(granteeFingerprint: new byte[16])); } [Fact] public void Encode_RejectsAWrongLengthKeyLogHead() { Should.Throw(() => Encode(keyLogHead: new byte[16])); } [Fact] public void ThePurposeValues_MatchTheDomainEnum() { // Covered by the signature, so a renumbering would make every grant of the changed kind fail // verification. Domain cannot be referenced from here, so the values are asserted literally // against docs/crypto.md §7.3. ((byte)GrantPurpose.Member).ShouldBe((byte)1); ((byte)GrantPurpose.Recovery).ShouldBe((byte)2); ((byte)GrantPurpose.Escrow).ShouldBe((byte)3); } // ---- Helpers ---- private static byte[] Encode( Guid? vaultId = null, uint keyGeneration = 1, GrantPurpose kind = GrantPurpose.Member, Guid? granteeUserId = null, byte[]? granteeFingerprint = null, byte[]? wrappedKey = null, Guid? granterUserId = null, byte[]? granterFingerprint = null, byte[]? keyLogHead = null, DateTimeOffset? grantedAt = null) => GrantStatementCodec.Encode( vaultId ?? VaultA, keyGeneration, kind, granteeUserId ?? Alice, granteeFingerprint ?? Fingerprint(0x40), wrappedKey ?? Bytes(80, 0x77), granterUserId ?? Alice, granterFingerprint ?? Fingerprint(0x60), keyLogHead ?? [], grantedAt ?? GrantedAt); private static byte[] Fingerprint(byte seed) => Bytes(CryptoSpec.DigestSize, seed); private static byte[] Bytes(int length, byte seed) => [.. Enumerable.Range(0, length).Select(i => (byte)(seed + i))]; private static Key CreateSigningKey() => Key.Create( SignatureAlgorithm.Ed25519, new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport }); private static byte[] PublicKeyBytes(Key key) => key.PublicKey.Export(KeyBlobFormat.RawPublicKey); }