From e65d7389128f2f48a3b8d760cdcb828cb5c87ced Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Tue, 28 Jul 2026 21:02:52 +0200 Subject: [PATCH] Add the client key hierarchy: bundle, master key, vault and item keys Everything crypto.md section 3 describes below the identity key, which is what the desktop client needs before it can enroll or store anything. DshAad gives every descriptor in the specification a named constructor. The AAD binding is the most valuable structural property in the design -- it is what stops a server holding every ciphertext from pasting one row's bytes onto another, rolling a row back to a superseded generation, or replaying a revoked grant -- and all of it depends on callers getting purpose, resource type and ids right at every single call site. Hand-constructing descriptors makes that a matter of care; picking a method name makes it a matter of spelling. UserSecretBundle holds private keys in libsodium's guarded, mlocked allocations rather than a byte[], so they are not paged out and do not land in a core dump. They are created exportable, deliberately: re-wrapping the same bundle for a passphrase change or a new device needs to re-encode it, and the alternative -- a long-lived managed array so the keys need not be exportable -- keeps the identical secret in strictly worse memory. Every export is into a buffer zeroed before the method returns. Two spec changes, both found by implementing it, which is the argument for writing code before calling a spec frozen: - MK is 64 bytes, not 32. Skipping HKDF-Extract is correct for an Argon2id output (RFC 5869 3.3), but it means MK *is* the PRK, and .NET's HKDF.Expand rejects a PRK shorter than the hash output -- so a 32-byte MK cannot be expanded with SHA-512 at all. Widening it keeps the specified primitive; the alternatives were dropping to SHA-256 or adding an Extract step that conditions nothing. - The bundle encoding is a fixed 92-byte layout rather than canonical CBOR. Canonicality is not load-bearing here -- unlike a key statement the bundle is never hashed or signed, only encrypted -- so CBOR's one advantage does not apply, while its canonicalisation rules are a real source of cross-implementation disagreement. It also costs a dependency System.Formats.Cbor is not in the shared framework. Safe to change now and not later: no bundle has ever been stored. 53 new tests. The encoding is checked against an independent codec written in the test rather than by round-tripping production code against itself -- a round trip passes just as happily when both directions are wrong the same way, and this format cannot change after one bundle is stored. The pinned 92-byte hex constant is the golden vector for the layout. Most of the rest are negative, because a binding is only demonstrated by the substitutions that fail: a wrap for another user, a grant from a superseded generation, a payload pasted onto another item, a metadata blob offered as a payload, a version rolled back. --- docs/crypto.md | 47 +- src/DodoSSH.Crypto/CryptoSpec.cs | 9 + src/DodoSSH.Crypto/DshAad.cs | 139 +++++ src/DodoSSH.Crypto/ItemKeys.cs | 140 +++++ src/DodoSSH.Crypto/MasterKey.cs | 159 +++++ src/DodoSSH.Crypto/UserSecretBundle.cs | 322 ++++++++++ src/DodoSSH.Crypto/VaultKeys.cs | 109 ++++ .../UserSecretBundleTests.cs | 550 ++++++++++++++++++ .../VaultAndItemKeyTests.cs | 291 +++++++++ 9 files changed, 1763 insertions(+), 3 deletions(-) create mode 100644 src/DodoSSH.Crypto/DshAad.cs create mode 100644 src/DodoSSH.Crypto/ItemKeys.cs create mode 100644 src/DodoSSH.Crypto/MasterKey.cs create mode 100644 src/DodoSSH.Crypto/UserSecretBundle.cs create mode 100644 src/DodoSSH.Crypto/VaultKeys.cs create mode 100644 tests/DodoSSH.Crypto.Tests/UserSecretBundleTests.cs create mode 100644 tests/DodoSSH.Crypto.Tests/VaultAndItemKeyTests.cs diff --git a/docs/crypto.md b/docs/crypto.md index 1c5ec5f..d928201 100644 --- a/docs/crypto.md +++ b/docs/crypto.md @@ -94,15 +94,14 @@ OIDC access-token gate plus client-side backoff. ``` vault passphrase - │ Argon2id(salt, m=256 MiB, t=4, p=1) → 32 B + │ Argon2id(salt, m=256 MiB, t=4, p=1) → 64 B ▼ MK — master key, RAM only, never persisted, never transmitted │ HKDF-SHA512-Expand with domain-separated info labels ├── KEK_pp info = "dsh1/kek/passphrase/v1" 32 B └── LocalCacheKey info = "dsh1/localcache/v1" 32 B ▼ -UserSecretBundle — canonical CBOR, ~200 B - { v: 1, x25519_sk: 32 B, ed25519_sk: 32 B, created: , keyGeneration: } +UserSecretBundle — fixed binary, 92 B (see 3.1) stored server-side as N independent wraps of the SAME bundle: kind=passphrase → symmetric AEAD under KEK_pp kind=device → SealTo(device_x25519_pk) one row per enrolled device @@ -119,6 +118,48 @@ item plaintext — password, private key, key passphrase, TOTP seed, encrypted m XChaCha20-Poly1305(DK, plaintext, aad) ``` +> **Changed 2026-07-28: MK is 64 bytes, not 32.** Skipping HKDF-Extract — correct, because an +> Argon2id output is already uniformly random, per RFC 5869 §3.3 — means MK *is* the PRK of the +> expansion. .NET's `HKDF.Expand` rejects a PRK shorter than the hash output, so a 32-byte MK cannot +> be expanded with SHA-512 at all. Widening MK keeps the specified primitive; the alternatives were +> dropping to SHA-256 or adding an Extract step that conditions nothing. Extra Argon2id output is +> free. Discovered by implementing it, which is the argument for writing the code before declaring a +> spec frozen. + +### 3.1 UserSecretBundle encoding + +> **Changed 2026-07-28**, from "canonical CBOR" to the fixed layout below. This reverses a stated +> choice rather than clarifying an unstated one, so the reasoning is recorded here. It is safe to +> make now and would not be later: nothing has been implemented against CBOR and no bundle has ever +> been stored, so there is nothing to migrate. + +``` +bundle = "dsh1/bundle/v1" 14 bytes, literal + || u16 version big-endian + || u32 keyGeneration big-endian + || i64 createdAt big-endian, Unix milliseconds, UTC + || x25519_sk 32 bytes, raw scalar + || ed25519_sk 32 bytes, raw seed + = 92 bytes, fixed +``` + +Three reasons for the change: + +- **Canonicality is not load-bearing here.** Unlike a key statement (§7.1), the bundle is never + hashed or signed — only encrypted. Any deterministic encoding is sufficient, so the one property + CBOR was chosen for does not apply. Canonical CBOR's rules (definite-length maps, sorted keys, + shortest-form integers) are a source of cross-implementation disagreement bought for nothing. +- **It costs a dependency.** `System.Formats.Cbor` is not in the .NET 10 shared framework. Keeping + `DodoSSH.Crypto` down to NSec alone matters for a client that wants trimming. +- **Consistency.** §7.1 and §7.2 already establish a fixed big-endian layout with an explicit + domain label. One convention to learn and to review beats two. + +Forward compatibility is unaffected: the bundle is versioned, and only our own clients ever read it, +so a new field means bumping `version` — which a fixed layout handles as well as CBOR would. + +Readers **must** reject a bundle whose length, label or version does not match exactly. This is the +root of everything a user can read; there is no safe way to guess at a malformed one. + ### Why the bundle is wrapped many ways This is the load-bearing structural choice. Because every wrap protects the *same* bundle: diff --git a/src/DodoSSH.Crypto/CryptoSpec.cs b/src/DodoSSH.Crypto/CryptoSpec.cs index c7c3b72..3d87a3a 100644 --- a/src/DodoSSH.Crypto/CryptoSpec.cs +++ b/src/DodoSSH.Crypto/CryptoSpec.cs @@ -32,6 +32,15 @@ public static class CryptoSpec /// Size of a symmetric content or wrapping key. public const int SymmetricKeySize = 32; + /// + /// Size of the passphrase-derived master key, which subkeys are expanded from. + /// + /// + /// 64 bytes, not 32, because it is the PRK of an HKDF-SHA512 expansion and .NET's + /// HKDF.Expand rejects a PRK shorter than the hash output. See docs/crypto.md §3. + /// + public const int MasterKeySize = 64; + /// Size of an X25519 or Ed25519 public key. public const int PublicKeySize = 32; diff --git a/src/DodoSSH.Crypto/DshAad.cs b/src/DodoSSH.Crypto/DshAad.cs new file mode 100644 index 0000000..7eca6bb --- /dev/null +++ b/src/DodoSSH.Crypto/DshAad.cs @@ -0,0 +1,139 @@ +namespace DodoSSH.Crypto; + +/// +/// Named constructors for every AAD descriptor the specification defines. See docs/crypto.md §4. +/// +/// +/// +/// The AAD binding is the most valuable structural property in the design: it is what stops a +/// server that holds every ciphertext from pasting one row's bytes onto another, rolling a row back +/// to a superseded key generation, replaying a revoked grant, or substituting a metadata blob for a +/// payload. None of that follows from access control. +/// +/// +/// All of it also depends on callers getting the purpose, resource type and ids right at every call +/// site. Hand-constructing makes that a matter of care; going through +/// these factories makes it a matter of picking the right method name, which is the difference +/// between a property that holds and one that mostly holds. +/// +/// +public static class DshAad +{ + /// + /// Binds a wrap of the user's secret bundle to that user and key generation. + /// + /// + /// + /// Every wrap kind — passphrase, device, recovery, escrow — protects the same bundle and uses + /// this same descriptor. In particular a device wrap is not bound to its device row: + /// the client cannot be, since the server assigns the device id after the wrap is built, and it + /// need not be, because the wrap is sealed to that device's public key. Relocating the row to + /// another device gains an attacker nothing they could open. + /// + /// + /// Including the generation means a server cannot serve back a superseded bundle after a key + /// rotation. The client learns the current generation from /me, so a lie there produces + /// a tag failure rather than a silent downgrade. + /// + /// + /// + /// The user's identifier, as assigned by the server. A client must therefore read + /// /api/v1/me — which provisions the account and returns its id even before enrollment — + /// before it can build a wrap. + /// + /// Generation of the identity key pair inside the bundle. + public static AadDescriptor UserSecretBundle(Guid userId, uint keyGeneration = 1) => + AadDescriptor.Create( + CryptoSpec.AadPurpose.UserSecretBundle, + CryptoSpec.AadResourceType.User, + userId, + keyGeneration: keyGeneration); + + /// + /// Binds a vault key grant to its vault and generation. + /// + /// + /// The generation is what makes revocation stick against a malicious server: after a rekey it + /// cannot re-serve a previous generation's grant to a removed member, because the AAD no longer + /// matches. That is a bound on future reads only — anything already downloaded is already gone, + /// which is why offboarding means rotating the SSH credentials themselves. See ADR 0001. + /// + public static AadDescriptor VaultKeyGrant(Guid vaultId, uint keyGeneration) => + AadDescriptor.Create( + CryptoSpec.AadPurpose.VaultKeyGrant, + CryptoSpec.AadResourceType.Vault, + vaultId, + keyGeneration: keyGeneration); + + /// Binds an item's data key, wrapped under the vault key, to that item and version. + public static AadDescriptor ItemDataKey( + CryptoSpec.AadResourceType resourceType, + Guid itemId, + uint keyGeneration, + uint itemVersion) => + AadDescriptor.Create( + CryptoSpec.AadPurpose.ItemDataKey, + resourceType, + itemId, + keyGeneration: keyGeneration, + itemVersion: itemVersion); + + /// Binds an item's payload to the item, its data key, generation and version. + /// What kind of item this is. + /// The item. + /// + /// The data key the payload is under — the content_key_id column. Reserved so that + /// per-item grants can make item-level access cryptographic in M5 without a migration. + /// + /// Vault key generation in force. + /// Item version, so an earlier version cannot be replayed. + public static AadDescriptor ItemPayload( + CryptoSpec.AadResourceType resourceType, + Guid itemId, + Guid dataKeyId, + uint keyGeneration, + uint itemVersion) => + AadDescriptor.Create( + CryptoSpec.AadPurpose.ItemPayload, + resourceType, + itemId, + dataKeyId, + keyGeneration, + itemVersion); + + /// + /// Binds an item's encrypted metadata. + /// + /// + /// A distinct purpose from on the same item, which is what stops a + /// server swapping one blob for the other — a substitution that would otherwise leave a client + /// decrypting a password where it expected a display name. + /// + public static AadDescriptor ItemMetadata( + CryptoSpec.AadResourceType resourceType, + Guid itemId, + Guid dataKeyId, + uint keyGeneration, + uint itemVersion) => + AadDescriptor.Create( + CryptoSpec.AadPurpose.ItemMetadata, + resourceType, + itemId, + dataKeyId, + keyGeneration, + itemVersion); + + /// + /// Binds a record in the client's own on-disk cache. + /// + /// + /// Separate from every server-side purpose so a cache record can never be accepted as vault + /// content, nor the reverse. The cache is local, so the adversary here is another process on + /// the same machine rather than the server. + /// + public static AadDescriptor LocalCache(Guid userId) => + AadDescriptor.Create( + CryptoSpec.AadPurpose.LocalCache, + CryptoSpec.AadResourceType.User, + userId); +} diff --git a/src/DodoSSH.Crypto/ItemKeys.cs b/src/DodoSSH.Crypto/ItemKeys.cs new file mode 100644 index 0000000..ad211dd --- /dev/null +++ b/src/DodoSSH.Crypto/ItemKeys.cs @@ -0,0 +1,140 @@ +using System.Security.Cryptography; + +namespace DodoSSH.Crypto; + +/// +/// Per-item data keys and the payloads they protect. See docs/crypto.md §3. +/// +/// +/// +/// Every item version gets its own 32-byte data key, wrapped under the vault key. Four reasons, all +/// of which matter: rotating a vault key re-wraps N × 32 bytes and never touches a content blob, so a +/// 10,000-item vault rotates in a few hundred kilobytes; a single item can be re-wrapped to another +/// key without disturbing the rest; each key encrypts about one message, which makes nonce-collision +/// analysis moot; and a new version gets a new key, so earlier ciphertext stays independently +/// decryptable for history and undo. +/// +/// +/// Be plain about what this does not yet buy. Data keys are wrapped under the vault key, not +/// to individual users, so anyone holding the vault key can decrypt any ciphertext they obtain. Until +/// per-item grants land in M5, an item-level ACL is access control, not cryptographic isolation. The +/// content_key_id column exists from the first migration so that change needs no migration. +/// +/// +public static class ItemKeys +{ + /// Generates a fresh data key. The caller owns and must zero it. + public static byte[] CreateDataKey() => + RandomNumberGenerator.GetBytes(CryptoSpec.SymmetricKeySize); + + /// Wraps an item's data key under the vault key. + /// The 32-byte data key. + /// The vault key it is wrapped under. + /// What kind of item this is. + /// The item. + /// Vault key generation in force. + /// Item version. + public static byte[] WrapDataKey( + ReadOnlySpan dataKey, + ReadOnlySpan vaultKey, + CryptoSpec.AadResourceType resourceType, + Guid itemId, + uint keyGeneration, + uint itemVersion) + { + RequireDataKey(dataKey); + VaultKeys.RequireVaultKey(vaultKey); + + return DshCrypto.Seal( + vaultKey, + dataKey, + DshAad.ItemDataKey(resourceType, itemId, keyGeneration, itemVersion)); + } + + /// Opens a wrapped data key. + /// The data key, or if it does not belong to this item, version + /// or generation. + public static byte[]? TryUnwrapDataKey( + ReadOnlySpan vaultKey, + ReadOnlySpan envelope, + CryptoSpec.AadResourceType resourceType, + Guid itemId, + uint keyGeneration, + uint itemVersion) + { + VaultKeys.RequireVaultKey(vaultKey); + + var plaintext = DshCrypto.Open( + vaultKey, + envelope, + DshAad.ItemDataKey(resourceType, itemId, keyGeneration, itemVersion)); + + if (plaintext is null) + { + return null; + } + + if (plaintext.Length != CryptoSpec.SymmetricKeySize) + { + CryptographicOperations.ZeroMemory(plaintext); + return null; + } + + return plaintext; + } + + /// Encrypts an item's payload under its data key. + /// The item's data key. + /// The payload. + /// What kind of item this is. + /// The item. + /// The data key's identifier, stored as content_key_id. + /// Vault key generation in force. + /// Item version. + public static byte[] SealPayload( + ReadOnlySpan dataKey, + ReadOnlySpan plaintext, + CryptoSpec.AadResourceType resourceType, + Guid itemId, + Guid dataKeyId, + uint keyGeneration, + uint itemVersion) + { + RequireDataKey(dataKey); + + return DshCrypto.Seal( + dataKey, + plaintext, + DshAad.ItemPayload(resourceType, itemId, dataKeyId, keyGeneration, itemVersion)); + } + + /// Decrypts an item's payload. + /// The payload, or on any mismatch. A null here after a + /// successful data key unwrap means the server moved, rolled back or substituted the blob. + public static byte[]? TryOpenPayload( + ReadOnlySpan dataKey, + ReadOnlySpan envelope, + CryptoSpec.AadResourceType resourceType, + Guid itemId, + Guid dataKeyId, + uint keyGeneration, + uint itemVersion) + { + RequireDataKey(dataKey); + + return DshCrypto.Open( + dataKey, + envelope, + DshAad.ItemPayload(resourceType, itemId, dataKeyId, keyGeneration, itemVersion)); + } + + private static void RequireDataKey(ReadOnlySpan dataKey) + { + if (dataKey.Length != CryptoSpec.SymmetricKeySize) + { + throw new ArgumentException( + $"A data key is {CryptoSpec.SymmetricKeySize} bytes, got {dataKey.Length}.", + nameof(dataKey)); + } + } +} diff --git a/src/DodoSSH.Crypto/MasterKey.cs b/src/DodoSSH.Crypto/MasterKey.cs new file mode 100644 index 0000000..fb11a7d --- /dev/null +++ b/src/DodoSSH.Crypto/MasterKey.cs @@ -0,0 +1,159 @@ +using System.Security.Cryptography; + +namespace DodoSSH.Crypto; + +/// +/// The master key derived from a vault passphrase, and the subkeys it yields. See docs/crypto.md §3. +/// +/// +/// +/// RAM only. Never persisted, never transmitted, never written to a keystore. What is +/// cached locally is the KDF salt and the wrapped bundle, because unlock has to work with no +/// network: a salt fetched at unlock time would mean an offline launch cannot open the vault, which +/// is the single most common thing a user does on a plane. +/// +/// +/// Argon2id parameters travel with each wrap rather than being global, so raising them later is a +/// per-user migration at next unlock rather than a breaking change — an older client can still open +/// its own wrap. See docs/crypto.md §2 for why the memory unit needs guarding. +/// +/// +public sealed class MasterKey : IDisposable +{ + private readonly byte[] material = new byte[CryptoSpec.MasterKeySize]; + private bool disposed; + + private MasterKey(ReadOnlySpan derived) => derived.CopyTo(material); + + /// + /// Stretches a passphrase into the master key. + /// + /// + /// The cost is deliberately high enough to be noticeable — a few hundred milliseconds on a + /// desktop at the default profile — because this is the only thing standing between a stolen + /// database dump and every credential in the vault. + /// + /// The user's vault passphrase. + /// Per-wrap salt, at least 16 bytes. Not a secret. + /// Argon2id cost parameters. + public static MasterKey Derive(string passphrase, ReadOnlySpan salt, Argon2Profile profile) + { + ArgumentException.ThrowIfNullOrEmpty(passphrase); + ArgumentNullException.ThrowIfNull(profile); + + if (salt.Length < CryptoSpec.SaltSize) + { + throw new ArgumentException( + $"Salt must be at least {CryptoSpec.SaltSize} bytes, got {salt.Length}.", + nameof(salt)); + } + + var derived = profile + .CreateAlgorithm() + .DeriveBytes(passphrase, salt, CryptoSpec.MasterKeySize); + + try + { + return new MasterKey(derived); + } + finally + { + CryptographicOperations.ZeroMemory(derived); + } + } + + /// + /// Derives the key that encrypts the client's on-disk cache. + /// + /// + /// Domain-separated from the bundle's key-encryption key by its HKDF info label, so a cache + /// record can never be opened with the wrap key or the reverse — the two live in very different + /// threat models and must not share a key. + /// + public void DeriveLocalCacheKey(Span destination) => + DeriveSubkey(CryptoSpec.DerivationLabels.LocalCache, destination); + + /// Wraps a bundle under the passphrase-derived key-encryption key. + /// + /// The key-encryption key is never handed out. Callers that only need to wrap or unwrap should + /// not be holding it, and one that does is one more place it can be leaked or logged. + /// + public byte[] WrapBundle(UserSecretBundle bundle, in AadDescriptor descriptor) + { + ArgumentNullException.ThrowIfNull(bundle); + ObjectDisposedException.ThrowIf(disposed, this); + + Span kek = stackalloc byte[CryptoSpec.SymmetricKeySize]; + try + { + DeriveSubkey(CryptoSpec.DerivationLabels.PassphraseKek, kek); + return bundle.WrapUnder(kek, descriptor); + } + finally + { + CryptographicOperations.ZeroMemory(kek); + } + } + + /// + /// Opens a bundle wrapped by . + /// + /// + /// The bundle, or when the passphrase is wrong or the wrap does not + /// belong to this descriptor. A wrong passphrase is the overwhelmingly common case, so it is a + /// return value rather than an exception. + /// + public UserSecretBundle? TryOpenBundle(ReadOnlySpan envelope, in AadDescriptor descriptor) + { + ObjectDisposedException.ThrowIf(disposed, this); + + Span kek = stackalloc byte[CryptoSpec.SymmetricKeySize]; + try + { + DeriveSubkey(CryptoSpec.DerivationLabels.PassphraseKek, kek); + return UserSecretBundle.TryOpenUnder(kek, envelope, descriptor); + } + finally + { + CryptographicOperations.ZeroMemory(kek); + } + } + + /// + public void Dispose() + { + if (disposed) + { + return; + } + + disposed = true; + CryptographicOperations.ZeroMemory(material); + } + + /// + /// + /// HKDF-Expand rather than the full extract-then-expand: the master key is already a uniformly + /// random Argon2id output, so there is no low-entropy input left for an extract step to + /// condition. RFC 5869 §3.3 covers exactly this case. + /// + /// + /// That choice is why the master key is bytes rather than + /// 32: skipping extract means the master key is the PRK, and .NET's + /// HKDF.Expand rejects a PRK shorter than the hash output. + /// + /// + private void DeriveSubkey(ReadOnlySpan info, Span destination) + { + ObjectDisposedException.ThrowIf(disposed, this); + + if (destination.Length != CryptoSpec.SymmetricKeySize) + { + throw new ArgumentException( + $"Subkeys are {CryptoSpec.SymmetricKeySize} bytes, got {destination.Length}.", + nameof(destination)); + } + + HKDF.Expand(HashAlgorithmName.SHA512, material, destination, info); + } +} diff --git a/src/DodoSSH.Crypto/UserSecretBundle.cs b/src/DodoSSH.Crypto/UserSecretBundle.cs new file mode 100644 index 0000000..1584ce3 --- /dev/null +++ b/src/DodoSSH.Crypto/UserSecretBundle.cs @@ -0,0 +1,322 @@ +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)); + } + + /// + /// 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; + + private 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; + } +} diff --git a/src/DodoSSH.Crypto/VaultKeys.cs b/src/DodoSSH.Crypto/VaultKeys.cs new file mode 100644 index 0000000..fb4f42e --- /dev/null +++ b/src/DodoSSH.Crypto/VaultKeys.cs @@ -0,0 +1,109 @@ +using System.Security.Cryptography; +using NSec.Cryptography; + +namespace DodoSSH.Crypto; + +/// +/// Vault keys and the grants that wrap them to members. See docs/crypto.md §3 and §6. +/// +/// +/// +/// A vault key is 32 random bytes, one per vault per generation. It never leaves a client in +/// plaintext: each member holds it sealed to their own X25519 key, which is why the server can store +/// every grant and read none of them. +/// +/// +/// Sealing is anonymous-sender by construction, so a grant proves nothing about who created it. That +/// is why every grant additionally carries a detached Ed25519 signature from the granter — without +/// one, a server could fabricate a grant containing a key of its own choosing and the recipient +/// would have no way to tell. The signature makes it detectable and attributable; it cannot make it +/// impossible, because verification would require the server to hold the key. +/// +/// +public static class VaultKeys +{ + /// + /// Generates a fresh vault key. + /// + /// + /// Returned as an array the caller owns and must zero. There is no wrapper type here on purpose: + /// the key is needed as a raw span by every item operation, and a type that had to be unwrapped + /// at each call site would be unwrapped carelessly. + /// + public static byte[] Create() => + RandomNumberGenerator.GetBytes(CryptoSpec.SymmetricKeySize); + + /// + /// Seals a vault key to a member's encryption key. + /// + /// The 32-byte vault key. + /// + /// The recipient's X25519 public key. Verify this key before calling. Wrapping to an + /// unverified key is the one mistake that undoes end-to-end encryption entirely — check the + /// identity-provider binding, the pinned fingerprint and the key log head first. See ADR 0001. + /// + /// The vault, which the AAD binds this grant to. + /// + /// The generation in force. Part of the AAD, so a server cannot re-serve a superseded + /// generation's grant after a rekey. + /// + public static byte[] WrapTo( + ReadOnlySpan vaultKey, + ReadOnlySpan recipientEncryptionPublicKey, + Guid vaultId, + uint keyGeneration) + { + RequireVaultKey(vaultKey); + + return DshCrypto.SealTo( + recipientEncryptionPublicKey, + vaultKey, + DshAad.VaultKeyGrant(vaultId, keyGeneration)); + } + + /// + /// Opens a grant with the recipient's private key. + /// + /// + /// The vault key, or if the grant is not for this key, not for this vault, + /// or not for this generation. A malicious granter can seal garbage; that surfaces here as a + /// null, and the grant's signature names who did it. + /// + public static byte[]? TryUnwrap( + Key recipientKey, + ReadOnlySpan envelope, + Guid vaultId, + uint keyGeneration) + { + var plaintext = DshCrypto.OpenSealed( + recipientKey, + envelope, + DshAad.VaultKeyGrant(vaultId, keyGeneration)); + + if (plaintext is null) + { + return null; + } + + // A grant that opens but does not contain a key-sized value is not a vault key. Failing here + // rather than passing it on means the error names the grant instead of surfacing later as an + // unexplained item decryption failure. + if (plaintext.Length != CryptoSpec.SymmetricKeySize) + { + CryptographicOperations.ZeroMemory(plaintext); + return null; + } + + return plaintext; + } + + internal static void RequireVaultKey(ReadOnlySpan vaultKey) + { + if (vaultKey.Length != CryptoSpec.SymmetricKeySize) + { + throw new ArgumentException( + $"A vault key is {CryptoSpec.SymmetricKeySize} bytes, got {vaultKey.Length}.", + nameof(vaultKey)); + } + } +} diff --git a/tests/DodoSSH.Crypto.Tests/UserSecretBundleTests.cs b/tests/DodoSSH.Crypto.Tests/UserSecretBundleTests.cs new file mode 100644 index 0000000..a548bed --- /dev/null +++ b/tests/DodoSSH.Crypto.Tests/UserSecretBundleTests.cs @@ -0,0 +1,550 @@ +using System.Buffers.Binary; +using System.Globalization; +using NSec.Cryptography; + +namespace DodoSSH.Crypto.Tests; + +/// +/// The user secret bundle and the passphrase-derived keys that wrap it. See docs/crypto.md §3. +/// +/// +/// The encoding is checked against an independent implementation written here rather than by +/// round-tripping production code against itself. A round trip passes just as happily when both +/// directions are wrong in the same way, and this format cannot be changed after a single bundle has +/// been stored — the server holds no keys, so only clients could ever re-encrypt. +/// +public sealed class UserSecretBundleTests +{ + private static readonly Guid Alice = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f"); + private static readonly Guid Bob = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e10"); + private static readonly DateTimeOffset CreatedAt = DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_123); + + /// + /// The layout of §3.1, pinned. Built from the fixed keys below at generation 1 and + /// . + /// + /// + /// This constant is the golden vector for the bundle format. A change here that is not a + /// deliberate, versioned format change makes every stored wrap unopenable, with no server-side + /// remedy and no rollback. + /// + private const string ExpectedEncodingHex = + "647368312f62756e646c652f7631" // "dsh1/bundle/v1" + + "0001" // version 1 + + "00000001" // key generation 1 + + "000001977420dc7b" // created at, Unix ms + + "4041424344454647484950515253545556575859606162636465666768697071" // x25519 sk + + "8081828384858687888990919293949596979899a0a1a2a3a4a5a6a7a8a9b0b1"; // ed25519 sk + + private static byte[] EncryptionPrivateKey { get; } = + Convert.FromHexString("4041424344454647484950515253545556575859606162636465666768697071"); + + private static byte[] SigningPrivateKey { get; } = + Convert.FromHexString("8081828384858687888990919293949596979899a0a1a2a3a4a5a6a7a8a9b0b1"); + + // ---- Encoding ---- + + [Fact] + public void TheEncodedLength_IsNinetyTwoBytes() + { + UserSecretBundle.EncodedLength.ShouldBe(92); + ExpectedEncodingHex.Length.ShouldBe(UserSecretBundle.EncodedLength * 2); + } + + [Fact] + public void TheDecoder_AcceptsAnIndependentlyBuiltEncoding() + { + // Proves the reader matches the specification rather than matching our writer. + Span kek = stackalloc byte[CryptoSpec.SymmetricKeySize]; + FillKek(kek); + + var descriptor = DshAad.UserSecretBundle(Alice); + var envelope = DshCrypto.Seal(kek, Convert.FromHexString(ExpectedEncodingHex), descriptor); + + using var bundle = UserSecretBundle.TryOpenUnder(kek, envelope, descriptor); + + bundle.ShouldNotBeNull(); + bundle.KeyGeneration.ShouldBe(1u); + bundle.CreatedAt.ToUnixTimeMilliseconds().ShouldBe(CreatedAt.ToUnixTimeMilliseconds()); + bundle.EncryptionPublicKey.ShouldBe(PublicKeyOf(KeyAgreementAlgorithm.X25519, EncryptionPrivateKey)); + bundle.SigningPublicKey.ShouldBe(PublicKeyOf(SignatureAlgorithm.Ed25519, SigningPrivateKey)); + } + + [Fact] + public void TheEncoder_ProducesWhatAnIndependentDecoderExpects() + { + Span kek = stackalloc byte[CryptoSpec.SymmetricKeySize]; + FillKek(kek); + + using var bundle = UserSecretBundle.Create(CreatedAt, keyGeneration: 3); + var descriptor = DshAad.UserSecretBundle(Alice, keyGeneration: 3); + + var envelope = bundle.WrapUnder(kek, descriptor); + var plaintext = DshCrypto.Open(kek, envelope, descriptor); + + plaintext.ShouldNotBeNull(); + plaintext.Length.ShouldBe(UserSecretBundle.EncodedLength); + + var decoded = DecodeIndependently(plaintext); + + decoded.Label.ShouldBe("dsh1/bundle/v1"); + decoded.Version.ShouldBe(1); + decoded.KeyGeneration.ShouldBe(3u); + decoded.CreatedAtUnixMilliseconds.ShouldBe(CreatedAt.ToUnixTimeMilliseconds()); + + // The private keys in the encoding must be the ones whose public halves the bundle reports. + PublicKeyOf(KeyAgreementAlgorithm.X25519, decoded.EncryptionPrivateKey) + .ShouldBe(bundle.EncryptionPublicKey); + PublicKeyOf(SignatureAlgorithm.Ed25519, decoded.SigningPrivateKey) + .ShouldBe(bundle.SigningPublicKey); + } + + [Fact] + public void ASubMillisecondTimestamp_IsTruncatedSoTheEncodingIsStable() + { + using var exact = UserSecretBundle.Create(CreatedAt); + using var noisy = UserSecretBundle.Create(CreatedAt.AddTicks(9_999)); + + noisy.CreatedAt.ShouldBe(exact.CreatedAt); + } + + // ---- The keys inside actually work ---- + + [Fact] + public void TheSigningKey_ProducesSignaturesItsPublicHalfVerifies() + { + using var bundle = UserSecretBundle.Create(CreatedAt); + + var statement = KeyStatementCodec.Encode(new KeyStatementFields( + 1, "https://idp.example", "alice", null, + bundle.EncryptionPublicKey, bundle.SigningPublicKey, + 1, CreatedAt, "laptop")); + + var signature = DshSignatures.SignKeyStatement(bundle.SigningKey, statement); + + DshSignatures.VerifyKeyStatement(bundle.SigningPublicKey, statement, signature).ShouldBeTrue(); + } + + [Fact] + public void TheEncryptionKey_OpensWhatWasSealedToItsPublicHalf() + { + using var bundle = UserSecretBundle.Create(CreatedAt); + + var vaultKey = VaultKeys.Create(); + var grant = VaultKeys.WrapTo(vaultKey, bundle.EncryptionPublicKey, Alice, 1); + + VaultKeys.TryUnwrap(bundle.EncryptionKey, grant, Alice, 1).ShouldBe(vaultKey); + } + + [Fact] + public void TheTwoKeys_AreDistinct() + { + using var bundle = UserSecretBundle.Create(CreatedAt); + + bundle.EncryptionPublicKey.ShouldNotBe(bundle.SigningPublicKey); + } + + // ---- The AAD binding ---- + + [Fact] + public void AWrapForAnotherUser_DoesNotOpen() + { + // The property that matters most. Without it a server could hand Bob's wrap to Alice, and if + // they ever shared a passphrase she would silently unlock his identity. + Span kek = stackalloc byte[CryptoSpec.SymmetricKeySize]; + FillKek(kek); + + using var bundle = UserSecretBundle.Create(CreatedAt); + var envelope = bundle.WrapUnder(kek, DshAad.UserSecretBundle(Alice)); + + UserSecretBundle.TryOpenUnder(kek, envelope, DshAad.UserSecretBundle(Bob)).ShouldBeNull(); + } + + [Fact] + public void AWrapFromAnotherKeyGeneration_DoesNotOpen() + { + // Stops a server serving back a superseded bundle after a key rotation. + Span kek = stackalloc byte[CryptoSpec.SymmetricKeySize]; + FillKek(kek); + + using var bundle = UserSecretBundle.Create(CreatedAt, keyGeneration: 2); + var envelope = bundle.WrapUnder(kek, DshAad.UserSecretBundle(Alice, keyGeneration: 2)); + + UserSecretBundle.TryOpenUnder(kek, envelope, DshAad.UserSecretBundle(Alice, keyGeneration: 1)) + .ShouldBeNull(); + } + + [Fact] + public void AWrongKey_ReturnsNullRatherThanThrowing() + { + // The overwhelmingly common case is a mistyped passphrase, so this is a return value. + Span kek = stackalloc byte[CryptoSpec.SymmetricKeySize]; + FillKek(kek); + + using var bundle = UserSecretBundle.Create(CreatedAt); + var envelope = bundle.WrapUnder(kek, DshAad.UserSecretBundle(Alice)); + + UserSecretBundle.TryOpenUnder(new byte[32], envelope, DshAad.UserSecretBundle(Alice)) + .ShouldBeNull(); + } + + [Fact] + public void ASealedWrap_DoesNotOpenAsASymmetricOne() + { + using var device = Key.Create(KeyAgreementAlgorithm.X25519); + using var bundle = UserSecretBundle.Create(CreatedAt); + + var descriptor = DshAad.UserSecretBundle(Alice); + var sealedWrap = bundle.SealTo(device.PublicKey.Export(KeyBlobFormat.RawPublicKey), descriptor); + + UserSecretBundle.TryOpenUnder(new byte[32], sealedWrap, descriptor).ShouldBeNull(); + } + + // ---- Device wraps ---- + + [Fact] + public void ADeviceWrap_OpensWithTheDeviceKey() + { + using var device = Key.Create(KeyAgreementAlgorithm.X25519); + using var original = UserSecretBundle.Create(CreatedAt); + + var descriptor = DshAad.UserSecretBundle(Alice); + var wrap = original.SealTo(device.PublicKey.Export(KeyBlobFormat.RawPublicKey), descriptor); + + using var reopened = UserSecretBundle.TryOpenSealed(device, wrap, descriptor); + + reopened.ShouldNotBeNull(); + reopened.EncryptionPublicKey.ShouldBe(original.EncryptionPublicKey); + reopened.SigningPublicKey.ShouldBe(original.SigningPublicKey); + } + + [Fact] + public void ADeviceWrap_DoesNotOpenWithAnotherDevicesKey() + { + using var device = Key.Create(KeyAgreementAlgorithm.X25519); + using var other = Key.Create(KeyAgreementAlgorithm.X25519); + using var bundle = UserSecretBundle.Create(CreatedAt); + + var descriptor = DshAad.UserSecretBundle(Alice); + var wrap = bundle.SealTo(device.PublicKey.Export(KeyBlobFormat.RawPublicKey), descriptor); + + UserSecretBundle.TryOpenSealed(other, wrap, descriptor).ShouldBeNull(); + } + + [Fact] + public void ManyWrapsOfOneBundle_AllYieldTheSameIdentity() + { + // The load-bearing property of the whole hierarchy: a passphrase change re-wraps ~92 bytes + // and touches one row, because every wrap protects the same bundle. + using var device = Key.Create(KeyAgreementAlgorithm.X25519); + using var bundle = UserSecretBundle.Create(CreatedAt); + var descriptor = DshAad.UserSecretBundle(Alice); + + Span passphraseKek = stackalloc byte[CryptoSpec.SymmetricKeySize]; + FillKek(passphraseKek); + + Span recoveryKek = stackalloc byte[CryptoSpec.SymmetricKeySize]; + recoveryKek.Fill(0x5A); + + using var viaPassphrase = UserSecretBundle.TryOpenUnder( + passphraseKek, bundle.WrapUnder(passphraseKek, descriptor), descriptor); + using var viaRecovery = UserSecretBundle.TryOpenUnder( + recoveryKek, bundle.WrapUnder(recoveryKek, descriptor), descriptor); + using var viaDevice = UserSecretBundle.TryOpenSealed( + device, + bundle.SealTo(device.PublicKey.Export(KeyBlobFormat.RawPublicKey), descriptor), + descriptor); + + foreach (var opened in new[] { viaPassphrase, viaRecovery, viaDevice }) + { + opened.ShouldNotBeNull(); + opened.EncryptionPublicKey.ShouldBe(bundle.EncryptionPublicKey); + opened.SigningPublicKey.ShouldBe(bundle.SigningPublicKey); + } + } + + // ---- Malformed plaintext ---- + + [Theory] + [InlineData(0)] + [InlineData(91)] + [InlineData(93)] + [InlineData(200)] + public void APlaintextOfTheWrongLength_IsRejected(int length) + { + Span kek = stackalloc byte[CryptoSpec.SymmetricKeySize]; + FillKek(kek); + + var descriptor = DshAad.UserSecretBundle(Alice); + var envelope = DshCrypto.Seal(kek, new byte[length], descriptor); + + UserSecretBundle.TryOpenUnder(kek, envelope, descriptor).ShouldBeNull(); + } + + [Fact] + public void APlaintextWithTheWrongLabel_IsRejected() + { + Span kek = stackalloc byte[CryptoSpec.SymmetricKeySize]; + FillKek(kek); + + var tampered = Convert.FromHexString(ExpectedEncodingHex); + tampered[0] ^= 0xFF; + + var descriptor = DshAad.UserSecretBundle(Alice); + var envelope = DshCrypto.Seal(kek, tampered, descriptor); + + UserSecretBundle.TryOpenUnder(kek, envelope, descriptor).ShouldBeNull(); + } + + [Fact] + public void APlaintextWithAnUnknownVersion_IsRejected() + { + Span kek = stackalloc byte[CryptoSpec.SymmetricKeySize]; + FillKek(kek); + + var tampered = Convert.FromHexString(ExpectedEncodingHex); + BinaryPrimitives.WriteUInt16BigEndian(tampered.AsSpan(14), 2); + + var descriptor = DshAad.UserSecretBundle(Alice); + var envelope = DshCrypto.Seal(kek, tampered, descriptor); + + UserSecretBundle.TryOpenUnder(kek, envelope, descriptor).ShouldBeNull(); + } + + [Fact] + public void APlaintextWithGenerationZero_IsRejected() + { + Span kek = stackalloc byte[CryptoSpec.SymmetricKeySize]; + FillKek(kek); + + var tampered = Convert.FromHexString(ExpectedEncodingHex); + BinaryPrimitives.WriteUInt32BigEndian(tampered.AsSpan(16), 0); + + var descriptor = DshAad.UserSecretBundle(Alice); + var envelope = DshCrypto.Seal(kek, tampered, descriptor); + + UserSecretBundle.TryOpenUnder(kek, envelope, descriptor).ShouldBeNull(); + } + + // ---- Lifetime ---- + + [Fact] + public void UsingADisposedBundle_Throws() + { + var bundle = UserSecretBundle.Create(CreatedAt); + bundle.Dispose(); + + Should.Throw(() => bundle.SigningKey); + Should.Throw(() => bundle.EncryptionKey); + } + + [Fact] + public void DisposingTwice_IsHarmless() + { + var bundle = UserSecretBundle.Create(CreatedAt); + + bundle.Dispose(); + Should.NotThrow(bundle.Dispose); + } + + [Fact] + public void Create_RejectsGenerationZero() + { + Should.Throw(() => UserSecretBundle.Create(CreatedAt, 0)); + } + + // ---- Independent codec, for the differential assertions above ---- + + private static byte[] PublicKeyOf(Algorithm algorithm, byte[] privateKey) + { + using var key = Key.Import(algorithm, privateKey, KeyBlobFormat.RawPrivateKey); + return key.PublicKey.Export(KeyBlobFormat.RawPublicKey); + } + + private static void FillKek(Span destination) + { + for (var i = 0; i < destination.Length; i++) + { + destination[i] = (byte)(0x10 + i); + } + } + + /// Reads §3.1 from the document, independently of the production decoder. + private static DecodedBundle DecodeIndependently(byte[] encoded) => + new( + Label: System.Text.Encoding.ASCII.GetString(encoded, 0, 14), + Version: BinaryPrimitives.ReadUInt16BigEndian(encoded.AsSpan(14)), + KeyGeneration: BinaryPrimitives.ReadUInt32BigEndian(encoded.AsSpan(16)), + CreatedAtUnixMilliseconds: BinaryPrimitives.ReadInt64BigEndian(encoded.AsSpan(20)), + EncryptionPrivateKey: encoded[28..60], + SigningPrivateKey: encoded[60..92]); + + private sealed record DecodedBundle( + string Label, + int Version, + uint KeyGeneration, + long CreatedAtUnixMilliseconds, + byte[] EncryptionPrivateKey, + byte[] SigningPrivateKey); +} + +/// +/// Passphrase stretching and subkey derivation. See docs/crypto.md §2 and §3. +/// +/// +/// Uses the cheapest profile throughout. These tests are about the shape of the hierarchy, not about +/// how long Argon2id takes; the default profile would add several seconds per case for no extra +/// coverage. Cost calibration is a measurement, recorded in docs/platform-flags.md. +/// +public sealed class MasterKeyTests +{ + private static readonly Guid Alice = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f"); + private static readonly DateTimeOffset CreatedAt = DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_123); + private const string Passphrase = "correct horse battery staple"; + + private static byte[] Salt { get; } = + [.. Enumerable.Range(0, CryptoSpec.SaltSize).Select(i => (byte)(0x20 + i))]; + + [Fact] + public void ThePassphrase_UnlocksTheBundleItWrapped() + { + var descriptor = DshAad.UserSecretBundle(Alice); + + byte[] wrap; + byte[] expectedSigningKey; + + using (var master = Derive()) + using (var bundle = UserSecretBundle.Create(CreatedAt)) + { + wrap = master.WrapBundle(bundle, descriptor); + expectedSigningKey = bundle.SigningPublicKey; + } + + // A fresh derivation, as a later unlock on another device would do. + using var reopenedMaster = Derive(); + using var reopened = reopenedMaster.TryOpenBundle(wrap, descriptor); + + reopened.ShouldNotBeNull(); + reopened.SigningPublicKey.ShouldBe(expectedSigningKey); + } + + [Fact] + public void AWrongPassphrase_ReturnsNull() + { + var descriptor = DshAad.UserSecretBundle(Alice); + + using var master = Derive(); + using var bundle = UserSecretBundle.Create(CreatedAt); + var wrap = master.WrapBundle(bundle, descriptor); + + using var wrong = MasterKey.Derive("not the passphrase", Salt, Argon2Profile.RandomSecret); + + wrong.TryOpenBundle(wrap, descriptor).ShouldBeNull(); + } + + [Fact] + public void ADifferentSalt_ProducesADifferentKey() + { + // Which is why the salt has to be cached locally: unlock must work offline, and fetching the + // salt at unlock time would make an offline launch impossible. + var descriptor = DshAad.UserSecretBundle(Alice); + + using var master = Derive(); + using var bundle = UserSecretBundle.Create(CreatedAt); + var wrap = master.WrapBundle(bundle, descriptor); + + var otherSalt = Salt.ToArray(); + otherSalt[0] ^= 0xFF; + + using var other = MasterKey.Derive(Passphrase, otherSalt, Argon2Profile.RandomSecret); + + other.TryOpenBundle(wrap, descriptor).ShouldBeNull(); + } + + [Fact] + public void TheLocalCacheKey_CannotOpenABundleWrap() + { + // Domain separation, tested through behaviour rather than by comparing derived bytes. The + // cache and the vault live in different threat models and must not share a key. + var descriptor = DshAad.UserSecretBundle(Alice); + + using var master = Derive(); + using var bundle = UserSecretBundle.Create(CreatedAt); + var wrap = master.WrapBundle(bundle, descriptor); + + Span cacheKey = stackalloc byte[CryptoSpec.SymmetricKeySize]; + master.DeriveLocalCacheKey(cacheKey); + + UserSecretBundle.TryOpenUnder(cacheKey, wrap, descriptor).ShouldBeNull(); + } + + [Fact] + public void TheLocalCacheKey_IsStableForTheSamePassphraseAndSalt() + { + Span first = stackalloc byte[CryptoSpec.SymmetricKeySize]; + Span second = stackalloc byte[CryptoSpec.SymmetricKeySize]; + + using (var master = Derive()) + { + master.DeriveLocalCacheKey(first); + } + + using (var master = Derive()) + { + master.DeriveLocalCacheKey(second); + } + + first.SequenceEqual(second).ShouldBeTrue(); + } + + [Fact] + public void ASaltShorterThanTheSpecifiedMinimum_IsRejected() + { + Should.Throw(() => + MasterKey.Derive(Passphrase, new byte[CryptoSpec.SaltSize - 1], Argon2Profile.RandomSecret)); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void AnEmptyPassphrase_IsRejected(string? passphrase) + { + Should.Throw(() => + MasterKey.Derive(passphrase!, Salt, Argon2Profile.RandomSecret)); + } + + [Fact] + public void UsingADisposedMasterKey_Throws() + { + var master = Derive(); + master.Dispose(); + + Should.Throw(() => + { + var buffer = new byte[CryptoSpec.SymmetricKeySize]; + master.DeriveLocalCacheKey(buffer); + }); + } + + [Fact] + public void ASubkeyBufferOfTheWrongSize_IsRejected() + { + using var master = Derive(); + + Should.Throw(() => master.DeriveLocalCacheKey(new byte[16])); + } + + [Fact] + public void TheCheapestProfile_IsStillTheSpecifiedFloor() + { + // Guards the constant these tests lean on: if RandomSecret ever dropped below the server's + // enrollment floor, the tests would be exercising parameters the API rejects. + Argon2Profile.RandomSecret.MemoryKibibytes + .ShouldBeGreaterThanOrEqualTo( + 64 * 1024, + string.Create(CultureInfo.InvariantCulture, $"64 MiB is the enrollment floor.")); + } + + private static MasterKey Derive() => + MasterKey.Derive(Passphrase, Salt, Argon2Profile.RandomSecret); +} diff --git a/tests/DodoSSH.Crypto.Tests/VaultAndItemKeyTests.cs b/tests/DodoSSH.Crypto.Tests/VaultAndItemKeyTests.cs new file mode 100644 index 0000000..e54c07c --- /dev/null +++ b/tests/DodoSSH.Crypto.Tests/VaultAndItemKeyTests.cs @@ -0,0 +1,291 @@ +using NSec.Cryptography; + +namespace DodoSSH.Crypto.Tests; + +/// +/// Vault key grants, per-item data keys, and the AAD binding that holds them in place. +/// +/// +/// Most of these are negative. The AAD binding is the most valuable structural property in the +/// design — it is what stops a server that holds every ciphertext from moving one between rows, +/// rolling a row back, or replaying a revoked grant — and a property like that is only demonstrated +/// by the substitutions that fail. +/// +public sealed class VaultAndItemKeyTests +{ + 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 ItemA = Guid.Parse("0192f0c8-3333-7c3d-8e4f-5a6b7c8d9e0f"); + private static readonly Guid ItemB = Guid.Parse("0192f0c8-4444-7c3d-8e4f-5a6b7c8d9e0f"); + private static readonly Guid DataKeyId = Guid.Parse("0192f0c8-5555-7c3d-8e4f-5a6b7c8d9e0f"); + + // ---- Vault keys ---- + + [Fact] + public void AVaultKey_IsThirtyTwoRandomBytes() + { + var first = VaultKeys.Create(); + var second = VaultKeys.Create(); + + first.Length.ShouldBe(CryptoSpec.SymmetricKeySize); + first.ShouldNotBe(second); + } + + [Fact] + public void AGrant_OpensForItsRecipient() + { + using var member = Key.Create(KeyAgreementAlgorithm.X25519); + var vaultKey = VaultKeys.Create(); + + var grant = VaultKeys.WrapTo( + vaultKey, member.PublicKey.Export(KeyBlobFormat.RawPublicKey), VaultA, 1); + + VaultKeys.TryUnwrap(member, grant, VaultA, 1).ShouldBe(vaultKey); + } + + [Fact] + public void AGrantForAnotherVault_DoesNotOpen() + { + using var member = Key.Create(KeyAgreementAlgorithm.X25519); + var vaultKey = VaultKeys.Create(); + + var grant = VaultKeys.WrapTo( + vaultKey, member.PublicKey.Export(KeyBlobFormat.RawPublicKey), VaultA, 1); + + VaultKeys.TryUnwrap(member, grant, VaultB, 1).ShouldBeNull(); + } + + [Fact] + public void AGrantFromASupersededGeneration_DoesNotOpen() + { + // This is what makes a rekey stick against a malicious server: it cannot re-serve the old + // generation's grant to a removed member. Bounded to future reads only — whatever they + // already downloaded is already gone, which is why offboarding means rotating the SSH + // credentials themselves. + using var member = Key.Create(KeyAgreementAlgorithm.X25519); + var vaultKey = VaultKeys.Create(); + + var grant = VaultKeys.WrapTo( + vaultKey, member.PublicKey.Export(KeyBlobFormat.RawPublicKey), VaultA, 1); + + VaultKeys.TryUnwrap(member, grant, VaultA, 2).ShouldBeNull(); + } + + [Fact] + public void AGrantForAnotherMember_DoesNotOpen() + { + using var member = Key.Create(KeyAgreementAlgorithm.X25519); + using var intruder = Key.Create(KeyAgreementAlgorithm.X25519); + var vaultKey = VaultKeys.Create(); + + var grant = VaultKeys.WrapTo( + vaultKey, member.PublicKey.Export(KeyBlobFormat.RawPublicKey), VaultA, 1); + + VaultKeys.TryUnwrap(intruder, grant, VaultA, 1).ShouldBeNull(); + } + + [Fact] + public void AGrantContainingSomethingOtherThanAKey_IsRejected() + { + // A malicious granter can seal anything; the recipient finds out here rather than later, when + // an unexplained item decryption failure would look like data corruption. + using var member = Key.Create(KeyAgreementAlgorithm.X25519); + + var bogus = DshCrypto.SealTo( + member.PublicKey.Export(KeyBlobFormat.RawPublicKey), + new byte[16], + DshAad.VaultKeyGrant(VaultA, 1)); + + VaultKeys.TryUnwrap(member, bogus, VaultA, 1).ShouldBeNull(); + } + + [Fact] + public void WrappingAKeyOfTheWrongLength_Throws() + { + using var member = Key.Create(KeyAgreementAlgorithm.X25519); + + Should.Throw(() => VaultKeys.WrapTo( + new byte[16], member.PublicKey.Export(KeyBlobFormat.RawPublicKey), VaultA, 1)); + } + + // ---- Item data keys ---- + + [Fact] + public void ADataKey_RoundTripsUnderTheVaultKey() + { + var vaultKey = VaultKeys.Create(); + var dataKey = ItemKeys.CreateDataKey(); + + var wrapped = ItemKeys.WrapDataKey( + dataKey, vaultKey, CryptoSpec.AadResourceType.Host, ItemA, 1, 1); + + ItemKeys.TryUnwrapDataKey( + vaultKey, wrapped, CryptoSpec.AadResourceType.Host, ItemA, 1, 1).ShouldBe(dataKey); + } + + [Fact] + public void ADataKeyFromAnotherItem_DoesNotOpen() + { + var vaultKey = VaultKeys.Create(); + var dataKey = ItemKeys.CreateDataKey(); + + var wrapped = ItemKeys.WrapDataKey( + dataKey, vaultKey, CryptoSpec.AadResourceType.Host, ItemA, 1, 1); + + ItemKeys.TryUnwrapDataKey( + vaultKey, wrapped, CryptoSpec.AadResourceType.Host, ItemB, 1, 1).ShouldBeNull(); + } + + [Fact] + public void ADataKeyFromAnotherItemVersion_DoesNotOpen() + { + var vaultKey = VaultKeys.Create(); + var dataKey = ItemKeys.CreateDataKey(); + + var wrapped = ItemKeys.WrapDataKey( + dataKey, vaultKey, CryptoSpec.AadResourceType.Host, ItemA, 1, 2); + + ItemKeys.TryUnwrapDataKey( + vaultKey, wrapped, CryptoSpec.AadResourceType.Host, ItemA, 1, 1).ShouldBeNull(); + } + + [Fact] + public void ADataKeyForAnotherResourceType_DoesNotOpen() + { + // An id collision across tables would otherwise let a credential's key open a host's. + var vaultKey = VaultKeys.Create(); + var dataKey = ItemKeys.CreateDataKey(); + + var wrapped = ItemKeys.WrapDataKey( + dataKey, vaultKey, CryptoSpec.AadResourceType.Host, ItemA, 1, 1); + + ItemKeys.TryUnwrapDataKey( + vaultKey, wrapped, CryptoSpec.AadResourceType.Credential, ItemA, 1, 1).ShouldBeNull(); + } + + // ---- Item payloads ---- + + [Fact] + public void APayload_RoundTripsUnderItsDataKey() + { + var dataKey = ItemKeys.CreateDataKey(); + var plaintext = "ssh -p 2222 dodo@bastion.internal"u8.ToArray(); + + var envelope = ItemKeys.SealPayload( + dataKey, plaintext, CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 1, 1); + + ItemKeys.TryOpenPayload( + dataKey, envelope, CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 1, 1) + .ShouldBe(plaintext); + } + + [Fact] + public void APayloadPastedOntoAnotherItem_DoesNotOpen() + { + // The headline property. A server holding every ciphertext cannot move one row's bytes onto + // another row, which is a guarantee no amount of access control provides. + var dataKey = ItemKeys.CreateDataKey(); + var plaintext = "secret"u8.ToArray(); + + var envelope = ItemKeys.SealPayload( + dataKey, plaintext, CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 1, 1); + + ItemKeys.TryOpenPayload( + dataKey, envelope, CryptoSpec.AadResourceType.Host, ItemB, DataKeyId, 1, 1) + .ShouldBeNull(); + } + + [Fact] + public void APayloadRolledBackToAnEarlierVersion_DoesNotOpen() + { + var dataKey = ItemKeys.CreateDataKey(); + + var envelope = ItemKeys.SealPayload( + dataKey, "v2"u8.ToArray(), CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 1, 2); + + ItemKeys.TryOpenPayload( + dataKey, envelope, CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 1, 1) + .ShouldBeNull(); + } + + [Fact] + public void APayloadFromASupersededKeyGeneration_DoesNotOpen() + { + var dataKey = ItemKeys.CreateDataKey(); + + var envelope = ItemKeys.SealPayload( + dataKey, "old"u8.ToArray(), CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 1, 1); + + ItemKeys.TryOpenPayload( + dataKey, envelope, CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 2, 1) + .ShouldBeNull(); + } + + [Fact] + public void AMetadataBlob_DoesNotOpenAsAPayload() + { + // Distinct purposes on the same item, so a server cannot swap the two — a substitution that + // would leave a client decrypting a password where it expected a display name. + var dataKey = ItemKeys.CreateDataKey(); + + var metadata = DshCrypto.Seal( + dataKey, + "display name"u8, + DshAad.ItemMetadata(CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 1, 1)); + + ItemKeys.TryOpenPayload( + dataKey, metadata, CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 1, 1) + .ShouldBeNull(); + } + + [Fact] + public void APayloadUnderAnotherDataKeyId_DoesNotOpen() + { + // content_key_id is part of the binding, which is what lets M5 add per-item grants without + // reworking the AAD. + var dataKey = ItemKeys.CreateDataKey(); + + var envelope = ItemKeys.SealPayload( + dataKey, "x"u8.ToArray(), CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 1, 1); + + ItemKeys.TryOpenPayload( + dataKey, envelope, CryptoSpec.AadResourceType.Host, ItemA, Guid.Empty, 1, 1) + .ShouldBeNull(); + } + + [Fact] + public void TheFullChain_WorksEndToEnd() + { + // Enrollment through to a stored item: identity key, vault key sealed to it, data key wrapped + // under the vault key, payload under the data key. Every link uses only what the layer above + // it hands over, which is the shape the client actually follows. + using var bundle = UserSecretBundle.Create( + DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_000)); + + var vaultKey = VaultKeys.Create(); + var grant = VaultKeys.WrapTo(vaultKey, bundle.EncryptionPublicKey, VaultA, 1); + + var recoveredVaultKey = VaultKeys.TryUnwrap(bundle.EncryptionKey, grant, VaultA, 1); + recoveredVaultKey.ShouldNotBeNull(); + + var dataKey = ItemKeys.CreateDataKey(); + var wrappedDataKey = ItemKeys.WrapDataKey( + dataKey, recoveredVaultKey, CryptoSpec.AadResourceType.Host, ItemA, 1, 1); + + var plaintext = """{"hostname":"bastion.internal","username":"dodo"}"""u8.ToArray(); + var payload = ItemKeys.SealPayload( + dataKey, plaintext, CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 1, 1); + + // Now the read path, holding nothing but the identity key and the stored ciphertexts. + var readVaultKey = VaultKeys.TryUnwrap(bundle.EncryptionKey, grant, VaultA, 1); + readVaultKey.ShouldNotBeNull(); + + var readDataKey = ItemKeys.TryUnwrapDataKey( + readVaultKey, wrappedDataKey, CryptoSpec.AadResourceType.Host, ItemA, 1, 1); + readDataKey.ShouldNotBeNull(); + + ItemKeys.TryOpenPayload( + readDataKey, payload, CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 1, 1) + .ShouldBe(plaintext); + } +}