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);
}