using System.Buffers.Binary; using System.Security.Cryptography; using NSec.Cryptography; namespace DodoSSH.Crypto; /// /// A user's identity key pair, in the form that gets wrapped. See docs/crypto.md §3. /// /// /// /// This is the load-bearing structural choice of the whole key hierarchy. The bundle is stored /// server-side as N independent wraps of the same bytes — one per passphrase, device, /// recovery code and escrow key — so a passphrase change re-wraps about a hundred bytes and updates /// one row. No vault data is re-encrypted, and no other member is involved. Adding a device or a /// recovery code is likewise one more wrap row. Encrypting vault keys under the passphrase key /// directly would have made every one of those a fan-out over the entire vault. /// /// /// Private keys live in libsodium's guarded, mlocked allocations rather than in a /// array, so they are not paged to disk and do not appear in a core dump. They /// are created exportable, which is a deliberate trade: re-wrapping the same bundle for a passphrase /// change or a new device requires re-encoding it, and the alternative — holding the plaintext in a /// long-lived managed array so the keys need not be exportable — keeps the identical secret in /// strictly worse memory. Every export here is into a buffer that is zeroed before the method /// returns. /// /// public sealed class UserSecretBundle : IDisposable { /// Domain-separating prefix of the encoding. public static ReadOnlySpan Label => "dsh1/bundle/v1"u8; /// Encoding version this implementation writes. public const int CurrentVersion = 1; /// /// Total encoded length. Fixed, because every field is fixed-width. /// public const int EncodedLength = 14 // Label + sizeof(ushort) // version + sizeof(uint) // key generation + sizeof(long) // created at, Unix milliseconds + (CryptoSpec.SymmetricKeySize * 2); // X25519 then Ed25519 private keys private const int OffsetVersion = 14; private const int OffsetKeyGeneration = 16; private const int OffsetCreatedAt = 20; private const int OffsetEncryptionKey = 28; private const int OffsetSigningKey = 60; private readonly Key encryptionKey; private readonly Key signingKey; private bool disposed; private UserSecretBundle(Key encryptionKey, Key signingKey, uint keyGeneration, DateTimeOffset createdAt) { this.encryptionKey = encryptionKey; this.signingKey = signingKey; KeyGeneration = keyGeneration; CreatedAt = createdAt; EncryptionPublicKey = encryptionKey.PublicKey.Export(KeyBlobFormat.RawPublicKey); SigningPublicKey = signingKey.PublicKey.Export(KeyBlobFormat.RawPublicKey); } /// Generation of this identity key pair, starting at 1. public uint KeyGeneration { get; } /// When the keys were generated, truncated to milliseconds. public DateTimeOffset CreatedAt { get; } /// X25519 public key, 32 bytes. Safe to publish. public byte[] EncryptionPublicKey { get; } /// Ed25519 public key, 32 bytes. Safe to publish. public byte[] SigningPublicKey { get; } /// The X25519 key, for unwrapping vault keys sealed to this user. public Key EncryptionKey => Alive().encryptionKey; /// The Ed25519 key, for signing key statements and grants. public Key SigningKey => Alive().signingKey; /// Generates a fresh identity key pair. /// Creation timestamp; truncated to milliseconds. /// Generation number. 1 at enrollment. public static UserSecretBundle Create(DateTimeOffset createdAt, uint keyGeneration = 1) { ArgumentOutOfRangeException.ThrowIfLessThan(keyGeneration, 1u); var parameters = new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport, }; var encryption = Key.Create(KeyAgreementAlgorithm.X25519, parameters); Key signing; try { signing = Key.Create(SignatureAlgorithm.Ed25519, parameters); } catch { encryption.Dispose(); throw; } return new UserSecretBundle( encryption, signing, keyGeneration, KeyLogChain.TruncateTimestamp(createdAt)); } /// /// Derives the key that encrypts this client's on-disk cache. See docs/crypto.md §3.2. /// /// /// /// Derived from the bundle rather than from the passphrase master key, because the bundle is the one /// thing every unlock path ends up holding. A device unlock opens a wrap and never /// sees a master key at all, so under the old derivation it could open the identity and still not read /// the cache it had itself written; a recovery-code unlock would derive a different key from a different /// master and silently orphan every cached row. Keying on the bundle makes the cache readable through /// whichever door was used, and unreadable exactly when the identity itself has been rotated — which is /// the correct time to discard it. /// /// /// Extract-and-expand, not expand alone. can expand directly because an /// Argon2id output is already uniformly random over its whole length. The bundle's encoding is not: it /// opens with a fixed 14-byte label and carries a version, a generation and a timestamp before it /// reaches any key material. HKDF's extract step is what turns that into a pseudorandom key, and /// skipping it would be feeding structured input to a function that assumes it has none. /// /// public void DeriveLocalCacheKey(Span destination) { Alive(); if (destination.Length != CryptoSpec.SymmetricKeySize) { throw new ArgumentException( $"The cache key is {CryptoSpec.SymmetricKeySize} bytes, got {destination.Length}.", nameof(destination)); } Span encoded = stackalloc byte[EncodedLength]; try { Encode(encoded); HKDF.DeriveKey( HashAlgorithmName.SHA512, encoded, destination, salt: default, info: CryptoSpec.DerivationLabels.LocalCache); } finally { CryptographicOperations.ZeroMemory(encoded); } } /// /// Wraps the bundle under a symmetric key-encryption key. /// /// /// Used for the passphrase and recovery-code wraps, whose keys are derived rather than sealed /// to. The plaintext exists only inside this method and is zeroed before it returns. /// public byte[] WrapUnder(ReadOnlySpan keyEncryptionKey, in AadDescriptor descriptor) { Alive(); Span plaintext = stackalloc byte[EncodedLength]; try { Encode(plaintext); return DshCrypto.Seal(keyEncryptionKey, plaintext, descriptor); } finally { CryptographicOperations.ZeroMemory(plaintext); } } /// /// Seals the bundle to a public key. /// /// /// Used for device and escrow wraps. Anonymous-sender by construction, which is fine here: the /// recipient is the same user, so there is nothing to attribute. /// public byte[] SealTo(ReadOnlySpan recipientPublicKey, in AadDescriptor descriptor) { Alive(); Span plaintext = stackalloc byte[EncodedLength]; try { Encode(plaintext); return DshCrypto.SealTo(recipientPublicKey, plaintext, descriptor); } finally { CryptographicOperations.ZeroMemory(plaintext); } } /// /// Opens a wrap made by . /// /// /// The bundle, or if the key is wrong, the descriptor does not match, or /// the encoding is unrecognised. Never throws on bad input: the wrap arrives from a server that /// is explicitly not trusted, so a failed tag is an expected outcome — most often simply a /// mistyped passphrase. /// public static UserSecretBundle? TryOpenUnder( ReadOnlySpan keyEncryptionKey, ReadOnlySpan envelope, in AadDescriptor descriptor) { var plaintext = DshCrypto.Open(keyEncryptionKey, envelope, descriptor); return FromPlaintext(plaintext); } /// Opens a wrap made by , using the recipient's private key. public static UserSecretBundle? TryOpenSealed( Key recipientKey, ReadOnlySpan envelope, in AadDescriptor descriptor) { var plaintext = DshCrypto.OpenSealed(recipientKey, envelope, descriptor); return FromPlaintext(plaintext); } /// public void Dispose() { if (disposed) { return; } disposed = true; encryptionKey.Dispose(); signingKey.Dispose(); } private void Encode(Span destination) { Label.CopyTo(destination); BinaryPrimitives.WriteUInt16BigEndian(destination[OffsetVersion..], CurrentVersion); BinaryPrimitives.WriteUInt32BigEndian(destination[OffsetKeyGeneration..], KeyGeneration); BinaryPrimitives.WriteInt64BigEndian(destination[OffsetCreatedAt..], CreatedAt.ToUnixTimeMilliseconds()); // Ed25519's raw private key is the 32-byte seed, and X25519's is the 32-byte scalar, so both // fit the fixed layout. Exported straight into the caller's buffer, which is zeroed there. ExportInto(encryptionKey, destination.Slice(OffsetEncryptionKey, CryptoSpec.SymmetricKeySize)); ExportInto(signingKey, destination.Slice(OffsetSigningKey, CryptoSpec.SymmetricKeySize)); } private static void ExportInto(Key key, Span destination) { var exported = key.Export(KeyBlobFormat.RawPrivateKey); try { if (exported.Length != destination.Length) { throw new CryptographicException( $"Expected a {destination.Length}-byte raw private key, got {exported.Length}."); } exported.CopyTo(destination); } finally { CryptographicOperations.ZeroMemory(exported); } } private static UserSecretBundle? FromPlaintext(byte[]? plaintext) { if (plaintext is null) { return null; } try { return TryDecode(plaintext); } finally { CryptographicOperations.ZeroMemory(plaintext); } } /// /// Fails closed on anything unexpected. A bundle is the root of everything a user can read, so /// guessing at a malformed one is never the right move. /// private static bool IsWellFormed(ReadOnlySpan encoded) => encoded.Length == EncodedLength && encoded[..Label.Length].SequenceEqual(Label) && BinaryPrimitives.ReadUInt16BigEndian(encoded[OffsetVersion..]) == CurrentVersion && BinaryPrimitives.ReadUInt32BigEndian(encoded[OffsetKeyGeneration..]) >= 1; /// /// Internal rather than private so the golden-vector generator can build a bundle from fixed bytes. /// The cache key derives from this encoding, and a vector for it is worth nothing unless the input is /// pinned — draws fresh randomness, so it cannot produce a reproducible one. /// internal static UserSecretBundle? TryDecode(ReadOnlySpan encoded) { if (!IsWellFormed(encoded)) { return null; } var keyGeneration = BinaryPrimitives.ReadUInt32BigEndian(encoded[OffsetKeyGeneration..]); var createdAt = DateTimeOffset.FromUnixTimeMilliseconds( BinaryPrimitives.ReadInt64BigEndian(encoded[OffsetCreatedAt..])); var parameters = new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport, }; Key? encryption = null; Key? signing = null; try { encryption = Key.Import( KeyAgreementAlgorithm.X25519, encoded.Slice(OffsetEncryptionKey, CryptoSpec.SymmetricKeySize), KeyBlobFormat.RawPrivateKey, parameters); signing = Key.Import( SignatureAlgorithm.Ed25519, encoded.Slice(OffsetSigningKey, CryptoSpec.SymmetricKeySize), KeyBlobFormat.RawPrivateKey, parameters); var bundle = new UserSecretBundle(encryption, signing, keyGeneration, createdAt); // Ownership transferred; do not dispose in the catch below. encryption = null; signing = null; return bundle; } catch (FormatException) { return null; } finally { encryption?.Dispose(); signing?.Dispose(); } } private UserSecretBundle Alive() { ObjectDisposedException.ThrowIf(disposed, this); return this; } }