Files
DodoSSH/tests/DodoSSH.Crypto.Tests/GrantStatementCodecTests.cs
jaap-jan a878c2b6bb Add the server client and client-side enrollment
A typed client over DodoSSH.Contracts, and the orchestration that turns a
passphrase into an enrolled identity: generate keys, have the identity
provider sign over them, wrap the bundle three ways, create the personal
vault, publish.

Ordering here is forced, not chosen. The secret bundle's AAD binds to the
server-assigned user id, so /me has to be read before anything can be
wrapped -- which is exactly why /me provisions the account and returns its id
even while reporting that enrollment is required. That constraint was
designed into the server earlier; this is the first code that depends on it.

The grant tuple now has a real canonical encoding (crypto.md 7.3) rather
than the placeholder signature I would otherwise have had to invent and then
keep. §7 named the tuple without specifying how to encode it; this fills that
in with the same conventions as 7.1, and the self-grant at enrollment is
already in its final format. The signature covers SHA-256(wrappedKey) rather
than the key, so a verifier can check attribution without holding the vault
key at all.

The most valuable tests are the negative ones about the request body: the
server is meant to be unable to read what it stores, and a refactor that put
a passphrase or a private key into the enrollment request would be invisible
to every other test in the repository. So one asserts the body contains
neither the passphrase, the recovery code, nor any private key in base64 or
hex. Another opens the same bundle three ways -- passphrase, recovery code and
device key -- which is what makes a passphrase change a one-row update.

ClientEnrollment depends on IKeyBindingAuthorizer rather than the whole
OidcClient. It needs exactly one capability, and depending on the full client
would drag discovery and token exchange into every test of key binding.

Two things fixed while building it. The recovery code buffer was sized one
separator short, so every enrollment threw IndexOutOfRange -- caught
immediately because nine of ten tests failed identically. And the crypto
enum collided with Domain.GrantKind in the server, so it is GrantPurpose
there; the numeric values still have to match, which the doc and a test both
say.

448 tests pass, zero warnings on a clean rebuild, format clean.
2026-07-28 22:42:56 +02:00

224 lines
7.8 KiB
C#

using NSec.Cryptography;
namespace DodoSSH.Crypto.Tests;
/// <summary>
/// The vault key grant tuple and its signature. See docs/crypto.md §7.3.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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<ArgumentOutOfRangeException>(() => Encode(kind: GrantPurpose.Unspecified));
}
[Fact]
public void Encode_RejectsAnEmptyWrappedKey()
{
Should.Throw<ArgumentException>(() => Encode(wrappedKey: []));
}
[Fact]
public void Encode_RejectsAWrongLengthFingerprint()
{
Should.Throw<ArgumentException>(() => Encode(granteeFingerprint: new byte[16]));
}
[Fact]
public void Encode_RejectsAWrongLengthKeyLogHead()
{
Should.Throw<ArgumentException>(() => 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);
}