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.
This commit is contained in:
2026-07-28 22:42:56 +02:00
parent 5fccd53824
commit a878c2b6bb
15 changed files with 3301 additions and 1 deletions
+260
View File
@@ -0,0 +1,260 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using NSec.Cryptography;
namespace DodoSSH.Crypto;
/// <summary>
/// Why a vault key grant exists.
/// </summary>
/// <remarks>
/// <para>
/// Named <c>GrantPurpose</c> rather than <c>GrantKind</c> only to avoid colliding with
/// <c>DodoSSH.Domain.GrantKind</c>, which the server uses for the same concept. Both are visible in the
/// server, and an ambiguous name there would need qualifying at every use.
/// </para>
/// <para>
/// The <b>numeric values must match</b> that enum exactly. They are covered by a grant signature, so a
/// renumbering would make every grant of the changed kind fail verification for good. A test pins them.
/// </para>
/// </remarks>
public enum GrantPurpose : byte
{
/// <summary>Not a legal value.</summary>
Unspecified = 0,
/// <summary>Wrapped to a member's identity key.</summary>
Member = 1,
/// <summary>Wrapped to a recovery key held by the vault owner.</summary>
Recovery = 2,
/// <summary>Wrapped to a team break-glass key.</summary>
Escrow = 3,
}
/// <summary>
/// The canonical encoding of a vault key grant, as signed by the granter. See docs/crypto.md §7.3.
/// </summary>
/// <remarks>
/// <para>
/// Sealing a vault key is anonymous-sender by construction, so a grant proves nothing about who
/// created it. Without a signature over this tuple a server could fabricate a grant containing a key
/// of its own choosing, and the recipient would unwrap it successfully and be none the wiser. The
/// signature makes that detectable and attributable — it cannot make it impossible, since verifying
/// the contents would require the server to hold the key.
/// </para>
/// <para>
/// The signature covers <c>SHA-256(wrappedKey)</c> rather than the wrapped key itself, so a verifier
/// does not need the vault key to check who issued the grant.
/// </para>
/// </remarks>
public static class GrantStatementCodec
{
/// <summary>Domain-separating prefix.</summary>
public static ReadOnlySpan<byte> Label => "dsh1/grant/v1"u8;
private const int LabelLength = 13;
/// <summary>Length without a key log head.</summary>
private const int BaseLength =
LabelLength
+ sizeof(uint) // key generation
+ 1 // grant kind
+ 16 // vault id
+ 16 // grantee user id
+ CryptoSpec.DigestSize // grantee key fingerprint
+ CryptoSpec.DigestSize // SHA-256 of the wrapped key
+ 16 // granter user id
+ CryptoSpec.DigestSize // granter key fingerprint
+ 1 // key log head presence
+ sizeof(long); // timestamp, Unix milliseconds
/// <summary>
/// Writes the canonical encoding.
/// </summary>
/// <param name="vaultId">The vault the key belongs to.</param>
/// <param name="keyGeneration">Generation the grant is for, so a superseded one cannot be replayed.</param>
/// <param name="kind">Why the grant exists.</param>
/// <param name="granteeUserId">Who may open it. <see cref="Guid.Empty"/> for a non-member grant.</param>
/// <param name="granteeKeyFingerprint">The exact identity key it was wrapped to.</param>
/// <param name="wrappedKey">The sealed vault key; only its digest is covered.</param>
/// <param name="granterUserId">Who issued it.</param>
/// <param name="granterKeyFingerprint">The granter's identity key.</param>
/// <param name="keyLogHead">
/// The key log head the granter observed, or null. Null for a self-grant, where there is no third
/// party whose key could have been substituted — and where the log entry that would supply the head
/// is written in the same transaction, so the client could not have signed over it.
/// </param>
/// <param name="grantedAt">Signing time; truncated to milliseconds.</param>
public static byte[] Encode(
Guid vaultId,
uint keyGeneration,
GrantPurpose kind,
Guid granteeUserId,
ReadOnlySpan<byte> granteeKeyFingerprint,
ReadOnlySpan<byte> wrappedKey,
Guid granterUserId,
ReadOnlySpan<byte> granterKeyFingerprint,
ReadOnlySpan<byte> keyLogHead,
DateTimeOffset grantedAt)
{
Validate(kind, granteeKeyFingerprint, wrappedKey, granterKeyFingerprint, keyLogHead);
var buffer = new byte[BaseLength + keyLogHead.Length];
var span = buffer.AsSpan();
Label.CopyTo(span);
var offset = LabelLength;
BinaryPrimitives.WriteUInt32BigEndian(span[offset..], keyGeneration);
offset += sizeof(uint);
span[offset++] = (byte)kind;
offset = WriteGuid(span, offset, vaultId);
offset = WriteGuid(span, offset, granteeUserId);
granteeKeyFingerprint.CopyTo(span[offset..]);
offset += CryptoSpec.DigestSize;
// The digest, not the key. A verifier must be able to check attribution without holding the
// vault key.
SHA256.HashData(wrappedKey, span.Slice(offset, CryptoSpec.DigestSize));
offset += CryptoSpec.DigestSize;
offset = WriteGuid(span, offset, granterUserId);
granterKeyFingerprint.CopyTo(span[offset..]);
offset += CryptoSpec.DigestSize;
if (keyLogHead.IsEmpty)
{
span[offset++] = 0;
}
else
{
span[offset++] = 1;
keyLogHead.CopyTo(span[offset..]);
offset += CryptoSpec.DigestSize;
}
BinaryPrimitives.WriteInt64BigEndian(span[offset..], grantedAt.ToUnixTimeMilliseconds());
offset += sizeof(long);
if (offset != buffer.Length)
{
throw new InvalidOperationException(
$"Grant encoding wrote {offset} bytes but reserved {buffer.Length}.");
}
return buffer;
}
private static void Validate(
GrantPurpose kind,
ReadOnlySpan<byte> granteeKeyFingerprint,
ReadOnlySpan<byte> wrappedKey,
ReadOnlySpan<byte> granterKeyFingerprint,
ReadOnlySpan<byte> keyLogHead)
{
if (kind == GrantPurpose.Unspecified)
{
throw new ArgumentOutOfRangeException(nameof(kind), kind, "A grant kind is required.");
}
RequireDigest(granteeKeyFingerprint, nameof(granteeKeyFingerprint));
RequireDigest(granterKeyFingerprint, nameof(granterKeyFingerprint));
if (wrappedKey.IsEmpty)
{
throw new ArgumentException("A wrapped key is required.", nameof(wrappedKey));
}
if (!keyLogHead.IsEmpty && keyLogHead.Length != CryptoSpec.DigestSize)
{
throw new ArgumentException(
$"A key log head is {CryptoSpec.DigestSize} bytes or absent.",
nameof(keyLogHead));
}
}
/// <summary>Signs a grant encoding.</summary>
public static byte[] Sign(Key signingKey, ReadOnlySpan<byte> canonicalGrant)
{
ArgumentNullException.ThrowIfNull(signingKey);
return SignatureAlgorithm.Ed25519.Sign(signingKey, BuildMessage(canonicalGrant));
}
/// <summary>
/// Verifies a grant signature against the granter's published signing key.
/// </summary>
/// <remarks>
/// Clients verify these; the server stores them opaquely. Server-side verification would be a
/// convenience and never the boundary, and would put an asymmetric implementation on a machine
/// that is supposed to hold no keys.
/// </remarks>
public static bool Verify(
ReadOnlySpan<byte> granterSigningPublicKey,
ReadOnlySpan<byte> canonicalGrant,
ReadOnlySpan<byte> signature)
{
if (granterSigningPublicKey.Length != CryptoSpec.PublicKeySize
|| signature.Length != CryptoSpec.SignatureSize)
{
return false;
}
PublicKey publicKey;
try
{
publicKey = PublicKey.Import(
SignatureAlgorithm.Ed25519,
granterSigningPublicKey,
KeyBlobFormat.RawPublicKey);
}
catch (FormatException)
{
return false;
}
return SignatureAlgorithm.Ed25519.Verify(publicKey, BuildMessage(canonicalGrant), signature);
}
/// <remarks>
/// Context-prefixed, so a grant signature can never be replayed as a key statement signature or an
/// attestation.
/// </remarks>
private static byte[] BuildMessage(ReadOnlySpan<byte> canonicalGrant)
{
var context = CryptoSpec.SigningContexts.Grant;
var message = new byte[context.Length + canonicalGrant.Length];
context.CopyTo(message);
canonicalGrant.CopyTo(message.AsSpan(context.Length));
return message;
}
private static int WriteGuid(Span<byte> destination, int offset, Guid value)
{
// RFC 4122 big-endian, as everywhere else in this specification.
if (!value.TryWriteBytes(destination[offset..], bigEndian: true, out _))
{
throw new InvalidOperationException("Failed to write a grant identifier.");
}
return offset + 16;
}
private static void RequireDigest(ReadOnlySpan<byte> value, string parameterName)
{
if (value.Length != CryptoSpec.DigestSize)
{
throw new ArgumentException(
$"Expected a {CryptoSpec.DigestSize}-byte fingerprint, got {value.Length}.",
parameterName);
}
}
}