From b15af836a3189e91e3b97bd6056750b7a47dd9f5 Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Tue, 28 Jul 2026 13:18:29 +0200 Subject: [PATCH] Freeze DSH1 crypto specification and implement the core (M1) docs/crypto.md is now the normative, frozen specification. This had to land before anything else in M1: the server holds ciphertext and no keys, so it can never re-encrypt, and a format change after users hold data is a coordinated client rewrite with no rollback. Specification: - DSH1 envelope layout, canonical 64-byte AAD encoding, SealTo construction, key hierarchy, Argon2id profiles, fingerprints, and the change rules for each version field. - AAD encoding is fixed-width binary rather than delimited string concatenation, so no field value can forge a field boundary. This supersedes the illustrative form sketched in ADR 0001, which now points here. - UUIDs are RFC 4122 big-endian. Guid.ToByteArray() emits the first three groups little-endian and would have made our ciphertext unreadable by any other implementation of this spec, failing only at a cross-implementation boundary. Verified rather than assumed: - PrimitiveAvailabilityTests proves X25519, Ed25519, XChaCha20-Poly1305, Argon2id and HKDF-SHA512 all function on net10.0. NSec 26.4.0 targets net9.0 and is consumed by forward compatibility; this closes one of the two package questions the plan flagged. - Argon2Profile exists because NSec's MemorySize is in KIBIBYTES, not bytes. Passing bytes gives either a 256 GiB allocation or a 256 KiB KDF that cracks instantly. The type takes mebibytes so the unit cannot be got wrong at a call site. Found by benchmarking: the first measurements were ~1000x too slow, which turned out to be 19 GiB of work. - Parameters measured, not guessed: 256 MiB/t=4 is 323 ms on this machine; the table of candidates is in the spec. Implementation and tests (83 total, up from 17): - AadDescriptor, DshEnvelope, DshCrypto (Seal/Open/SealTo/OpenSealed/fingerprints). - Decryption returns null rather than throwing: ciphertext comes from a server that is explicitly not trusted, so a failed tag is an expected outcome. - Envelope readers reject unknown algorithms and any non-zero flag bit, so an envelope that is not fully understood fails closed. - Executable form of the spec's substitution claims: a server cannot move ciphertext between resources, roll back a key generation or item version, repurpose a payload as metadata, or confuse the two constructions. - Golden vectors in tests/fixtures/crypto/vectors.json guard the format. Mutation-checked: a one-byte schema version change trips four tests including the guard. Two build-infrastructure bugs found and fixed along the way: - .editorconfig forced camelCase on const and static readonly fields. PascalCase is the .NET convention for both; the config was wrong, not the code. - The golden fixture was resolved with [CallerFilePath], which ContinuousIntegrationBuild rewrites to /_/... under deterministic source paths. It passed locally and would have failed only in CI. Now copied to the output directory and read from there. --- .editorconfig | 20 + Directory.Packages.props | 17 + docs/adr/0001-e2ee-trust-model.md | 8 +- docs/crypto.md | 345 ++++++++++++++++++ src/DodoSSH.Crypto/AadDescriptor.cs | 148 ++++++++ src/DodoSSH.Crypto/Argon2Profile.cs | 119 ++++++ src/DodoSSH.Crypto/CryptoSpec.cs | 162 +++++++- src/DodoSSH.Crypto/DodoSSH.Crypto.csproj | 4 + src/DodoSSH.Crypto/DshCrypto.cs | 241 ++++++++++++ src/DodoSSH.Crypto/DshEnvelope.cs | 209 +++++++++++ src/DodoSSH.Crypto/DshEnvelopeView.cs | 41 +++ src/DodoSSH.Crypto/packages.lock.json | 15 + .../AadDescriptorTests.cs | 134 +++++++ tests/DodoSSH.Crypto.Tests/CryptoSpecTests.cs | 108 +++++- .../DodoSSH.Crypto.Tests.csproj | 11 + tests/DodoSSH.Crypto.Tests/DshCryptoTests.cs | 327 +++++++++++++++++ .../DodoSSH.Crypto.Tests/GoldenVectorTests.cs | 104 ++++++ tests/DodoSSH.Crypto.Tests/GoldenVectors.cs | 281 ++++++++++++++ .../PrimitiveAvailabilityTests.cs | 115 ++++++ tests/DodoSSH.Crypto.Tests/packages.lock.json | 20 +- tests/fixtures/crypto/vectors.json | 190 ++++++++++ 21 files changed, 2589 insertions(+), 30 deletions(-) create mode 100644 docs/crypto.md create mode 100644 src/DodoSSH.Crypto/AadDescriptor.cs create mode 100644 src/DodoSSH.Crypto/Argon2Profile.cs create mode 100644 src/DodoSSH.Crypto/DshCrypto.cs create mode 100644 src/DodoSSH.Crypto/DshEnvelope.cs create mode 100644 src/DodoSSH.Crypto/DshEnvelopeView.cs create mode 100644 tests/DodoSSH.Crypto.Tests/AadDescriptorTests.cs create mode 100644 tests/DodoSSH.Crypto.Tests/DshCryptoTests.cs create mode 100644 tests/DodoSSH.Crypto.Tests/GoldenVectorTests.cs create mode 100644 tests/DodoSSH.Crypto.Tests/GoldenVectors.cs create mode 100644 tests/DodoSSH.Crypto.Tests/PrimitiveAvailabilityTests.cs create mode 100644 tests/fixtures/crypto/vectors.json diff --git a/.editorconfig b/.editorconfig index 5ab5b6d..511f8d5 100644 --- a/.editorconfig +++ b/.editorconfig @@ -62,6 +62,26 @@ dotnet_naming_symbols.any_interface.applicable_kinds = interface dotnet_naming_style.starts_with_i.required_prefix = I dotnet_naming_style.starts_with_i.capitalization = pascal_case +# Constants and static readonly fields are PascalCase, per .NET convention. These rules must +# come before the camelCase rule below: the first matching rule wins, and a rule matching all +# private fields would otherwise force `const int Foo` to be named `foo`. +dotnet_naming_rule.constants_are_pascal_case.severity = warning +dotnet_naming_rule.constants_are_pascal_case.symbols = any_const_field +dotnet_naming_rule.constants_are_pascal_case.style = pascal_case_style +dotnet_naming_symbols.any_const_field.applicable_kinds = field +dotnet_naming_symbols.any_const_field.applicable_accessibilities = * +dotnet_naming_symbols.any_const_field.required_modifiers = const + +dotnet_naming_rule.static_readonly_fields_are_pascal_case.severity = warning +dotnet_naming_rule.static_readonly_fields_are_pascal_case.symbols = static_readonly_field +dotnet_naming_rule.static_readonly_fields_are_pascal_case.style = pascal_case_style +dotnet_naming_symbols.static_readonly_field.applicable_kinds = field +dotnet_naming_symbols.static_readonly_field.applicable_accessibilities = * +dotnet_naming_symbols.static_readonly_field.required_modifiers = static, readonly + +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +# Private instance fields are camelCase. dotnet_naming_rule.private_fields_are_camel_case.severity = warning dotnet_naming_rule.private_fields_are_camel_case.symbols = private_field dotnet_naming_rule.private_fields_are_camel_case.style = camel_case_style diff --git a/Directory.Packages.props b/Directory.Packages.props index afd11b7..a752abe 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -26,6 +26,23 @@ + + + + + + + + diff --git a/docs/adr/0001-e2ee-trust-model.md b/docs/adr/0001-e2ee-trust-model.md index f77eff9..6ab5013 100644 --- a/docs/adr/0001-e2ee-trust-model.md +++ b/docs/adr/0001-e2ee-trust-model.md @@ -29,8 +29,12 @@ we cannot make. key; each item has its own data key wrapped under the vault key. Rotating a vault key re-wraps N × 32-byte data keys and never touches content blobs. 4. **AAD bound to row identity.** Every ciphertext's AAD is recomputed from the row's - plaintext columns rather than stored: - `SHA-256("dsh1\n" + purpose + resourceType + resourceId + keyId + keyGeneration + schemaVersion)`. + plaintext columns rather than stored, over `purpose`, `resourceType`, `resourceId`, + `keyId`, `keyGeneration`, `itemVersion` and `schemaVersion`. + + The normative byte encoding is [`docs/crypto.md` §4](../crypto.md). It is fixed-width + binary rather than delimited string concatenation, so that no field value can forge a + field boundary. 5. **Layered public-key trust** — see Consequences. ## Consequences diff --git a/docs/crypto.md b/docs/crypto.md new file mode 100644 index 0000000..57b1f37 --- /dev/null +++ b/docs/crypto.md @@ -0,0 +1,345 @@ +# DodoSSH cryptographic specification (DSH1) + +- Status: **frozen** as of 2026-07-28. Version `1`. +- Normative. `DodoSSH.Crypto` must agree with this document exactly, and + `tests/fixtures/crypto/vectors.json` pins the byte-level results. + +> **Why this is frozen before anything is built on it.** The server holds ciphertext and no +> keys, so it cannot re-encrypt anything, ever. Only clients can. A change to the envelope +> layout or to AAD derivation after users hold data is therefore not a server migration — it +> is a coordinated rewrite of every client's local store, with no rollback. Additive change +> is possible through the version fields in §8; changing the meaning of an existing field is +> not. + +## 1. Primitives + +| Purpose | Algorithm | Source | +| --- | --- | --- | +| Passphrase KDF | Argon2id | NSec (libsodium) | +| Subkey derivation | HKDF-SHA512 | BCL `System.Security.Cryptography.HKDF` | +| Content AEAD | XChaCha20-Poly1305 | NSec (libsodium) | +| Key wrapping | X25519 + HKDF-SHA256 + XChaCha20-Poly1305 | NSec (libsodium) | +| Signatures | Ed25519 | NSec (libsodium) | +| Hashing, fingerprints | SHA-256 | BCL | + +### Why not the BCL for everything + +- The BCL has **no X25519 and no Ed25519** as of .NET 10. `ECDiffieHellman` is NIST curves + only. We do not substitute P-256 to avoid a native dependency: point validation, cofactor + and encoding are all footguns that X25519 does not have. +- **`ChaCha20Poly1305.IsSupported` is false on macOS**, and on Windows builds before + 10.0.20142. That disqualifies the in-box AEAD for a cross-platform client. +- NSec holds key material in libsodium's guarded, `mlock`ed allocations with + `KeyExportPolicies.None`, which is real protection against heap scraping and core dumps. + A `byte[]` cannot offer that. + +`AES-256-GCM` (`alg_id = 2`) is specified as a fallback for environments without +XChaCha20-Poly1305. It is **not currently emitted**; readers must accept it. + +### Verified availability + +`tests/DodoSSH.Crypto.Tests/PrimitiveAvailabilityTests.cs` proves each primitive functions on +the host running the suite. It is not ceremony: it is the guard that catches a platform where +this specification is not implementable. + +## 2. Argon2id parameters + +| Purpose | Memory | Passes | Parallelism | Output | +| --- | --- | --- | --- | --- | +| Passphrase → master key | 256 MiB | 4 | 1 | 32 B | +| Recovery code → KEK | 64 MiB | 3 | 1 | 32 B | +| Invite secret → KEK | 64 MiB | 3 | 1 | 32 B | + +Salt is 16 bytes from a CSPRNG, fresh on every passphrase change. + +> ### `MemorySize` is in kibibytes +> +> `NSec.Cryptography.Argon2Parameters.MemorySize` is **KiB, not bytes**. Passing bytes gives +> either a catastrophically weak KDF or an absurd allocation: +> +> | Intent | Correct | If bytes were assumed | +> | --- | --- | --- | +> | 256 MiB | `262144` | `268435456` → 256 GiB, allocation failure | +> | | | `262144` bytes → 256 KiB, ~1 ms, trivially crackable | +> +> `DodoSSH.Crypto` therefore never accepts a raw integer here. `Argon2Profile` takes +> `MemoryMebibytes` and converts, so the unit cannot be got wrong at a call site. + +**Parallelism is pinned to 1** because libsodium's Argon2id implementation supports only +`p=1`. Memory cost compensates: 256 MiB at `t=4` is far above OWASP's 19 MiB/`t=2` floor. + +Measured on a fast desktop (see §2 note in the test suite for the harness): + +| Parameters | Time | +| --- | --- | +| 64 MiB, t=3 | 52 ms | +| 128 MiB, t=3 | 114 ms | +| **256 MiB, t=4 (default)** | **323 ms** | +| 512 MiB, t=4 | 700 ms | + +A once-per-session unlock at roughly 0.3 s on fast hardware and an estimated 1–1.5 s on a +low-end laptop is the intended trade. Clients expose a security level of 128 / 256 / 512 MiB. + +**KDF parameters are stored in plaintext per wrap row** (`kdf_alg`, `kdf_salt`, `kdf_m`, +`kdf_t`, `kdf_p`). Salts are not secrets, and storing the parameters makes raising them later +a per-user, unlock-time migration instead of a breaking change. An old client can still open +its own wrap. + +**No passphrase verifier is stored server-side.** An `Argon2id(passphrase)` hash held by the +server would be an offline-crackable target on the very machine being defended against, for +no gain: the AEAD tag on the bundle wrap already proves the passphrase. Rate limiting is the +OIDC access-token gate plus client-side backoff. + +## 3. Key hierarchy + +``` +vault passphrase + │ Argon2id(salt, m=256 MiB, t=4, p=1) → 32 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: } + 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 + kind=recovery → symmetric AEAD under KEK_rc = Argon2id(recovery code) + kind=escrow → SealTo(team_breakglass_pk) opt-in, M5 + ▼ +VaultKey — 32 B CSPRNG, per vault, per key generation + wrapped per member: SealTo(member_x25519_pk, VaultKey, aad) + ▼ +DataKey (DK) — 32 B CSPRNG, per item, per version + wrapped: XChaCha20-Poly1305(VaultKey, DK, aad) + ▼ +item plaintext — password, private key, key passphrase, TOTP seed, encrypted metadata + XChaCha20-Poly1305(DK, plaintext, aad) +``` + +### Why the bundle is wrapped many ways + +This is the load-bearing structural choice. Because every wrap protects the *same* bundle: + +- **Passphrase change** re-derives `KEK_pp` from a new salt, re-wraps ~200 bytes and updates + one row. No vault data is re-encrypted and no other member is involved. This is the entire + reason an identity keypair exists rather than encrypting vault keys under the passphrase key + directly. +- **New device** is one additional wrap row. +- **Recovery** is one additional wrap row. + +### Why a per-item DataKey + +1. **Cheap rotation.** Rotating a vault key re-wraps N × 32-byte data keys and never touches + content blobs. A 10,000-item vault rotates in a few hundred kilobytes of writes. +2. **Narrow sharing.** A single item can be re-wrapped to another vault key or user key. +3. **Nonce hygiene.** Each key encrypts about one message. +4. **Versioning.** A new item version gets a new data key, so prior ciphertext stays + independently decryptable for history and undo. + +Per-item keys wrapped *to individual users* — which is what would make per-item ACLs +cryptographic rather than server-enforced — are deferred to M5. The `content_key_id` column +exists from the first migration so that lands without a migration. Until then, **an item ACL +is access control, not cryptographic isolation**: anyone holding the vault key can decrypt any +ciphertext they obtain. Say so in the product. + +## 4. Canonical AAD + +Every AEAD operation binds its ciphertext to the identity of the row that holds it. The AAD is +**not stored**; it is recomputed from that row's plaintext columns on both encrypt and decrypt. + +### 4.1 Encoding + +Fixed-width binary, 64 bytes, big-endian throughout: + +| Offset | Size | Field | Notes | +| --- | --- | --- | --- | +| 0 | 5 | magic | ASCII `dsh1\n` | +| 5 | 1 | aadVersion | `u8`, currently `1` | +| 6 | 1 | purpose | `u8`, §4.2 | +| 7 | 1 | resourceType | `u8`, §4.3 | +| 8 | 16 | resourceId | UUID, RFC 4122 big-endian byte order | +| 24 | 16 | keyId | UUID, or 16 zero bytes when not applicable | +| 40 | 4 | keyGeneration | `u32` | +| 44 | 4 | itemVersion | `u32`, `0` when not applicable | +| 48 | 2 | schemaVersion | `u16` | +| 50 | 14 | reserved | zero | + +``` +AAD = SHA-256(canonical 64-byte encoding) +``` + +Fixed-width encoding is used rather than delimited string concatenation so that no field +value can forge a field boundary. UUIDs must be serialised in RFC 4122 order — +**not** .NET's `Guid.ToByteArray()`, which emits the first three groups little-endian. Use +`Guid.TryWriteBytes(dest, bigEndian: true)`. + +> This supersedes the illustrative string form sketched in +> [ADR 0001](adr/0001-e2ee-trust-model.md). The fields and intent are unchanged; only the +> byte encoding is nailed down here. + +### 4.2 `purpose` + +| Value | Name | Binds | +| --- | --- | --- | +| 1 | `UserSecretBundle` | a bundle wrap | +| 2 | `VaultKeyGrant` | a vault key sealed to a member | +| 3 | `ItemDataKey` | a data key wrapped under a vault key | +| 4 | `ItemPayload` | item plaintext under its data key | +| 5 | `ItemMetadata` | encrypted host metadata under its data key | +| 6 | `LocalCache` | a client's on-disk cache record | + +### 4.3 `resourceType` + +`1` User, `2` Device, `3` Vault, `4` Host, `5` Credential, `6` SshKey, `7` HostGroup, +`8` Tag, `9` Snippet, `10` PortForward, `11` KnownHostKey. + +`0` means not applicable and is legal only where the table in §4.2 implies no resource. + +### 4.4 What this prevents + +A malicious or compromised server, holding every ciphertext and every plaintext column: + +- **cannot move** credential A's payload onto host B — `resourceId` differs, tag fails; +- **cannot roll back** a row to an earlier key generation — `keyGeneration` differs; +- **cannot replay** a revoked grant blob — `keyGeneration` and `resourceId` differ; +- **cannot repurpose** a bundle wrap as a vault grant — `purpose` differs; +- **cannot substitute** an item's metadata blob for its payload blob — `purpose` differs. + +None of that follows from ACLs. It is the single most valuable structural property here, and +it is why AAD derivation is frozen ahead of everything else. + +## 5. DSH1 envelope + +Binary layout. All multi-byte integers big-endian. + +``` + offset size field + 0 4 magic ASCII "DSH1" + 4 1 alg_id 1 XChaCha20-Poly1305 | 2 AES-256-GCM | 3 SealTo(X25519) + 5 1 flags reserved, must be 0, readers must reject non-zero + [alg_id = 3 only] + 6 32 ephemeral_pk X25519 ephemeral public key + — — nonce 24 B for alg 1 and 3, 12 B for alg 2 + — n ciphertext includes the trailing 16-byte AEAD tag +``` + +- Header is 6 bytes, plus 32 for `alg_id = 3`. +- Nonces are drawn from a CSPRNG per message. A 192-bit nonce is why no counter is needed; + this is a concrete reason to prefer XChaCha20 over AES-GCM's 96-bit nonce. +- `flags` exists so a reader can fail closed on an envelope it does not fully understand. +- Minimum lengths: 46 bytes for `alg_id = 1`, 34 for `2`, 78 for `3`. Shorter is malformed. + +## 6. `SealTo` — anonymous-sender wrapping (`alg_id = 3`) + +Specified explicitly rather than using libsodium's sealed box, because the sealed-box KDF is +Blake2b over the ephemeral and recipient keys only and we require the AAD binding of §4. + +``` +Seal(recipient_pk, plaintext, aad): + (e_sk, e_pk) = X25519.GenerateKeyPair() + dh = X25519(e_sk, recipient_pk) reject all-zero output + prk = HKDF-SHA256-Extract(salt = e_pk || recipient_pk, ikm = dh) + k = HKDF-SHA256-Expand(prk, info = "dsh1/sealto/v1|" || aad, L = 32) + nonce = CSPRNG(24) + ct = XChaCha20-Poly1305-Encrypt(k, nonce, aad, plaintext) + wipe(e_sk, dh, prk, k) + return e_pk || nonce || ct + +Open(recipient_sk, envelope, aad): + parse e_pk, nonce, ct + dh = X25519(recipient_sk, e_pk) reject all-zero output + prk = HKDF-SHA256-Extract(salt = e_pk || X25519_public(recipient_sk), ikm = dh) + k = HKDF-SHA256-Expand(prk, info = "dsh1/sealto/v1|" || aad, L = 32) + return XChaCha20-Poly1305-Decrypt(k, nonce, aad, ct) null on tag failure +``` + +`SealTo` is **anonymous-sender by construction** — it proves nothing about who created the +envelope. Every grant record therefore additionally carries a **detached Ed25519 signature** +from the granter (§7). Without that, a server could fabricate a grant and the recipient could +not tell. + +## 7. Signatures + +Ed25519 over a canonical, length-prefixed encoding. Each signature is domain-separated by a +context string so a signature in one role can never be replayed in another: + +| Context | Signs | +| --- | --- | +| `dsh1/sig/keystatement/v1` | an enrollment key statement | +| `dsh1/sig/grant/v1` | `(vaultId, keyGeneration, granteeUserId, granteeKeyFingerprint, SHA-256(wrappedKey), grantKind, granterUserId, granterKeyFingerprint, keyLogHead, timestamp)` | +| `dsh1/sig/attestation/v1` | an admin's attestation of another user's key statement | + +A grant signature covers `SHA-256(wrappedKey)` rather than the wrapped key itself, so +signature verification does not require the verifier to hold the vault key. + +**The server stores signatures opaquely and clients verify them.** Server-side verification +would be a convenience, never the security boundary, and would drag an asymmetric +implementation onto a machine that is supposed to have none. + +## 8. Fingerprints and versioning + +``` +fingerprint = SHA-256( "dsh1/fp/v1" || x25519_pk || ed25519_pk ) 32 bytes +``` + +Displayed as lowercase hex in groups of four. The 6-word safety number for out-of-band +verification derives from the first 48 bits of the sorted concatenation of both parties' +fingerprints, so both sides compute the same words regardless of who initiates. + +SSH **host** key fingerprints are a different thing and follow OpenSSH: +`SHA256:` + unpadded base64 of `SHA-256(host key blob)`. Compute from the raw host key blob; +do not use SSH.NET's MD5 property. + +### Change rules + +| Field | Widening | Meaning change | +| --- | --- | --- | +| `alg_id` | new value, readers reject unknown | never | +| `flags` | new bit, readers reject unknown bits | never | +| `aadVersion` | new value; rows carry `payload_aad_version` | never | +| `keyGeneration` | monotonic per vault | never | +| `purpose`, `resourceType` | append only | never | + +`alg_id = 4` is **reserved** for a hybrid X25519 + ML-KEM-768 seal. The identifier is claimed +now, before it is needed, because store-now-decrypt-later is a genuine threat against +long-lived SSH private keys and the value must not be reused. .NET 10 ships `MLKem`; the +construction concatenates both shared secrets into HKDF-Extract. Deferred, not forgotten. + +Raising `aadVersion` or `alg_id` requires a **client-side lazy re-encrypt-on-write path** to +exist first. The server cannot participate. + +## 9. Test vectors + +`tests/fixtures/crypto/vectors.json` is generated by +`DodoSSH.Crypto.Tests.VectorGenerator` and asserted by `GoldenVectorTests`. It pins: + +- canonical AAD encodings and their SHA-256, including UUID byte order; +- envelope framing for each `alg_id`, with fixed key, nonce and plaintext; +- Argon2id and HKDF outputs for fixed inputs; +- the negative cases of §4.4 — each must fail to decrypt. + +Deterministic operations are pinned to exact bytes. `SealTo` and signature generation use +fresh randomness, so those are verified by round-trip plus fixed-input `Open` vectors. + +**A failing golden vector is never to be "fixed" by regenerating the file.** It means either a +genuine regression or an intentional, versioned format change that requires a client migration +path first. + +## 10. Threat model boundaries + +This specification protects the confidentiality and integrity of vault contents against the +server, its operators, its backups and the network. It does **not** address: + +- a compromised client endpoint — past the endpoint, E2EE is irrelevant; +- a malicious authorized member — an authorization and rotation problem; +- retroactive revocation — impossible; rotate the SSH credential itself; +- public-key substitution — mitigated but not eliminated; see + [ADR 0001 §Consequences](adr/0001-e2ee-trust-model.md); +- metadata — item counts, sizes, timestamps, access patterns and the sharing graph are + visible, as are host addresses for relay-enabled hosts; +- a weak passphrase — §2 parameters and passphrase entropy are the whole defence; +- supply chain — a server can serve a backdoored client. Sign releases with a key the server + does not hold. In a self-hosted E2EE product this is the largest practical hole. diff --git a/src/DodoSSH.Crypto/AadDescriptor.cs b/src/DodoSSH.Crypto/AadDescriptor.cs new file mode 100644 index 0000000..f292963 --- /dev/null +++ b/src/DodoSSH.Crypto/AadDescriptor.cs @@ -0,0 +1,148 @@ +using System.Buffers.Binary; +using System.Runtime.InteropServices; +using System.Security.Cryptography; + +namespace DodoSSH.Crypto; + +/// +/// Identifies the row a ciphertext belongs to, and computes the additional authenticated data +/// that binds the ciphertext to it. +/// +/// +/// +/// See docs/crypto.md §4, which is normative. The AAD is never stored: it is recomputed from +/// the row's plaintext columns on both encrypt and decrypt. That is what stops a server that +/// holds every ciphertext from moving one between rows, rolling a row back to an earlier key +/// generation, replaying a revoked grant, or substituting a metadata blob for a payload blob. +/// None of those properties follow from access control. +/// +/// +/// The encoding is fixed-width rather than delimited, so no field value can forge a field +/// boundary. +/// +/// +/// What this ciphertext is. +/// The kind of entity it belongs to. +/// The entity's identifier. +/// The key it is encrypted under, where one is identified. +/// The vault key generation in force. +/// The item version, where applicable. +/// The AAD rule version, so a change can be applied lazily. +/// The relational schema version. +[StructLayout(LayoutKind.Auto)] +public readonly record struct AadDescriptor( + CryptoSpec.AadPurpose Purpose, + CryptoSpec.AadResourceType ResourceType, + Guid ResourceId, + Guid KeyId, + uint KeyGeneration, + uint ItemVersion, + byte AadVersion, + ushort SchemaVersion) +{ + private const int OffsetAadVersion = 5; + private const int OffsetPurpose = 6; + private const int OffsetResourceType = 7; + private const int OffsetResourceId = 8; + private const int OffsetKeyId = 24; + private const int OffsetKeyGeneration = 40; + private const int OffsetItemVersion = 44; + private const int OffsetSchemaVersion = 48; + + /// + /// Creates a descriptor at the current AAD and schema versions. + /// + public static AadDescriptor Create( + CryptoSpec.AadPurpose purpose, + CryptoSpec.AadResourceType resourceType, + Guid resourceId, + Guid keyId = default, + uint keyGeneration = 1, + uint itemVersion = 0) => + new( + purpose, + resourceType, + resourceId, + keyId, + keyGeneration, + itemVersion, + CryptoSpec.CurrentAadVersion, + CryptoSpec.CurrentSchemaVersion); + + /// + /// Writes the canonical 64-byte encoding. + /// + /// + /// UUIDs are written in RFC 4122 big-endian order, not the mixed-endian order that + /// produces by default. Getting that wrong would make + /// ciphertext written by one implementation undecryptable by another. + /// + public void WriteCanonicalEncoding(Span destination) + { + if (destination.Length < CryptoSpec.AadEncodedLength) + { + throw new ArgumentException( + $"Destination must be at least {CryptoSpec.AadEncodedLength} bytes.", + nameof(destination)); + } + + if (Purpose == CryptoSpec.AadPurpose.Unspecified) + { + throw new InvalidOperationException("AAD purpose must be specified."); + } + + var buffer = destination[..CryptoSpec.AadEncodedLength]; + buffer.Clear(); + + CryptoSpec.AadMagic.CopyTo(buffer); + buffer[OffsetAadVersion] = AadVersion; + buffer[OffsetPurpose] = (byte)Purpose; + buffer[OffsetResourceType] = (byte)ResourceType; + + if (!ResourceId.TryWriteBytes(buffer[OffsetResourceId..], bigEndian: true, out _)) + { + throw new InvalidOperationException("Failed to write resource id."); + } + + if (!KeyId.TryWriteBytes(buffer[OffsetKeyId..], bigEndian: true, out _)) + { + throw new InvalidOperationException("Failed to write key id."); + } + + BinaryPrimitives.WriteUInt32BigEndian(buffer[OffsetKeyGeneration..], KeyGeneration); + BinaryPrimitives.WriteUInt32BigEndian(buffer[OffsetItemVersion..], ItemVersion); + BinaryPrimitives.WriteUInt16BigEndian(buffer[OffsetSchemaVersion..], SchemaVersion); + + // Trailing 14 reserved bytes stay zero from the Clear above. + } + + /// Returns the canonical encoding as a new array. Prefer the span overload. + public byte[] ToCanonicalEncoding() + { + var buffer = new byte[CryptoSpec.AadEncodedLength]; + WriteCanonicalEncoding(buffer); + return buffer; + } + + /// Computes the AAD: SHA-256 over the canonical encoding. + public void ComputeAad(Span destination) + { + Span encoded = stackalloc byte[CryptoSpec.AadEncodedLength]; + WriteCanonicalEncoding(encoded); + + if (!SHA256.TryHashData(encoded, destination, out _)) + { + throw new ArgumentException( + $"Destination must be at least {CryptoSpec.DigestSize} bytes.", + nameof(destination)); + } + } + + /// Computes the AAD as a new array. + public byte[] ComputeAad() + { + var aad = new byte[CryptoSpec.DigestSize]; + ComputeAad(aad); + return aad; + } +} diff --git a/src/DodoSSH.Crypto/Argon2Profile.cs b/src/DodoSSH.Crypto/Argon2Profile.cs new file mode 100644 index 0000000..2e93d8c --- /dev/null +++ b/src/DodoSSH.Crypto/Argon2Profile.cs @@ -0,0 +1,119 @@ +using NSec.Cryptography; + +namespace DodoSSH.Crypto; + +/// +/// An Argon2id parameter set, in units that cannot be misread. +/// +/// +/// +/// This type exists for one reason: NSec.Cryptography.Argon2Parameters.MemorySize is in +/// kibibytes, not bytes. Passing bytes silently produces either a catastrophically weak +/// KDF or an impossible allocation — for a 256 MiB intent, 268435456 asks for 256 GiB, +/// while 262144 interpreted as bytes is 256 KiB and cracks in milliseconds. +/// +/// +/// Callers therefore never supply a raw memory figure. See docs/crypto.md §2. +/// +/// +/// Parallelism is pinned to 1 because libsodium's Argon2id supports only p=1. Memory +/// cost compensates: the default of 256 MiB at four passes is far above OWASP's 19 MiB/2-pass +/// floor, and measured about 323 ms on a fast desktop. +/// +/// +public sealed record Argon2Profile +{ + private const int KibibytesPerMebibyte = 1024; + + private Argon2Profile(int memoryMebibytes, int passes) + { + if (memoryMebibytes is < 8 or > 4096) + { + throw new ArgumentOutOfRangeException( + nameof(memoryMebibytes), + memoryMebibytes, + "Memory must be between 8 and 4096 MiB."); + } + + if (passes is < 1 or > 16) + { + throw new ArgumentOutOfRangeException(nameof(passes), passes, "Passes must be 1 to 16."); + } + + MemoryMebibytes = memoryMebibytes; + Passes = passes; + } + + /// Memory cost, in mebibytes. + public int MemoryMebibytes { get; } + + /// Number of passes over memory. + public int Passes { get; } + + /// + /// Degree of parallelism. Always 1; libsodium's Argon2id supports no other value, so this + /// is a property of the algorithm rather than of a profile. + /// + public static int Parallelism => 1; + + /// Default for deriving the master key from a vault passphrase. + public static Argon2Profile PassphraseDefault { get; } = new(256, 4); + + /// Reduced profile for low-powered devices. Still well above the OWASP floor. + public static Argon2Profile PassphraseReduced { get; } = new(128, 3); + + /// Raised profile for users who accept a slower unlock. + public static Argon2Profile PassphraseHigh { get; } = new(512, 4); + + /// + /// Profile for 128-bit random secrets — recovery codes and invite secrets. + /// + /// + /// KDF hardening is nearly irrelevant for a full-entropy random secret; this is + /// anti-nuisance only, not a defence against a serious offline attack. + /// + public static Argon2Profile RandomSecret { get; } = new(64, 3); + + /// + /// Reconstructs a profile from stored parameters. + /// + /// + /// KDF parameters are stored in plaintext per wrap row so that raising them later is a + /// per-user, unlock-time migration rather than a breaking change, and so an older client + /// can still open its own wrap. + /// + /// Stored memory cost, in kibibytes. + /// Stored pass count. + /// Stored parallelism. Must be 1. + public static Argon2Profile FromStoredParameters(int memoryKibibytes, int passes, int parallelism) + { + if (parallelism != 1) + { + throw new NotSupportedException( + $"Argon2id parallelism {parallelism} is not supported; libsodium implements only p=1."); + } + + if (memoryKibibytes % KibibytesPerMebibyte != 0) + { + throw new ArgumentOutOfRangeException( + nameof(memoryKibibytes), + memoryKibibytes, + "Stored memory cost must be a whole number of mebibytes."); + } + + return new Argon2Profile(memoryKibibytes / KibibytesPerMebibyte, passes); + } + + /// Memory cost in kibibytes, as persisted and as NSec expects it. + public int MemoryKibibytes => MemoryMebibytes * KibibytesPerMebibyte; + + /// Builds the NSec algorithm instance for this profile. + public PasswordBasedKeyDerivationAlgorithm CreateAlgorithm() => + PasswordBasedKeyDerivationAlgorithm.Argon2id(new Argon2Parameters + { + // MemorySize is KiB. This single line is the reason this type exists. + MemorySize = MemoryKibibytes, + NumberOfPasses = Passes, + DegreeOfParallelism = Parallelism, + }); +} diff --git a/src/DodoSSH.Crypto/CryptoSpec.cs b/src/DodoSSH.Crypto/CryptoSpec.cs index 3158016..c7c3b72 100644 --- a/src/DodoSSH.Crypto/CryptoSpec.cs +++ b/src/DodoSSH.Crypto/CryptoSpec.cs @@ -1,47 +1,171 @@ namespace DodoSSH.Crypto; /// -/// Constants of the DodoSSH cryptographic specification. +/// Constants of the DodoSSH cryptographic specification, version 1. /// /// -/// docs/crypto.md is the normative specification; this type must agree with it exactly. -/// The implementation of the envelope, AAD derivation and key wrapping lands in M1, once -/// the specification and its test vectors are frozen. Nothing else may be built on top of -/// an unfrozen AAD: only clients can re-encrypt, so a change after users hold data cannot -/// be migrated server-side. +/// docs/crypto.md is normative; this type must agree with it exactly. The values here are +/// written into stored data, so changing one reinterprets or orphans existing ciphertext. +/// Only clients can re-encrypt, so the server cannot migrate a change here. /// public static class CryptoSpec { /// Magic prefix identifying a DSH1 envelope. - public const string EnvelopeMagic = "DSH1"; + public static ReadOnlySpan EnvelopeMagic => "DSH1"u8; + + /// Magic prefix of the canonical AAD encoding. + public static ReadOnlySpan AadMagic => "dsh1\n"u8; + + /// Length of the canonical AAD encoding, before hashing. + public const int AadEncodedLength = 64; /// Version of the AAD derivation rule that payloads are bound to. /// - /// Stored per row as payload_aad_version so a future change can be applied - /// lazily, re-encrypting on next write rather than in a migration. + /// Stored per row as payload_aad_version so a future change can be applied lazily, + /// re-encrypting on next write rather than in a migration. /// - public const short CurrentAadVersion = 1; + public const byte CurrentAadVersion = 1; - /// Domain-separation prefix for every AAD computation. - public const string AadDomainPrefix = "dsh1\n"; + /// Version of the relational schema that AAD is bound to. + public const ushort CurrentSchemaVersion = 1; - /// Identifiers for the algorithms an envelope may declare. + /// Size of a symmetric content or wrapping key. + public const int SymmetricKeySize = 32; + + /// Size of an X25519 or Ed25519 public key. + public const int PublicKeySize = 32; + + /// Size of an Ed25519 signature. + public const int SignatureSize = 64; + + /// Size of a SHA-256 output, used for AAD, fingerprints and digests. + public const int DigestSize = 32; + + /// Size of an AEAD authentication tag. + public const int TagSize = 16; + + /// Recommended salt length for password-based derivation. + public const int SaltSize = 16; + + /// Identifies the construction used by a DSH1 envelope. public enum AlgorithmId : byte { - /// Reserved; never written. + /// Reserved; never written, and rejected on read. Unspecified = 0, /// Symmetric content encryption under a known key. XChaCha20Poly1305 = 1, - /// Symmetric fallback where XChaCha20 is unavailable. + /// + /// Symmetric fallback for environments without XChaCha20-Poly1305. Accepted on read, + /// not currently emitted. + /// Aes256Gcm = 2, - /// Anonymous-sender seal to an X25519 public key. + /// Anonymous-sender seal to an X25519 public key. See docs/crypto.md §6. SealToX25519 = 3, - // 4 is reserved for a hybrid X25519 + ML-KEM-768 seal. Store-now-decrypt-later is - // a genuine threat for long-lived SSH keys, so the identifier is claimed now even - // though the construction ships later. + // 4 is reserved for a hybrid X25519 + ML-KEM-768 seal. Claimed now so it cannot be + // reused: store-now-decrypt-later is a real threat for long-lived SSH keys. + } + + /// + /// What a given ciphertext is, so that one kind of blob can never be substituted for + /// another. Part of the AAD; see docs/crypto.md §4.2. + /// + public enum AadPurpose : byte + { + /// Not a legal value. + Unspecified = 0, + + /// A wrap of the user's secret bundle. + UserSecretBundle = 1, + + /// A vault key sealed to a member's public key. + VaultKeyGrant = 2, + + /// An item data key wrapped under a vault key. + ItemDataKey = 3, + + /// Item plaintext under its data key. + ItemPayload = 4, + + /// Encrypted item metadata under its data key. + ItemMetadata = 5, + + /// A record in a client's on-disk cache. + LocalCache = 6, + } + + /// + /// The kind of entity a ciphertext belongs to. Part of the AAD; see docs/crypto.md §4.3. + /// Append only. + /// + public enum AadResourceType : byte + { + /// No resource. Legal only where the purpose implies none. + None = 0, + + /// A user account. + User = 1, + + /// An enrolled device. + Device = 2, + + /// A vault. + Vault = 3, + + /// An SSH host. + Host = 4, + + /// A credential. + Credential = 5, + + /// An SSH key pair. + SshKey = 6, + + /// A host group. + HostGroup = 7, + + /// A tag. + Tag = 8, + + /// A snippet. + Snippet = 9, + + /// A port forward. + PortForward = 10, + + /// A known SSH host key. + KnownHostKey = 11, + } + + /// HKDF info labels. Domain-separated so one subkey cannot stand in for another. + public static class DerivationLabels + { + /// Derives the key-encryption key that wraps the secret bundle. + public static ReadOnlySpan PassphraseKek => "dsh1/kek/passphrase/v1"u8; + + /// Derives the key that encrypts the client's on-disk cache. + public static ReadOnlySpan LocalCache => "dsh1/localcache/v1"u8; + + /// Prefix of the SealTo key-derivation info, concatenated with the AAD. + public static ReadOnlySpan SealTo => "dsh1/sealto/v1|"u8; + + /// Prefix of the identity key fingerprint input. + public static ReadOnlySpan Fingerprint => "dsh1/fp/v1"u8; + } + + /// Ed25519 signing contexts. Prevents a signature being replayed in another role. + public static class SigningContexts + { + /// Signs an enrollment key statement. + public static ReadOnlySpan KeyStatement => "dsh1/sig/keystatement/v1"u8; + + /// Signs a vault key grant tuple. + public static ReadOnlySpan Grant => "dsh1/sig/grant/v1"u8; + + /// Signs an admin attestation of another user's key statement. + public static ReadOnlySpan Attestation => "dsh1/sig/attestation/v1"u8; } } diff --git a/src/DodoSSH.Crypto/DodoSSH.Crypto.csproj b/src/DodoSSH.Crypto/DodoSSH.Crypto.csproj index 6907b9c..482fb10 100644 --- a/src/DodoSSH.Crypto/DodoSSH.Crypto.csproj +++ b/src/DodoSSH.Crypto/DodoSSH.Crypto.csproj @@ -12,6 +12,10 @@ true + + + + diff --git a/src/DodoSSH.Crypto/DshCrypto.cs b/src/DodoSSH.Crypto/DshCrypto.cs new file mode 100644 index 0000000..9a7a882 --- /dev/null +++ b/src/DodoSSH.Crypto/DshCrypto.cs @@ -0,0 +1,241 @@ +using System.Security.Cryptography; +using NSec.Cryptography; + +namespace DodoSSH.Crypto; + +/// +/// The DSH1 operations: symmetric content encryption and anonymous-sender key wrapping. +/// +/// +/// +/// docs/crypto.md is normative. Every operation takes an rather +/// than a raw AAD, so a caller cannot omit the binding that stops a server relocating +/// ciphertext between rows or key generations. +/// +/// +/// Decryption returns on authentication failure rather than throwing. +/// Ciphertext arrives from a server that is explicitly not trusted, so a failed tag is an +/// expected outcome to be handled, not an exceptional one. +/// +/// +public static class DshCrypto +{ + private static AeadAlgorithm Aead => AeadAlgorithm.XChaCha20Poly1305; + + private static KeyAgreementAlgorithm Agreement => KeyAgreementAlgorithm.X25519; + + /// + /// Encrypts under a known symmetric key, producing a complete DSH1 envelope + /// (). + /// + /// 32-byte content key. + /// Data to protect. + /// Identifies the row this ciphertext belongs to. + public static byte[] Seal(ReadOnlySpan key, ReadOnlySpan plaintext, in AadDescriptor descriptor) + { + RequireSymmetricKey(key); + + Span aad = stackalloc byte[CryptoSpec.DigestSize]; + descriptor.ComputeAad(aad); + + // A 192-bit nonce is why a random nonce per message is safe with no counter to track. + Span nonce = stackalloc byte[DshEnvelope.XChaChaNonceSize]; + RandomNumberGenerator.Fill(nonce); + + using var aeadKey = Key.Import(Aead, key, KeyBlobFormat.RawSymmetricKey); + var ciphertext = Aead.Encrypt(aeadKey, nonce, aad, plaintext); + + return DshEnvelope.Write(CryptoSpec.AlgorithmId.XChaCha20Poly1305, nonce, ciphertext); + } + + /// + /// Opens an envelope produced by . + /// + /// The plaintext, or if the envelope is malformed, uses + /// another construction, or fails authentication under this descriptor. + public static byte[]? Open(ReadOnlySpan key, ReadOnlySpan envelope, in AadDescriptor descriptor) + { + RequireSymmetricKey(key); + + if (!DshEnvelope.TryRead(envelope, out var view)) + { + return null; + } + + if (view.Algorithm != CryptoSpec.AlgorithmId.XChaCha20Poly1305) + { + return null; + } + + Span aad = stackalloc byte[CryptoSpec.DigestSize]; + descriptor.ComputeAad(aad); + + using var aeadKey = Key.Import(Aead, key, KeyBlobFormat.RawSymmetricKey); + return Aead.Decrypt(aeadKey, view.Nonce, aad, view.Ciphertext); + } + + /// + /// Seals to a recipient's X25519 public key, producing a + /// envelope. See docs/crypto.md §6. + /// + /// + /// Anonymous-sender by construction: this proves nothing about who created the envelope. + /// Callers that need attribution — grants, in particular — must additionally attach a + /// detached Ed25519 signature, or a server could fabricate a grant undetectably. + /// + public static byte[] SealTo( + ReadOnlySpan recipientPublicKey, + ReadOnlySpan plaintext, + in AadDescriptor descriptor) + { + RequirePublicKey(recipientPublicKey); + + Span aad = stackalloc byte[CryptoSpec.DigestSize]; + descriptor.ComputeAad(aad); + + var recipient = PublicKey.Import(Agreement, recipientPublicKey, KeyBlobFormat.RawPublicKey); + + // The ephemeral key must be exportable: its public half goes into the envelope. + using var ephemeral = Key.Create( + Agreement, + new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport }); + + var ephemeralPublic = ephemeral.PublicKey.Export(KeyBlobFormat.RawPublicKey); + + using var shared = Agreement.Agree(ephemeral, recipient) + ?? throw new CryptographicException("X25519 agreement failed; the recipient key is invalid."); + + Span derived = stackalloc byte[CryptoSpec.SymmetricKeySize]; + DeriveSealKey(shared, ephemeralPublic, recipientPublicKey, aad, derived); + + Span nonce = stackalloc byte[DshEnvelope.XChaChaNonceSize]; + RandomNumberGenerator.Fill(nonce); + + using var contentKey = Key.Import(Aead, derived, KeyBlobFormat.RawSymmetricKey); + var ciphertext = Aead.Encrypt(contentKey, nonce, aad, plaintext); + + CryptographicOperations.ZeroMemory(derived); + + return DshEnvelope.Write( + CryptoSpec.AlgorithmId.SealToX25519, + nonce, + ciphertext, + ephemeralPublic); + } + + /// + /// Opens an envelope produced by using the recipient's private key. + /// + /// The plaintext, or on any failure. + public static byte[]? OpenSealed(Key recipientKey, ReadOnlySpan envelope, in AadDescriptor descriptor) + { + ArgumentNullException.ThrowIfNull(recipientKey); + + if (!DshEnvelope.TryRead(envelope, out var view)) + { + return null; + } + + if (view.Algorithm != CryptoSpec.AlgorithmId.SealToX25519) + { + return null; + } + + Span aad = stackalloc byte[CryptoSpec.DigestSize]; + descriptor.ComputeAad(aad); + + PublicKey ephemeralPublic; + try + { + ephemeralPublic = PublicKey.Import(Agreement, view.EphemeralPublicKey, KeyBlobFormat.RawPublicKey); + } + catch (FormatException) + { + return null; + } + + using var shared = Agreement.Agree(recipientKey, ephemeralPublic); + if (shared is null) + { + // All-zero agreement: a low-order or otherwise invalid ephemeral key. + return null; + } + + var recipientPublic = recipientKey.PublicKey.Export(KeyBlobFormat.RawPublicKey); + + Span derived = stackalloc byte[CryptoSpec.SymmetricKeySize]; + DeriveSealKey(shared, view.EphemeralPublicKey, recipientPublic, aad, derived); + + using var contentKey = Key.Import(Aead, derived, KeyBlobFormat.RawSymmetricKey); + var plaintext = Aead.Decrypt(contentKey, view.Nonce, aad, view.Ciphertext); + + CryptographicOperations.ZeroMemory(derived); + return plaintext; + } + + /// + /// Computes an identity fingerprint over a user's two public keys. See docs/crypto.md §8. + /// + public static byte[] ComputeFingerprint( + ReadOnlySpan x25519PublicKey, + ReadOnlySpan ed25519PublicKey) + { + RequirePublicKey(x25519PublicKey); + RequirePublicKey(ed25519PublicKey); + + var label = CryptoSpec.DerivationLabels.Fingerprint; + + Span input = stackalloc byte[label.Length + (CryptoSpec.PublicKeySize * 2)]; + label.CopyTo(input); + x25519PublicKey.CopyTo(input[label.Length..]); + ed25519PublicKey.CopyTo(input[(label.Length + CryptoSpec.PublicKeySize)..]); + + var fingerprint = new byte[CryptoSpec.DigestSize]; + SHA256.HashData(input, fingerprint); + return fingerprint; + } + + /// + /// HKDF-SHA256 over the X25519 shared secret, salted with both public keys and bound to + /// the AAD. Specified rather than reusing libsodium's sealed box, whose KDF covers only the + /// key pair and would not carry the AAD binding. + /// + private static void DeriveSealKey( + SharedSecret shared, + ReadOnlySpan ephemeralPublicKey, + ReadOnlySpan recipientPublicKey, + ReadOnlySpan aad, + Span destination) + { + Span salt = stackalloc byte[CryptoSpec.PublicKeySize * 2]; + ephemeralPublicKey.CopyTo(salt); + recipientPublicKey.CopyTo(salt[CryptoSpec.PublicKeySize..]); + + var prefix = CryptoSpec.DerivationLabels.SealTo; + Span info = stackalloc byte[prefix.Length + aad.Length]; + prefix.CopyTo(info); + aad.CopyTo(info[prefix.Length..]); + + KeyDerivationAlgorithm.HkdfSha256.DeriveBytes(shared, salt, info, destination); + } + + private static void RequireSymmetricKey(ReadOnlySpan key) + { + if (key.Length != CryptoSpec.SymmetricKeySize) + { + throw new ArgumentException( + $"Key must be {CryptoSpec.SymmetricKeySize} bytes, got {key.Length}.", + nameof(key)); + } + } + + private static void RequirePublicKey(ReadOnlySpan publicKey) + { + if (publicKey.Length != CryptoSpec.PublicKeySize) + { + throw new ArgumentException( + $"Public key must be {CryptoSpec.PublicKeySize} bytes, got {publicKey.Length}.", + nameof(publicKey)); + } + } +} diff --git a/src/DodoSSH.Crypto/DshEnvelope.cs b/src/DodoSSH.Crypto/DshEnvelope.cs new file mode 100644 index 0000000..bda0918 --- /dev/null +++ b/src/DodoSSH.Crypto/DshEnvelope.cs @@ -0,0 +1,209 @@ +namespace DodoSSH.Crypto; + +/// +/// Framing of the DSH1 envelope. See docs/crypto.md §5, which is normative. +/// +/// +/// +/// Layout, all integers big-endian: +/// +/// +/// offset size field +/// 0 4 magic "DSH1" +/// 4 1 alg_id +/// 5 1 flags reserved, must be zero +/// [alg_id = 3 only] +/// 6 32 ephemeral_pk +/// — — nonce 24 bytes for alg 1 and 3, 12 for alg 2 +/// — n ciphertext including the trailing 16-byte tag +/// +/// +/// This type does framing only; it performs no cryptography. Readers reject unknown +/// algorithms and any non-zero flag bit, so an envelope that is not fully understood fails +/// closed rather than being partially interpreted. +/// +/// +public static class DshEnvelope +{ + /// Bytes before the algorithm-specific portion. + public const int HeaderSize = 6; + + private const int OffsetAlgorithm = 4; + private const int OffsetFlags = 5; + private const int OffsetEphemeralPublicKey = 6; + + /// Nonce length for XChaCha20-Poly1305 and SealTo. + public const int XChaChaNonceSize = 24; + + /// Nonce length for AES-256-GCM. + public const int AesGcmNonceSize = 12; + + /// Returns the nonce length used by an algorithm. + public static int NonceSizeFor(CryptoSpec.AlgorithmId algorithm) => algorithm switch + { + CryptoSpec.AlgorithmId.XChaCha20Poly1305 => XChaChaNonceSize, + CryptoSpec.AlgorithmId.SealToX25519 => XChaChaNonceSize, + CryptoSpec.AlgorithmId.Aes256Gcm => AesGcmNonceSize, + _ => throw new ArgumentOutOfRangeException(nameof(algorithm), algorithm, "Unknown algorithm."), + }; + + /// True when the algorithm carries an ephemeral public key in its header. + public static bool HasEphemeralPublicKey(CryptoSpec.AlgorithmId algorithm) => + algorithm == CryptoSpec.AlgorithmId.SealToX25519; + + /// Total prefix length before the ciphertext for a given algorithm. + public static int PrefixSizeFor(CryptoSpec.AlgorithmId algorithm) => + HeaderSize + + (HasEphemeralPublicKey(algorithm) ? CryptoSpec.PublicKeySize : 0) + + NonceSizeFor(algorithm); + + /// Smallest legal envelope for an algorithm: prefix plus a bare tag. + public static int MinimumSizeFor(CryptoSpec.AlgorithmId algorithm) => + PrefixSizeFor(algorithm) + CryptoSpec.TagSize; + + /// Exact size of an envelope for an algorithm and ciphertext length. + public static int SizeFor(CryptoSpec.AlgorithmId algorithm, int ciphertextLength) => + PrefixSizeFor(algorithm) + ciphertextLength; + + /// + /// Writes an envelope. + /// + /// Buffer receiving the envelope. + /// Construction used. + /// Nonce, whose length must match the algorithm. + /// Ciphertext including its trailing tag. + /// + /// Ephemeral X25519 public key, required for + /// and forbidden otherwise. + /// + /// Number of bytes written. + public static int Write( + Span destination, + CryptoSpec.AlgorithmId algorithm, + ReadOnlySpan nonce, + ReadOnlySpan ciphertext, + ReadOnlySpan ephemeralPublicKey = default) + { + var expectedNonce = NonceSizeFor(algorithm); + if (nonce.Length != expectedNonce) + { + throw new ArgumentException( + $"{algorithm} requires a {expectedNonce}-byte nonce, got {nonce.Length}.", + nameof(nonce)); + } + + var wantsEphemeral = HasEphemeralPublicKey(algorithm); + if (wantsEphemeral && ephemeralPublicKey.Length != CryptoSpec.PublicKeySize) + { + throw new ArgumentException( + $"{algorithm} requires a {CryptoSpec.PublicKeySize}-byte ephemeral public key.", + nameof(ephemeralPublicKey)); + } + + if (!wantsEphemeral && !ephemeralPublicKey.IsEmpty) + { + throw new ArgumentException( + $"{algorithm} does not carry an ephemeral public key.", + nameof(ephemeralPublicKey)); + } + + if (ciphertext.Length < CryptoSpec.TagSize) + { + throw new ArgumentException( + $"Ciphertext must include a {CryptoSpec.TagSize}-byte tag.", + nameof(ciphertext)); + } + + var total = SizeFor(algorithm, ciphertext.Length); + if (destination.Length < total) + { + throw new ArgumentException($"Destination must be at least {total} bytes.", nameof(destination)); + } + + CryptoSpec.EnvelopeMagic.CopyTo(destination); + destination[OffsetAlgorithm] = (byte)algorithm; + destination[OffsetFlags] = 0; + + var cursor = HeaderSize; + if (wantsEphemeral) + { + ephemeralPublicKey.CopyTo(destination[cursor..]); + cursor += CryptoSpec.PublicKeySize; + } + + nonce.CopyTo(destination[cursor..]); + cursor += nonce.Length; + + ciphertext.CopyTo(destination[cursor..]); + return cursor + ciphertext.Length; + } + + /// Writes an envelope into a new array. + public static byte[] Write( + CryptoSpec.AlgorithmId algorithm, + ReadOnlySpan nonce, + ReadOnlySpan ciphertext, + ReadOnlySpan ephemeralPublicKey = default) + { + var buffer = new byte[SizeFor(algorithm, ciphertext.Length)]; + Write(buffer, algorithm, nonce, ciphertext, ephemeralPublicKey); + return buffer; + } + + /// + /// Parses an envelope without copying. Returns false for anything malformed or not fully + /// understood, rather than throwing, because envelopes arrive from an untrusted server. + /// + public static bool TryRead(ReadOnlySpan envelope, out DshEnvelopeView view) + { + view = default; + + if (envelope.Length < HeaderSize) + { + return false; + } + + if (!envelope[..CryptoSpec.EnvelopeMagic.Length].SequenceEqual(CryptoSpec.EnvelopeMagic)) + { + return false; + } + + // Reject unknown flag bits: an envelope we do not fully understand must fail closed. + if (envelope[OffsetFlags] != 0) + { + return false; + } + + var algorithmByte = envelope[OffsetAlgorithm]; + if (!Enum.IsDefined(typeof(CryptoSpec.AlgorithmId), algorithmByte)) + { + return false; + } + + var algorithm = (CryptoSpec.AlgorithmId)algorithmByte; + if (algorithm == CryptoSpec.AlgorithmId.Unspecified) + { + return false; + } + + if (envelope.Length < MinimumSizeFor(algorithm)) + { + return false; + } + + var cursor = HeaderSize; + var ephemeral = ReadOnlySpan.Empty; + if (HasEphemeralPublicKey(algorithm)) + { + ephemeral = envelope.Slice(cursor, CryptoSpec.PublicKeySize); + cursor += CryptoSpec.PublicKeySize; + } + + var nonceSize = NonceSizeFor(algorithm); + var nonce = envelope.Slice(cursor, nonceSize); + cursor += nonceSize; + + view = new DshEnvelopeView(algorithm, ephemeral, nonce, envelope[cursor..]); + return true; + } +} diff --git a/src/DodoSSH.Crypto/DshEnvelopeView.cs b/src/DodoSSH.Crypto/DshEnvelopeView.cs new file mode 100644 index 0000000..82ae2b7 --- /dev/null +++ b/src/DodoSSH.Crypto/DshEnvelopeView.cs @@ -0,0 +1,41 @@ +using System.Diagnostics.CodeAnalysis; + +namespace DodoSSH.Crypto; + +/// +/// A parsed view over an envelope's bytes. +/// +/// +/// Holds no copies, so it must not outlive the buffer it was parsed from. Produced by +/// . +/// +[SuppressMessage( + "Performance", + "CA1815:Override equals and object equals operator", + Justification = "A ref struct over borrowed spans; equality is meaningless and cannot be expressed.")] +public readonly ref struct DshEnvelopeView +{ + internal DshEnvelopeView( + CryptoSpec.AlgorithmId algorithm, + ReadOnlySpan ephemeralPublicKey, + ReadOnlySpan nonce, + ReadOnlySpan ciphertext) + { + Algorithm = algorithm; + EphemeralPublicKey = ephemeralPublicKey; + Nonce = nonce; + Ciphertext = ciphertext; + } + + /// Construction the envelope declares. + public CryptoSpec.AlgorithmId Algorithm { get; } + + /// Ephemeral X25519 public key, empty unless the algorithm carries one. + public ReadOnlySpan EphemeralPublicKey { get; } + + /// Nonce. + public ReadOnlySpan Nonce { get; } + + /// Ciphertext, including its trailing authentication tag. + public ReadOnlySpan Ciphertext { get; } +} diff --git a/src/DodoSSH.Crypto/packages.lock.json b/src/DodoSSH.Crypto/packages.lock.json index fd59966..60d4d9f 100644 --- a/src/DodoSSH.Crypto/packages.lock.json +++ b/src/DodoSSH.Crypto/packages.lock.json @@ -19,6 +19,21 @@ "requested": "[10.0.10, )", "resolved": "10.0.10", "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg==" + }, + "NSec.Cryptography": { + "type": "Direct", + "requested": "[26.4.0, )", + "resolved": "26.4.0", + "contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==", + "dependencies": { + "libsodium": "[1.0.22, 1.0.23)" + } + }, + "libsodium": { + "type": "CentralTransitive", + "requested": "[1.0.22, )", + "resolved": "1.0.22", + "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ==" } } } diff --git a/tests/DodoSSH.Crypto.Tests/AadDescriptorTests.cs b/tests/DodoSSH.Crypto.Tests/AadDescriptorTests.cs new file mode 100644 index 0000000..c7fb7ba --- /dev/null +++ b/tests/DodoSSH.Crypto.Tests/AadDescriptorTests.cs @@ -0,0 +1,134 @@ +using DodoSSH.Crypto; + +namespace DodoSSH.Crypto.Tests; + +/// +/// Canonical AAD encoding, per docs/crypto.md §4. +/// +public sealed class AadDescriptorTests +{ + private static readonly Guid ResourceId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f"); + private static readonly Guid KeyId = Guid.Parse("0192f0c8-9999-7aaa-8bbb-cccccccccccc"); + + private static AadDescriptor Sample() => AadDescriptor.Create( + CryptoSpec.AadPurpose.ItemPayload, + CryptoSpec.AadResourceType.Credential, + ResourceId, + KeyId, + keyGeneration: 7, + itemVersion: 3); + + [Fact] + public void Encoding_IsExactlySixtyFourBytes() + { + Sample().ToCanonicalEncoding().Length.ShouldBe(CryptoSpec.AadEncodedLength); + } + + [Fact] + public void Encoding_StartsWithMagicAndVersions() + { + var encoded = Sample().ToCanonicalEncoding(); + + encoded[..5].ShouldBe("dsh1\n"u8.ToArray()); + encoded[5].ShouldBe(CryptoSpec.CurrentAadVersion); + encoded[6].ShouldBe((byte)CryptoSpec.AadPurpose.ItemPayload); + encoded[7].ShouldBe((byte)CryptoSpec.AadResourceType.Credential); + } + + [Fact] + public void Encoding_WritesUuidsInRfc4122ByteOrder() + { + // Guid.ToByteArray() emits the first three groups little-endian. Using it here would + // make our ciphertext undecryptable by any other implementation of this spec, and the + // bug would only surface at a cross-implementation boundary. + var encoded = Sample().ToCanonicalEncoding(); + + encoded[8..24].ShouldBe(ResourceId.ToByteArray(bigEndian: true)); + encoded[24..40].ShouldBe(KeyId.ToByteArray(bigEndian: true)); + + // And prove the mixed-endian form differs, so this test cannot pass vacuously. + ResourceId.ToByteArray(bigEndian: true).ShouldNotBe(ResourceId.ToByteArray()); + } + + [Fact] + public void Encoding_WritesIntegersBigEndian() + { + var encoded = Sample().ToCanonicalEncoding(); + + encoded[40..44].ShouldBe(new byte[] { 0, 0, 0, 7 }); // keyGeneration + encoded[44..48].ShouldBe(new byte[] { 0, 0, 0, 3 }); // itemVersion + encoded[48..50].ShouldBe(new byte[] { 0, 1 }); // schemaVersion + } + + [Fact] + public void Encoding_LeavesReservedBytesZero() + { + Sample().ToCanonicalEncoding()[50..64].ShouldAllBe(b => b == 0); + } + + [Fact] + public void Encoding_IsDeterministic() + { + Sample().ToCanonicalEncoding().ShouldBe(Sample().ToCanonicalEncoding()); + } + + [Fact] + public void Aad_IsSha256OfTheCanonicalEncoding() + { + var descriptor = Sample(); + + descriptor.ComputeAad().ShouldBe( + System.Security.Cryptography.SHA256.HashData(descriptor.ToCanonicalEncoding())); + } + + [Fact] + public void UnspecifiedPurpose_IsRejected() + { + var descriptor = AadDescriptor.Create( + CryptoSpec.AadPurpose.Unspecified, + CryptoSpec.AadResourceType.Host, + ResourceId); + + Should.Throw(() => descriptor.ToCanonicalEncoding()); + } + + [Fact] + public void ShortDestination_IsRejected() + { + var descriptor = Sample(); + + Should.Throw(() => + { + var tooSmall = new byte[CryptoSpec.AadEncodedLength - 1]; + descriptor.WriteCanonicalEncoding(tooSmall); + }); + } + + /// + /// Each field must change the AAD. If one did not, the corresponding substitution attack + /// in docs/crypto.md §4.4 would succeed. + /// + [Fact] + public void EveryField_ChangesTheAad() + { + var baseline = Sample(); + var baselineAad = baseline.ComputeAad(); + + var variants = new (string Field, AadDescriptor Descriptor)[] + { + ("purpose", baseline with { Purpose = CryptoSpec.AadPurpose.ItemMetadata }), + ("resourceType", baseline with { ResourceType = CryptoSpec.AadResourceType.Host }), + ("resourceId", baseline with { ResourceId = Guid.Parse("0192f0c8-dead-7bee-8fee-000000000001") }), + ("keyId", baseline with { KeyId = Guid.Empty }), + ("keyGeneration", baseline with { KeyGeneration = 8 }), + ("itemVersion", baseline with { ItemVersion = 4 }), + ("aadVersion", baseline with { AadVersion = 2 }), + ("schemaVersion", baseline with { SchemaVersion = 2 }), + }; + + foreach (var (field, descriptor) in variants) + { + descriptor.ComputeAad().ShouldNotBe(baselineAad, $"changing {field} must change the AAD"); + } + } +} diff --git a/tests/DodoSSH.Crypto.Tests/CryptoSpecTests.cs b/tests/DodoSSH.Crypto.Tests/CryptoSpecTests.cs index d4849f7..04a7037 100644 --- a/tests/DodoSSH.Crypto.Tests/CryptoSpecTests.cs +++ b/tests/DodoSSH.Crypto.Tests/CryptoSpecTests.cs @@ -6,23 +6,48 @@ namespace DodoSSH.Crypto.Tests; /// Pins the specification constants that are written into stored data. /// /// -/// These are not busywork. The envelope magic and AAD version are persisted in every -/// ciphertext row, and only clients can re-encrypt: if one of these changes without a -/// deliberate migration path, existing vaults stop decrypting and the server cannot help. +/// These are not busywork. The envelope magic, AAD version and every enum value are persisted +/// in ciphertext rows or in the AAD they are bound to, and only clients can re-encrypt: if one +/// changes without a deliberate migration path, existing vaults stop decrypting and the server +/// cannot help. /// public sealed class CryptoSpecTests { [Fact] public void EnvelopeMagic_IsStable() { - CryptoSpec.EnvelopeMagic.ShouldBe("DSH1"); + CryptoSpec.EnvelopeMagic.ToArray().ShouldBe("DSH1"u8.ToArray()); + } + + [Fact] + public void AadMagic_IsStable() + { + CryptoSpec.AadMagic.ToArray().ShouldBe("dsh1\n"u8.ToArray()); } [Fact] public void CurrentAadVersion_IsStable() { // Bumping this requires a lazy re-encrypt-on-write path in the client first. - CryptoSpec.CurrentAadVersion.ShouldBe((short)1); + CryptoSpec.CurrentAadVersion.ShouldBe((byte)1); + } + + [Fact] + public void CurrentSchemaVersion_IsStable() + { + CryptoSpec.CurrentSchemaVersion.ShouldBe((ushort)1); + } + + [Fact] + public void Sizes_MatchTheSpecification() + { + CryptoSpec.AadEncodedLength.ShouldBe(64); + CryptoSpec.SymmetricKeySize.ShouldBe(32); + CryptoSpec.PublicKeySize.ShouldBe(32); + CryptoSpec.SignatureSize.ShouldBe(64); + CryptoSpec.DigestSize.ShouldBe(32); + CryptoSpec.TagSize.ShouldBe(16); + CryptoSpec.SaltSize.ShouldBe(16); } [Theory] @@ -37,9 +62,76 @@ public sealed class CryptoSpecTests [Fact] public void AlgorithmId_4_IsReservedForHybridPostQuantumSeal() { - // Reserved for X25519 + ML-KEM-768. Claimed now so the identifier cannot be - // reused: store-now-decrypt-later is a real threat for long-lived SSH keys. - // AlgorithmId is byte-backed, matching the single alg_id byte in the envelope. + // Reserved for X25519 + ML-KEM-768. Claimed now so the identifier cannot be reused: + // store-now-decrypt-later is a real threat for long-lived SSH keys. Enum.IsDefined(typeof(CryptoSpec.AlgorithmId), (byte)4).ShouldBeFalse(); } + + [Theory] + [InlineData(CryptoSpec.AadPurpose.UserSecretBundle, 1)] + [InlineData(CryptoSpec.AadPurpose.VaultKeyGrant, 2)] + [InlineData(CryptoSpec.AadPurpose.ItemDataKey, 3)] + [InlineData(CryptoSpec.AadPurpose.ItemPayload, 4)] + [InlineData(CryptoSpec.AadPurpose.ItemMetadata, 5)] + [InlineData(CryptoSpec.AadPurpose.LocalCache, 6)] + public void AadPurpose_HasStableWireValue(CryptoSpec.AadPurpose purpose, int expected) + { + ((int)purpose).ShouldBe(expected); + } + + [Theory] + [InlineData(CryptoSpec.AadResourceType.User, 1)] + [InlineData(CryptoSpec.AadResourceType.Device, 2)] + [InlineData(CryptoSpec.AadResourceType.Vault, 3)] + [InlineData(CryptoSpec.AadResourceType.Host, 4)] + [InlineData(CryptoSpec.AadResourceType.Credential, 5)] + [InlineData(CryptoSpec.AadResourceType.SshKey, 6)] + [InlineData(CryptoSpec.AadResourceType.HostGroup, 7)] + [InlineData(CryptoSpec.AadResourceType.Tag, 8)] + [InlineData(CryptoSpec.AadResourceType.Snippet, 9)] + [InlineData(CryptoSpec.AadResourceType.PortForward, 10)] + [InlineData(CryptoSpec.AadResourceType.KnownHostKey, 11)] + public void AadResourceType_HasStableWireValue(CryptoSpec.AadResourceType type, int expected) + { + ((int)type).ShouldBe(expected); + } + + [Fact] + public void DerivationLabels_AreStable() + { + // These are HKDF info strings; changing one silently derives a different key. + CryptoSpec.DerivationLabels.PassphraseKek.ToArray() + .ShouldBe("dsh1/kek/passphrase/v1"u8.ToArray()); + CryptoSpec.DerivationLabels.LocalCache.ToArray() + .ShouldBe("dsh1/localcache/v1"u8.ToArray()); + CryptoSpec.DerivationLabels.SealTo.ToArray() + .ShouldBe("dsh1/sealto/v1|"u8.ToArray()); + CryptoSpec.DerivationLabels.Fingerprint.ToArray() + .ShouldBe("dsh1/fp/v1"u8.ToArray()); + } + + [Fact] + public void SigningContexts_AreStable() + { + CryptoSpec.SigningContexts.KeyStatement.ToArray() + .ShouldBe("dsh1/sig/keystatement/v1"u8.ToArray()); + CryptoSpec.SigningContexts.Grant.ToArray() + .ShouldBe("dsh1/sig/grant/v1"u8.ToArray()); + CryptoSpec.SigningContexts.Attestation.ToArray() + .ShouldBe("dsh1/sig/attestation/v1"u8.ToArray()); + } + + [Fact] + public void SigningContexts_AreAllDistinct() + { + // A shared context would let a signature in one role be replayed in another. + string[] contexts = + [ + System.Text.Encoding.UTF8.GetString(CryptoSpec.SigningContexts.KeyStatement), + System.Text.Encoding.UTF8.GetString(CryptoSpec.SigningContexts.Grant), + System.Text.Encoding.UTF8.GetString(CryptoSpec.SigningContexts.Attestation), + ]; + + contexts.Distinct(StringComparer.Ordinal).Count().ShouldBe(contexts.Length); + } } diff --git a/tests/DodoSSH.Crypto.Tests/DodoSSH.Crypto.Tests.csproj b/tests/DodoSSH.Crypto.Tests/DodoSSH.Crypto.Tests.csproj index 3599401..8ffac79 100644 --- a/tests/DodoSSH.Crypto.Tests/DodoSSH.Crypto.Tests.csproj +++ b/tests/DodoSSH.Crypto.Tests/DodoSSH.Crypto.Tests.csproj @@ -14,4 +14,15 @@ + + + + + diff --git a/tests/DodoSSH.Crypto.Tests/DshCryptoTests.cs b/tests/DodoSSH.Crypto.Tests/DshCryptoTests.cs new file mode 100644 index 0000000..9ef3561 --- /dev/null +++ b/tests/DodoSSH.Crypto.Tests/DshCryptoTests.cs @@ -0,0 +1,327 @@ +using System.Security.Cryptography; +using DodoSSH.Crypto; +using NSec.Cryptography; + +namespace DodoSSH.Crypto.Tests; + +/// +/// Round-trip behaviour and, more importantly, the negative cases from docs/crypto.md §4.4. +/// +/// +/// The negative tests are the point of this file. They are the executable form of the claim +/// that a server holding every ciphertext and every plaintext column still cannot relocate, +/// roll back, replay or repurpose a blob. +/// +public sealed class DshCryptoTests +{ + private static readonly Guid CredentialId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f"); + private static readonly Guid OtherCredentialId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e10"); + + private static byte[] NewKey() => RandomNumberGenerator.GetBytes(CryptoSpec.SymmetricKeySize); + + private static AadDescriptor Payload(Guid id, uint generation = 1, uint version = 1) => + AadDescriptor.Create( + CryptoSpec.AadPurpose.ItemPayload, + CryptoSpec.AadResourceType.Credential, + id, + keyGeneration: generation, + itemVersion: version); + + [Fact] + public void Seal_RoundTrips() + { + var key = NewKey(); + var plaintext = "correct horse battery staple"u8.ToArray(); + var descriptor = Payload(CredentialId); + + var envelope = DshCrypto.Seal(key, plaintext, descriptor); + + DshCrypto.Open(key, envelope, descriptor).ShouldBe(plaintext); + } + + [Fact] + public void Seal_ProducesAWellFormedEnvelope() + { + var envelope = DshCrypto.Seal(NewKey(), "x"u8, Payload(CredentialId)); + + DshEnvelope.TryRead(envelope, out var view).ShouldBeTrue(); + view.Algorithm.ShouldBe(CryptoSpec.AlgorithmId.XChaCha20Poly1305); + view.Nonce.Length.ShouldBe(DshEnvelope.XChaChaNonceSize); + view.EphemeralPublicKey.IsEmpty.ShouldBeTrue(); + envelope[..4].ShouldBe("DSH1"u8.ToArray()); + } + + [Fact] + public void Seal_UsesAFreshNoncePerCall() + { + var key = NewKey(); + var descriptor = Payload(CredentialId); + + var first = DshCrypto.Seal(key, "same"u8, descriptor); + var second = DshCrypto.Seal(key, "same"u8, descriptor); + + first.ShouldNotBe(second); + } + + [Fact] + public void Open_RejectsTheWrongKey() + { + var envelope = DshCrypto.Seal(NewKey(), "secret"u8, Payload(CredentialId)); + + DshCrypto.Open(NewKey(), envelope, Payload(CredentialId)).ShouldBeNull(); + } + + [Fact] + public void Open_RejectsATamperedCiphertext() + { + var key = NewKey(); + var descriptor = Payload(CredentialId); + var envelope = DshCrypto.Seal(key, "secret"u8, descriptor); + + envelope[^1] ^= 0x01; + + DshCrypto.Open(key, envelope, descriptor).ShouldBeNull(); + } + + [Fact] + public void Open_RejectsANonZeroFlagByte() + { + var key = NewKey(); + var descriptor = Payload(CredentialId); + var envelope = DshCrypto.Seal(key, "secret"u8, descriptor); + + // Fail closed on an envelope we do not fully understand. + envelope[5] = 0x01; + + DshCrypto.Open(key, envelope, descriptor).ShouldBeNull(); + } + + [Theory] + [InlineData("DSH0")] + [InlineData("XSH1")] + public void Open_RejectsABadMagic(string magic) + { + var key = NewKey(); + var descriptor = Payload(CredentialId); + var envelope = DshCrypto.Seal(key, "secret"u8, descriptor); + + System.Text.Encoding.ASCII.GetBytes(magic).CopyTo(envelope, 0); + + DshCrypto.Open(key, envelope, descriptor).ShouldBeNull(); + } + + [Fact] + public void Open_RejectsATruncatedEnvelope() + { + var key = NewKey(); + var descriptor = Payload(CredentialId); + var envelope = DshCrypto.Seal(key, "secret"u8, descriptor); + + DshCrypto.Open(key, envelope.AsSpan(0, envelope.Length / 2), descriptor).ShouldBeNull(); + DshCrypto.Open(key, [], descriptor).ShouldBeNull(); + } + + // ---- docs/crypto.md §4.4: what a malicious server cannot do ---- + + [Fact] + public void Server_CannotMoveCiphertextToAnotherResource() + { + var key = NewKey(); + var envelope = DshCrypto.Seal(key, "host-a password"u8, Payload(CredentialId)); + + // Same vault key, same everything, different row. + DshCrypto.Open(key, envelope, Payload(OtherCredentialId)).ShouldBeNull(); + } + + [Fact] + public void Server_CannotRollBackAKeyGeneration() + { + var key = NewKey(); + var envelope = DshCrypto.Seal(key, "secret"u8, Payload(CredentialId, generation: 5)); + + DshCrypto.Open(key, envelope, Payload(CredentialId, generation: 4)).ShouldBeNull(); + } + + [Fact] + public void Server_CannotRollBackAnItemVersion() + { + var key = NewKey(); + var envelope = DshCrypto.Seal(key, "v2 secret"u8, Payload(CredentialId, version: 2)); + + DshCrypto.Open(key, envelope, Payload(CredentialId, version: 1)).ShouldBeNull(); + } + + [Fact] + public void Server_CannotRepurposeAPayloadAsMetadata() + { + var key = NewKey(); + var envelope = DshCrypto.Seal(key, "secret"u8, Payload(CredentialId)); + + var asMetadata = AadDescriptor.Create( + CryptoSpec.AadPurpose.ItemMetadata, + CryptoSpec.AadResourceType.Credential, + CredentialId, + itemVersion: 1); + + DshCrypto.Open(key, envelope, asMetadata).ShouldBeNull(); + } + + [Fact] + public void Server_CannotRepurposeAcrossResourceTypes() + { + var key = NewKey(); + var envelope = DshCrypto.Seal(key, "secret"u8, Payload(CredentialId)); + + var asHost = AadDescriptor.Create( + CryptoSpec.AadPurpose.ItemPayload, + CryptoSpec.AadResourceType.Host, + CredentialId, + itemVersion: 1); + + DshCrypto.Open(key, envelope, asHost).ShouldBeNull(); + } + + // ---- SealTo ---- + + private static Key NewAgreementKey() => Key.Create( + KeyAgreementAlgorithm.X25519, + new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport }); + + private static AadDescriptor Grant(Guid vaultId, uint generation = 1) => AadDescriptor.Create( + CryptoSpec.AadPurpose.VaultKeyGrant, + CryptoSpec.AadResourceType.Vault, + vaultId, + keyGeneration: generation); + + [Fact] + public void SealTo_RoundTrips() + { + var vaultId = Guid.CreateVersion7(); + using var recipient = NewAgreementKey(); + var recipientPublic = recipient.PublicKey.Export(KeyBlobFormat.RawPublicKey); + + var vaultKey = NewKey(); + var descriptor = Grant(vaultId); + + var envelope = DshCrypto.SealTo(recipientPublic, vaultKey, descriptor); + + DshCrypto.OpenSealed(recipient, envelope, descriptor).ShouldBe(vaultKey); + } + + [Fact] + public void SealTo_ProducesAWellFormedEnvelopeCarryingAnEphemeralKey() + { + using var recipient = NewAgreementKey(); + var envelope = DshCrypto.SealTo( + recipient.PublicKey.Export(KeyBlobFormat.RawPublicKey), + NewKey(), + Grant(Guid.CreateVersion7())); + + DshEnvelope.TryRead(envelope, out var view).ShouldBeTrue(); + view.Algorithm.ShouldBe(CryptoSpec.AlgorithmId.SealToX25519); + view.EphemeralPublicKey.Length.ShouldBe(CryptoSpec.PublicKeySize); + view.Nonce.Length.ShouldBe(DshEnvelope.XChaChaNonceSize); + } + + [Fact] + public void SealTo_IsNotOpenableByAnotherRecipient() + { + using var intended = NewAgreementKey(); + using var attacker = NewAgreementKey(); + var descriptor = Grant(Guid.CreateVersion7()); + + var envelope = DshCrypto.SealTo( + intended.PublicKey.Export(KeyBlobFormat.RawPublicKey), + NewKey(), + descriptor); + + DshCrypto.OpenSealed(attacker, envelope, descriptor).ShouldBeNull(); + } + + [Fact] + public void SealTo_IsBoundToItsVaultAndGeneration() + { + var vaultId = Guid.CreateVersion7(); + using var recipient = NewAgreementKey(); + var recipientPublic = recipient.PublicKey.Export(KeyBlobFormat.RawPublicKey); + + var envelope = DshCrypto.SealTo(recipientPublic, NewKey(), Grant(vaultId, generation: 3)); + + // A revoked grant from an earlier generation must not be replayable. + DshCrypto.OpenSealed(recipient, envelope, Grant(vaultId, generation: 2)).ShouldBeNull(); + DshCrypto.OpenSealed(recipient, envelope, Grant(Guid.CreateVersion7(), generation: 3)).ShouldBeNull(); + } + + [Fact] + public void SealTo_UsesAFreshEphemeralKeyPerCall() + { + using var recipient = NewAgreementKey(); + var recipientPublic = recipient.PublicKey.Export(KeyBlobFormat.RawPublicKey); + var descriptor = Grant(Guid.CreateVersion7()); + var vaultKey = NewKey(); + + var first = DshCrypto.SealTo(recipientPublic, vaultKey, descriptor); + var second = DshCrypto.SealTo(recipientPublic, vaultKey, descriptor); + + DshEnvelope.TryRead(first, out var a).ShouldBeTrue(); + DshEnvelope.TryRead(second, out var b).ShouldBeTrue(); + + a.EphemeralPublicKey.SequenceEqual(b.EphemeralPublicKey).ShouldBeFalse(); + } + + [Fact] + public void OpenSealed_RejectsASymmetricEnvelope() + { + // Cross-construction confusion: a symmetric envelope must not be accepted here. + using var recipient = NewAgreementKey(); + var descriptor = Grant(Guid.CreateVersion7()); + var symmetric = DshCrypto.Seal(NewKey(), "secret"u8, descriptor); + + DshCrypto.OpenSealed(recipient, symmetric, descriptor).ShouldBeNull(); + } + + [Fact] + public void Open_RejectsASealedEnvelope() + { + using var recipient = NewAgreementKey(); + var descriptor = Grant(Guid.CreateVersion7()); + var sealedEnvelope = DshCrypto.SealTo( + recipient.PublicKey.Export(KeyBlobFormat.RawPublicKey), + NewKey(), + descriptor); + + DshCrypto.Open(NewKey(), sealedEnvelope, descriptor).ShouldBeNull(); + } + + // ---- Fingerprints ---- + + [Fact] + public void Fingerprint_IsStableAndOrderSensitive() + { + var x25519 = RandomNumberGenerator.GetBytes(CryptoSpec.PublicKeySize); + var ed25519 = RandomNumberGenerator.GetBytes(CryptoSpec.PublicKeySize); + + var fingerprint = DshCrypto.ComputeFingerprint(x25519, ed25519); + + fingerprint.Length.ShouldBe(CryptoSpec.DigestSize); + fingerprint.ShouldBe(DshCrypto.ComputeFingerprint(x25519, ed25519)); + + // Swapping the keys must change the fingerprint, or the two roles would be conflated. + fingerprint.ShouldNotBe(DshCrypto.ComputeFingerprint(ed25519, x25519)); + } + + [Fact] + public void Fingerprint_RejectsWrongSizedKeys() + { + var valid = RandomNumberGenerator.GetBytes(CryptoSpec.PublicKeySize); + + Should.Throw(() => DshCrypto.ComputeFingerprint(new byte[31], valid)); + Should.Throw(() => DshCrypto.ComputeFingerprint(valid, new byte[33])); + } + + [Fact] + public void Seal_RejectsWrongSizedKeys() + { + Should.Throw(() => DshCrypto.Seal(new byte[16], "x"u8, Payload(CredentialId))); + } +} diff --git a/tests/DodoSSH.Crypto.Tests/GoldenVectorTests.cs b/tests/DodoSSH.Crypto.Tests/GoldenVectorTests.cs new file mode 100644 index 0000000..e36ebb5 --- /dev/null +++ b/tests/DodoSSH.Crypto.Tests/GoldenVectorTests.cs @@ -0,0 +1,104 @@ +namespace DodoSSH.Crypto.Tests; + +/// +/// Asserts the committed golden vectors still hold. +/// +/// +/// +/// This is the single most important test in the product. The server holds ciphertext and no +/// keys, so it can never re-encrypt anything: a change to the envelope layout or to AAD +/// derivation that reaches a release makes every existing vault undecryptable, with no +/// server-side remedy and no rollback. +/// +/// +/// A failure here is never fixed by regenerating the fixture. It means either a genuine +/// regression, or an intentional format change — which requires a new +/// aadVersion/algId and a client-side lazy re-encrypt-on-write path to exist +/// first. See docs/crypto.md §8. +/// +/// +/// To regenerate deliberately, set DODOSSH_REGENERATE_VECTORS=1. The test rewrites the +/// fixture in the source tree and then fails, so the diff has to be reviewed rather than +/// silently absorbed. +/// +/// +public sealed class GoldenVectorTests +{ + private const string RegenerateVariable = "DODOSSH_REGENERATE_VECTORS"; + private const string FixtureRelativePath = "fixtures/crypto/vectors.json"; + + [Fact] + public void CommittedVectors_MatchCurrentImplementation() + { + var actual = GoldenVectors.Generate(); + + if (string.Equals(Environment.GetEnvironmentVariable(RegenerateVariable), "1", StringComparison.Ordinal)) + { + var sourcePath = ResolveSourceTreeFixturePath(); + Directory.CreateDirectory(Path.GetDirectoryName(sourcePath)!); + File.WriteAllText(sourcePath, actual); + + Assert.Fail( + $"Regenerated {sourcePath}. Review the diff and unset {RegenerateVariable}. " + + "If the envelope or AAD changed, a version bump and a client migration path are required first."); + } + + var expected = File.ReadAllText(OutputFixturePath()); + + Normalise(actual).ShouldBe( + Normalise(expected), + "The DSH1 format or AAD derivation changed. This would make every existing vault " + + "undecryptable. Do not regenerate the fixture to silence this."); + } + + [Fact] + public void Fixture_IsCommittedAndNonTrivial() + { + var content = File.ReadAllText(OutputFixturePath()); + + content.Length.ShouldBeGreaterThan(1000); + content.ShouldContain("canonicalEncoding"); + content.ShouldContain("\"specVersion\": 1"); + } + + private static string Normalise(string json) => json.ReplaceLineEndings("\n").TrimEnd(); + + /// + /// The fixture as copied beside the test assembly. Robust under deterministic source paths. + /// + private static string OutputFixturePath() + { + var path = Path.Combine(AppContext.BaseDirectory, FixtureRelativePath); + + File.Exists(path).ShouldBeTrue( + $"Golden vector fixture missing at {path}. It should be copied to the output " + + $"directory by the project file. Set {RegenerateVariable}=1 to create it."); + + return path; + } + + /// + /// Locates the fixture in the source tree by walking up to the solution file. + /// + /// + /// Used only when regenerating, which is a developer-local action. + /// + private static string ResolveSourceTreeFixturePath() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "DodoSSH.slnx"))) + { + directory = directory.Parent; + } + + if (directory is null) + { + throw new InvalidOperationException( + "Could not locate the repository root (no DodoSSH.slnx found above " + + $"{AppContext.BaseDirectory}). Regenerate from within the repository."); + } + + return Path.Combine(directory.FullName, "tests", FixtureRelativePath.Replace('/', Path.DirectorySeparatorChar)); + } +} diff --git a/tests/DodoSSH.Crypto.Tests/GoldenVectors.cs b/tests/DodoSSH.Crypto.Tests/GoldenVectors.cs new file mode 100644 index 0000000..eb17e94 --- /dev/null +++ b/tests/DodoSSH.Crypto.Tests/GoldenVectors.cs @@ -0,0 +1,281 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using DodoSSH.Crypto; +using NSec.Cryptography; + +namespace DodoSSH.Crypto.Tests; + +/// +/// Produces the deterministic byte-level results of the DSH1 specification. +/// +/// +/// Only deterministic operations belong here. SealTo and signing draw fresh randomness, +/// so they are covered by round-trip and negative tests in instead. +/// +internal static class GoldenVectors +{ + private static readonly Guid ResourceA = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f"); + private static readonly Guid ResourceB = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e10"); + private static readonly Guid KeyIdA = Guid.Parse("0192f0c8-9999-7aaa-8bbb-cccccccccccc"); + + internal static string Generate() + { + var root = new JsonObject + { + ["_comment"] = "Generated by DodoSSH.Crypto.Tests.GoldenVectors. Normative source: docs/crypto.md.", + ["_warning"] = "A failing assertion here is a regression or an intentional versioned format change. Do not regenerate to make it pass.", + ["specVersion"] = 1, + ["aad"] = BuildAadVectors(), + ["envelope"] = BuildEnvelopeVectors(), + ["aead"] = BuildAeadVectors(), + ["hkdf"] = BuildHkdfVectors(), + ["argon2id"] = BuildArgon2Vectors(), + ["fingerprint"] = BuildFingerprintVectors(), + }; + + return root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }) + "\n"; + } + + private static JsonArray BuildAadVectors() + { + (string Name, AadDescriptor Descriptor)[] cases = + [ + ("item-payload", AadDescriptor.Create( + CryptoSpec.AadPurpose.ItemPayload, + CryptoSpec.AadResourceType.Credential, + ResourceA, + KeyIdA, + keyGeneration: 7, + itemVersion: 3)), + ("item-payload-other-resource", AadDescriptor.Create( + CryptoSpec.AadPurpose.ItemPayload, + CryptoSpec.AadResourceType.Credential, + ResourceB, + KeyIdA, + keyGeneration: 7, + itemVersion: 3)), + ("item-metadata", AadDescriptor.Create( + CryptoSpec.AadPurpose.ItemMetadata, + CryptoSpec.AadResourceType.Host, + ResourceA, + keyGeneration: 1, + itemVersion: 1)), + ("vault-key-grant", AadDescriptor.Create( + CryptoSpec.AadPurpose.VaultKeyGrant, + CryptoSpec.AadResourceType.Vault, + ResourceA, + keyGeneration: 2)), + ("user-secret-bundle", AadDescriptor.Create( + CryptoSpec.AadPurpose.UserSecretBundle, + CryptoSpec.AadResourceType.User, + ResourceA)), + ("all-zero-ids", AadDescriptor.Create( + CryptoSpec.AadPurpose.LocalCache, + CryptoSpec.AadResourceType.None, + Guid.Empty, + keyGeneration: 0)), + ]; + + var array = new JsonArray(); + foreach (var (name, descriptor) in cases) + { + array.Add(new JsonObject + { + ["name"] = name, + ["purpose"] = (int)descriptor.Purpose, + ["resourceType"] = (int)descriptor.ResourceType, + ["resourceId"] = descriptor.ResourceId.ToString(), + ["keyId"] = descriptor.KeyId.ToString(), + ["keyGeneration"] = descriptor.KeyGeneration, + ["itemVersion"] = descriptor.ItemVersion, + ["aadVersion"] = descriptor.AadVersion, + ["schemaVersion"] = descriptor.SchemaVersion, + ["canonicalEncoding"] = Hex(descriptor.ToCanonicalEncoding()), + ["aad"] = Hex(descriptor.ComputeAad()), + }); + } + + return array; + } + + private static JsonArray BuildEnvelopeVectors() + { + // Framing only, with fixed inputs, so the byte layout of the header is pinned + // independently of any AEAD behaviour. + var ciphertext = Enumerable.Range(0, 20).Select(i => (byte)i).ToArray(); + var xchachaNonce = Enumerable.Range(0, DshEnvelope.XChaChaNonceSize).Select(i => (byte)(0xA0 + i)).ToArray(); + var gcmNonce = Enumerable.Range(0, DshEnvelope.AesGcmNonceSize).Select(i => (byte)(0xB0 + i)).ToArray(); + var ephemeral = Enumerable.Range(0, CryptoSpec.PublicKeySize).Select(i => (byte)(0xC0 + i)).ToArray(); + + return + [ + new JsonObject + { + ["name"] = "xchacha20poly1305", + ["algId"] = (int)CryptoSpec.AlgorithmId.XChaCha20Poly1305, + ["nonce"] = Hex(xchachaNonce), + ["ciphertext"] = Hex(ciphertext), + ["prefixSize"] = DshEnvelope.PrefixSizeFor(CryptoSpec.AlgorithmId.XChaCha20Poly1305), + ["envelope"] = Hex(DshEnvelope.Write( + CryptoSpec.AlgorithmId.XChaCha20Poly1305, xchachaNonce, ciphertext)), + }, + new JsonObject + { + ["name"] = "aes256gcm", + ["algId"] = (int)CryptoSpec.AlgorithmId.Aes256Gcm, + ["nonce"] = Hex(gcmNonce), + ["ciphertext"] = Hex(ciphertext), + ["prefixSize"] = DshEnvelope.PrefixSizeFor(CryptoSpec.AlgorithmId.Aes256Gcm), + ["envelope"] = Hex(DshEnvelope.Write( + CryptoSpec.AlgorithmId.Aes256Gcm, gcmNonce, ciphertext)), + }, + new JsonObject + { + ["name"] = "sealto-x25519", + ["algId"] = (int)CryptoSpec.AlgorithmId.SealToX25519, + ["nonce"] = Hex(xchachaNonce), + ["ephemeralPublicKey"] = Hex(ephemeral), + ["ciphertext"] = Hex(ciphertext), + ["prefixSize"] = DshEnvelope.PrefixSizeFor(CryptoSpec.AlgorithmId.SealToX25519), + ["envelope"] = Hex(DshEnvelope.Write( + CryptoSpec.AlgorithmId.SealToX25519, xchachaNonce, ciphertext, ephemeral)), + }, + ]; + } + + private static JsonArray BuildAeadVectors() + { + // Fixed key and nonce, so the AEAD itself is pinned. DshCrypto.Seal draws a random + // nonce by design, so the primitive is exercised directly here. + var key = Enumerable.Range(0, CryptoSpec.SymmetricKeySize).Select(i => (byte)i).ToArray(); + var nonce = Enumerable.Range(0, DshEnvelope.XChaChaNonceSize).Select(i => (byte)(0x10 + i)).ToArray(); + var plaintext = "correct horse battery staple"u8.ToArray(); + + var descriptor = AadDescriptor.Create( + CryptoSpec.AadPurpose.ItemPayload, + CryptoSpec.AadResourceType.Credential, + ResourceA, + KeyIdA, + keyGeneration: 7, + itemVersion: 3); + + var aad = descriptor.ComputeAad(); + + using var aeadKey = Key.Import( + AeadAlgorithm.XChaCha20Poly1305, key, KeyBlobFormat.RawSymmetricKey); + var ciphertext = AeadAlgorithm.XChaCha20Poly1305.Encrypt(aeadKey, nonce, aad, plaintext); + + return + [ + new JsonObject + { + ["name"] = "xchacha20poly1305-with-canonical-aad", + ["key"] = Hex(key), + ["nonce"] = Hex(nonce), + ["aad"] = Hex(aad), + ["plaintext"] = Hex(plaintext), + ["ciphertext"] = Hex(ciphertext), + ["envelope"] = Hex(DshEnvelope.Write( + CryptoSpec.AlgorithmId.XChaCha20Poly1305, nonce, ciphertext)), + }, + ]; + } + + private static JsonArray BuildHkdfVectors() + { + var prk = Enumerable.Range(0, 64).Select(i => (byte)i).ToArray(); + + (string Name, byte[] Info)[] cases = + [ + ("passphrase-kek", CryptoSpec.DerivationLabels.PassphraseKek.ToArray()), + ("local-cache", CryptoSpec.DerivationLabels.LocalCache.ToArray()), + ]; + + var array = new JsonArray(); + foreach (var (name, info) in cases) + { + array.Add(new JsonObject + { + ["name"] = name, + ["algorithm"] = "HKDF-SHA512-Expand", + ["prk"] = Hex(prk), + ["info"] = Encoding.UTF8.GetString(info), + ["outputLength"] = CryptoSpec.SymmetricKeySize, + ["output"] = Hex(HKDF.Expand( + HashAlgorithmName.SHA512, prk, CryptoSpec.SymmetricKeySize, info)), + }); + } + + return array; + } + + private static JsonArray BuildArgon2Vectors() + { + var salt = Enumerable.Range(0, CryptoSpec.SaltSize).Select(i => (byte)(0x20 + i)).ToArray(); + const string Passphrase = "correct horse battery staple"; + + var array = new JsonArray(); + + // Parameters of every profile are pinned cheaply. Only the smallest profile's output is + // computed, to keep the suite fast; the KDF itself is libsodium's, not ours. + (string Name, Argon2Profile Profile)[] profiles = + [ + ("passphrase-default", Argon2Profile.PassphraseDefault), + ("passphrase-reduced", Argon2Profile.PassphraseReduced), + ("passphrase-high", Argon2Profile.PassphraseHigh), + ("random-secret", Argon2Profile.RandomSecret), + ]; + + foreach (var (name, profile) in profiles) + { + array.Add(new JsonObject + { + ["name"] = name, + ["memoryMebibytes"] = profile.MemoryMebibytes, + ["memoryKibibytes"] = profile.MemoryKibibytes, + ["passes"] = profile.Passes, + ["parallelism"] = Argon2Profile.Parallelism, + }); + } + + array.Add(new JsonObject + { + ["name"] = "random-secret-output", + ["memoryMebibytes"] = Argon2Profile.RandomSecret.MemoryMebibytes, + ["memoryKibibytes"] = Argon2Profile.RandomSecret.MemoryKibibytes, + ["passes"] = Argon2Profile.RandomSecret.Passes, + ["parallelism"] = Argon2Profile.Parallelism, + ["passphrase"] = Passphrase, + ["salt"] = Hex(salt), + ["outputLength"] = CryptoSpec.SymmetricKeySize, + ["output"] = Hex(Argon2Profile.RandomSecret + .CreateAlgorithm() + .DeriveBytes(Passphrase, salt, CryptoSpec.SymmetricKeySize)), + }); + + return array; + } + + private static JsonArray BuildFingerprintVectors() + { + var x25519 = Enumerable.Range(0, CryptoSpec.PublicKeySize).Select(i => (byte)(0x40 + i)).ToArray(); + var ed25519 = Enumerable.Range(0, CryptoSpec.PublicKeySize).Select(i => (byte)(0x60 + i)).ToArray(); + + return + [ + new JsonObject + { + ["name"] = "identity-fingerprint", + ["x25519PublicKey"] = Hex(x25519), + ["ed25519PublicKey"] = Hex(ed25519), + ["fingerprint"] = Hex(DshCrypto.ComputeFingerprint(x25519, ed25519)), + }, + ]; + } + + private static string Hex(ReadOnlySpan value) => + Convert.ToHexString(value).ToLower(CultureInfo.InvariantCulture); +} diff --git a/tests/DodoSSH.Crypto.Tests/PrimitiveAvailabilityTests.cs b/tests/DodoSSH.Crypto.Tests/PrimitiveAvailabilityTests.cs new file mode 100644 index 0000000..a4852b4 --- /dev/null +++ b/tests/DodoSSH.Crypto.Tests/PrimitiveAvailabilityTests.cs @@ -0,0 +1,115 @@ +using System.Security.Cryptography; +using NSec.Cryptography; + +namespace DodoSSH.Crypto.Tests; + +/// +/// Proves the primitives docs/crypto.md depends on are actually available and functional on +/// this runtime and platform. +/// +/// +/// Not ceremonial. Two concrete risks motivated these: +/// the BCL has no X25519 or Ed25519 at all, and ChaCha20Poly1305.IsSupported is false +/// on macOS, which is what disqualified the in-box AEAD for a cross-platform client. If any +/// of these fail on a target platform, the specification is wrong rather than the code. +/// +public sealed class PrimitiveAvailabilityTests +{ + [Fact] + public void X25519_AgreesOnASharedSecret() + { + var algorithm = KeyAgreementAlgorithm.X25519; + var creation = new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport }; + + using var alice = Key.Create(algorithm, creation); + using var bob = Key.Create(algorithm, creation); + + using var aliceView = algorithm.Agree(alice, bob.PublicKey)!; + using var bobView = algorithm.Agree(bob, alice.PublicKey)!; + + var derive = KeyDerivationAlgorithm.HkdfSha256; + var fromAlice = derive.DeriveBytes(aliceView, ReadOnlySpan.Empty, "test"u8, 32); + var fromBob = derive.DeriveBytes(bobView, ReadOnlySpan.Empty, "test"u8, 32); + + fromAlice.ShouldBe(fromBob); + } + + [Fact] + public void Ed25519_SignsAndVerifies() + { + var algorithm = SignatureAlgorithm.Ed25519; + using var signer = Key.Create(algorithm); + + var message = "grant tuple"u8; + var signature = algorithm.Sign(signer, message); + + signature.Length.ShouldBe(64); + algorithm.Verify(signer.PublicKey, message, signature).ShouldBeTrue(); + algorithm.Verify(signer.PublicKey, "tampered"u8, signature).ShouldBeFalse(); + } + + [Fact] + public void XChaCha20Poly1305_RoundTripsAndDetectsAadTampering() + { + var algorithm = AeadAlgorithm.XChaCha20Poly1305; + using var key = Key.Create(algorithm); + + var nonce = RandomNumberGenerator.GetBytes(algorithm.NonceSize); + var plaintext = "id_ed25519 private key"u8; + + var ciphertext = algorithm.Encrypt(key, nonce, "aad-a"u8, plaintext); + + algorithm.Decrypt(key, nonce, "aad-a"u8, ciphertext).ShouldBe(plaintext.ToArray()); + + // The whole point of binding AAD to row identity: a different AAD must not decrypt. + algorithm.Decrypt(key, nonce, "aad-b"u8, ciphertext).ShouldBeNull(); + } + + [Fact] + public void XChaCha20Poly1305_NonceIs24BytesSoRandomNoncesAreSafe() + { + // 192-bit nonces are why we can generate one at random per message without tracking + // a counter. AES-GCM's 96-bit nonce would not permit that. + AeadAlgorithm.XChaCha20Poly1305.NonceSize.ShouldBe(24); + AeadAlgorithm.XChaCha20Poly1305.KeySize.ShouldBe(32); + AeadAlgorithm.XChaCha20Poly1305.TagSize.ShouldBe(16); + } + + [Fact] + public void Argon2id_IsAvailableAndParallelismIsPinnedToOne() + { + // libsodium's Argon2id implementation only supports p=1. docs/crypto.md compensates + // with memory cost instead; this test pins the constraint so it is not forgotten. + var algorithm = PasswordBasedKeyDerivationAlgorithm.Argon2id( + new Argon2Parameters { DegreeOfParallelism = 1, MemorySize = 1 << 20, NumberOfPasses = 1 }); + + var salt = new byte[16]; + var derived = algorithm.DeriveBytes("correct horse battery staple", salt, 32); + + derived.Length.ShouldBe(32); + derived.ShouldNotBe(new byte[32]); + } + + [Fact] + public void Argon2id_IsDeterministicForTheSamePassphraseAndSalt() + { + var algorithm = PasswordBasedKeyDerivationAlgorithm.Argon2id( + new Argon2Parameters { DegreeOfParallelism = 1, MemorySize = 1 << 20, NumberOfPasses = 1 }); + + var salt = RandomNumberGenerator.GetBytes(16); + + algorithm.DeriveBytes("passphrase", salt, 32) + .ShouldBe(algorithm.DeriveBytes("passphrase", salt, 32)); + } + + [Fact] + public void HkdfSha512_IsAvailableInTheBcl() + { + // Subkey derivation from the master key uses the BCL, not NSec: HKDF is fully + // supported on every platform. + var info = "dsh1/kek/passphrase/v1"u8.ToArray(); + var okm = HKDF.Expand(HashAlgorithmName.SHA512, new byte[64], 32, info); + + okm.Length.ShouldBe(32); + } +} diff --git a/tests/DodoSSH.Crypto.Tests/packages.lock.json b/tests/DodoSSH.Crypto.Tests/packages.lock.json index 5ef1fad..7e66ab5 100644 --- a/tests/DodoSSH.Crypto.Tests/packages.lock.json +++ b/tests/DodoSSH.Crypto.Tests/packages.lock.json @@ -195,7 +195,25 @@ } }, "dodossh.crypto": { - "type": "Project" + "type": "Project", + "dependencies": { + "NSec.Cryptography": "[26.4.0, )" + } + }, + "libsodium": { + "type": "CentralTransitive", + "requested": "[1.0.22, )", + "resolved": "1.0.22", + "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ==" + }, + "NSec.Cryptography": { + "type": "CentralTransitive", + "requested": "[26.4.0, )", + "resolved": "26.4.0", + "contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==", + "dependencies": { + "libsodium": "[1.0.22, 1.0.23)" + } } } } diff --git a/tests/fixtures/crypto/vectors.json b/tests/fixtures/crypto/vectors.json new file mode 100644 index 0000000..5cdf1b9 --- /dev/null +++ b/tests/fixtures/crypto/vectors.json @@ -0,0 +1,190 @@ +{ + "_comment": "Generated by DodoSSH.Crypto.Tests.GoldenVectors. Normative source: docs/crypto.md.", + "_warning": "A failing assertion here is a regression or an intentional versioned format change. Do not regenerate to make it pass.", + "specVersion": 1, + "aad": [ + { + "name": "item-payload", + "purpose": 4, + "resourceType": 5, + "resourceId": "0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f", + "keyId": "0192f0c8-9999-7aaa-8bbb-cccccccccccc", + "keyGeneration": 7, + "itemVersion": 3, + "aadVersion": 1, + "schemaVersion": 1, + "canonicalEncoding": "647368310a0104050192f0c81a2b7c3d8e4f5a6b7c8d9e0f0192f0c899997aaa8bbbcccccccccccc000000070000000300010000000000000000000000000000", + "aad": "bb106e753e2fd9ac31142889a4356cbf9fc1f8db3777a1921ecbb5481bd4379e" + }, + { + "name": "item-payload-other-resource", + "purpose": 4, + "resourceType": 5, + "resourceId": "0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e10", + "keyId": "0192f0c8-9999-7aaa-8bbb-cccccccccccc", + "keyGeneration": 7, + "itemVersion": 3, + "aadVersion": 1, + "schemaVersion": 1, + "canonicalEncoding": "647368310a0104050192f0c81a2b7c3d8e4f5a6b7c8d9e100192f0c899997aaa8bbbcccccccccccc000000070000000300010000000000000000000000000000", + "aad": "ae87f8da1a55b36286ed103a11fb51145adff18a222557db30422918eba6c29f" + }, + { + "name": "item-metadata", + "purpose": 5, + "resourceType": 4, + "resourceId": "0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f", + "keyId": "00000000-0000-0000-0000-000000000000", + "keyGeneration": 1, + "itemVersion": 1, + "aadVersion": 1, + "schemaVersion": 1, + "canonicalEncoding": "647368310a0105040192f0c81a2b7c3d8e4f5a6b7c8d9e0f00000000000000000000000000000000000000010000000100010000000000000000000000000000", + "aad": "cfb042451484a4f484b45e812a2c7667d12592bcb8ec47be5b5ef95c38b1d3ad" + }, + { + "name": "vault-key-grant", + "purpose": 2, + "resourceType": 3, + "resourceId": "0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f", + "keyId": "00000000-0000-0000-0000-000000000000", + "keyGeneration": 2, + "itemVersion": 0, + "aadVersion": 1, + "schemaVersion": 1, + "canonicalEncoding": "647368310a0102030192f0c81a2b7c3d8e4f5a6b7c8d9e0f00000000000000000000000000000000000000020000000000010000000000000000000000000000", + "aad": "5c9444daa7f74193b04abefb57d5a49a8782e8e1a7fd97771865f9e038f8c957" + }, + { + "name": "user-secret-bundle", + "purpose": 1, + "resourceType": 1, + "resourceId": "0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f", + "keyId": "00000000-0000-0000-0000-000000000000", + "keyGeneration": 1, + "itemVersion": 0, + "aadVersion": 1, + "schemaVersion": 1, + "canonicalEncoding": "647368310a0101010192f0c81a2b7c3d8e4f5a6b7c8d9e0f00000000000000000000000000000000000000010000000000010000000000000000000000000000", + "aad": "9f73034823c49cdfcad4fcc75e67ae22be151a92afed72ab7548097ef5a99f68" + }, + { + "name": "all-zero-ids", + "purpose": 6, + "resourceType": 0, + "resourceId": "00000000-0000-0000-0000-000000000000", + "keyId": "00000000-0000-0000-0000-000000000000", + "keyGeneration": 0, + "itemVersion": 0, + "aadVersion": 1, + "schemaVersion": 1, + "canonicalEncoding": "647368310a0106000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000", + "aad": "cebc8d57709c0ebe47874c85fc39aa538e17c4202b767673c8636ef181cd1664" + } + ], + "envelope": [ + { + "name": "xchacha20poly1305", + "algId": 1, + "nonce": "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7", + "ciphertext": "000102030405060708090a0b0c0d0e0f10111213", + "prefixSize": 30, + "envelope": "445348310100a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7000102030405060708090a0b0c0d0e0f10111213" + }, + { + "name": "aes256gcm", + "algId": 2, + "nonce": "b0b1b2b3b4b5b6b7b8b9babb", + "ciphertext": "000102030405060708090a0b0c0d0e0f10111213", + "prefixSize": 18, + "envelope": "445348310200b0b1b2b3b4b5b6b7b8b9babb000102030405060708090a0b0c0d0e0f10111213" + }, + { + "name": "sealto-x25519", + "algId": 3, + "nonce": "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7", + "ephemeralPublicKey": "c0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedf", + "ciphertext": "000102030405060708090a0b0c0d0e0f10111213", + "prefixSize": 62, + "envelope": "445348310300c0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7000102030405060708090a0b0c0d0e0f10111213" + } + ], + "aead": [ + { + "name": "xchacha20poly1305-with-canonical-aad", + "key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "nonce": "101112131415161718191a1b1c1d1e1f2021222324252627", + "aad": "bb106e753e2fd9ac31142889a4356cbf9fc1f8db3777a1921ecbb5481bd4379e", + "plaintext": "636f727265637420686f727365206261747465727920737461706c65", + "ciphertext": "4793718431eb55f3feed50be98b0416d7bff929d804d53a7873495132465b6b1da6e73e042821964543ecd90", + "envelope": "445348310100101112131415161718191a1b1c1d1e1f20212223242526274793718431eb55f3feed50be98b0416d7bff929d804d53a7873495132465b6b1da6e73e042821964543ecd90" + } + ], + "hkdf": [ + { + "name": "passphrase-kek", + "algorithm": "HKDF-SHA512-Expand", + "prk": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "info": "dsh1/kek/passphrase/v1", + "outputLength": 32, + "output": "652b3a4a3ce03b235095ad32f1eed2cfdae915b5b0a98cc9f96face30853f4c7" + }, + { + "name": "local-cache", + "algorithm": "HKDF-SHA512-Expand", + "prk": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "info": "dsh1/localcache/v1", + "outputLength": 32, + "output": "5b69ed9266ff5f297f11667ca693b0049b805365ee34d54d6e60b843e414b1f5" + } + ], + "argon2id": [ + { + "name": "passphrase-default", + "memoryMebibytes": 256, + "memoryKibibytes": 262144, + "passes": 4, + "parallelism": 1 + }, + { + "name": "passphrase-reduced", + "memoryMebibytes": 128, + "memoryKibibytes": 131072, + "passes": 3, + "parallelism": 1 + }, + { + "name": "passphrase-high", + "memoryMebibytes": 512, + "memoryKibibytes": 524288, + "passes": 4, + "parallelism": 1 + }, + { + "name": "random-secret", + "memoryMebibytes": 64, + "memoryKibibytes": 65536, + "passes": 3, + "parallelism": 1 + }, + { + "name": "random-secret-output", + "memoryMebibytes": 64, + "memoryKibibytes": 65536, + "passes": 3, + "parallelism": 1, + "passphrase": "correct horse battery staple", + "salt": "202122232425262728292a2b2c2d2e2f", + "outputLength": 32, + "output": "3573a601a50874c6c4222082d040f039ba4f557a0151e0357e8abb66fed7b29e" + } + ], + "fingerprint": [ + { + "name": "identity-fingerprint", + "x25519PublicKey": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "ed25519PublicKey": "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f", + "fingerprint": "fe8d8673f517688bf0d5d9b812327619a303c765af1f47dbd6a777db193c36e5" + } + ] +}