Files
DodoSSH/docs/crypto.md
T
jaap-jan b15af836a3 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.
2026-07-28 13:18:29 +02:00

346 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.