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