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.
This commit is contained in:
2026-07-28 13:18:29 +02:00
parent ce43f397a6
commit b15af836a3
21 changed files with 2589 additions and 30 deletions
+6 -2
View File
@@ -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
+345
View File
@@ -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 11.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: <unix s>, keyGeneration: <u32> }
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.