using System.Buffers.Binary; using System.Security.Cryptography; using NSec.Cryptography; namespace DodoSSH.Crypto; /// /// Why a vault key grant exists. /// /// /// /// Named GrantPurpose rather than GrantKind only to avoid colliding with /// DodoSSH.Domain.GrantKind, 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. /// /// /// The numeric values must match 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. /// /// public enum GrantPurpose : byte { /// Not a legal value. Unspecified = 0, /// Wrapped to a member's identity key. Member = 1, /// Wrapped to a recovery key held by the vault owner. Recovery = 2, /// Wrapped to a team break-glass key. Escrow = 3, } /// /// The canonical encoding of a vault key grant, as signed by the granter. See docs/crypto.md §7.3. /// /// /// /// 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. /// /// /// The signature covers SHA-256(wrappedKey) rather than the wrapped key itself, so a verifier /// does not need the vault key to check who issued the grant. /// /// public static class GrantStatementCodec { /// Domain-separating prefix. public static ReadOnlySpan Label => "dsh1/grant/v1"u8; private const int LabelLength = 13; /// Length without a key log head. 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 /// /// Writes the canonical encoding. /// /// The vault the key belongs to. /// Generation the grant is for, so a superseded one cannot be replayed. /// Why the grant exists. /// Who may open it. for a non-member grant. /// The exact identity key it was wrapped to. /// The sealed vault key; only its digest is covered. /// Who issued it. /// The granter's identity key. /// /// 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. /// /// Signing time; truncated to milliseconds. public static byte[] Encode( Guid vaultId, uint keyGeneration, GrantPurpose kind, Guid granteeUserId, ReadOnlySpan granteeKeyFingerprint, ReadOnlySpan wrappedKey, Guid granterUserId, ReadOnlySpan granterKeyFingerprint, ReadOnlySpan 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 granteeKeyFingerprint, ReadOnlySpan wrappedKey, ReadOnlySpan granterKeyFingerprint, ReadOnlySpan 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)); } } /// Signs a grant encoding. public static byte[] Sign(Key signingKey, ReadOnlySpan canonicalGrant) { ArgumentNullException.ThrowIfNull(signingKey); return SignatureAlgorithm.Ed25519.Sign(signingKey, BuildMessage(canonicalGrant)); } /// /// Verifies a grant signature against the granter's published signing key. /// /// /// 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. /// public static bool Verify( ReadOnlySpan granterSigningPublicKey, ReadOnlySpan canonicalGrant, ReadOnlySpan 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); } /// /// Context-prefixed, so a grant signature can never be replayed as a key statement signature or an /// attestation. /// private static byte[] BuildMessage(ReadOnlySpan 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 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 value, string parameterName) { if (value.Length != CryptoSpec.DigestSize) { throw new ArgumentException( $"Expected a {CryptoSpec.DigestSize}-byte fingerprint, got {value.Length}.", parameterName); } } }