# 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. The one exception is the key statement self-signature in §7.1, which the server *does* verify. That is a data-integrity check, not a boundary: an unverifiable statement admitted into the append-only key log (§7.2) is permanent, and every client auditing the chain afterwards would see an entry it cannot validate and cannot distinguish from tampering. ### 7.1 Key statement — canonical encoding and the identity-provider binding > **Added 2026-07-28.** §7 always required "a canonical, length-prefixed encoding"; this > specifies it exactly. This is a clarification of an underspecified detail, made before any > client exists, not a change to a defined one. Pinned by `keyStatement` in the test vectors. ``` statement = "dsh1/keystatement/v1" 20 bytes, literal || u16 version big-endian || u32 keyGeneration big-endian || i64 createdAt big-endian, Unix milliseconds, UTC || x25519_pk 32 bytes || ed25519_pk 32 bytes || str(issuer) || str(subject) || str(email) || str(deviceName) str(absent) = 0x00 str(present) = 0x01 || u32 length (big-endian) || UTF-8 bytes binding = SHA-256(statement) 32 bytes nonce = base64url(binding), unpadded 43 characters ``` Notes that are normative, not stylistic: - **The presence byte is what makes the encoding injective.** Without it an absent email and an empty one encode identically, and two different statements would share a binding. - **`createdAt` is truncated to milliseconds by construction.** PostgreSQL stores microseconds, so a value that has been through a database round trip must still hash to the same thing. The offset is normalised to UTC, so the timezone a client happens to hold is irrelevant. - **JSON must never be hashed.** Property order, number formatting, Unicode escaping and whitespace all vary between serialisers. Two implementations disagreeing by one byte produce two nonces and an enrollment nobody can verify. The statement is *transmitted* as JSON and *hashed* as the encoding above; the two are independent on purpose. - The nonce is base64url because it travels in an authorization request query string. The client uses `nonce` for a **fresh** OIDC authorization with `prompt=login`, so the resulting ID token is an identity-provider signature over exactly these keys. Verifiers must check: signature against the provider's JWKS **fetched directly from the provider**, `iss` matching the statement, `sub` matching the account, `aud` equal to the **client id** — an ID token is audienced to the client, never to the API — and `nonce` equal to the value above. ### 7.2 Key log chain ``` entryHash = SHA-256( "dsh1/keylog/v1" 14 bytes, literal || previousHash 32 bytes, all-zero for the first entry || userId 16 bytes, RFC 4122 big-endian || u32 generation big-endian || x25519_pk 32 bytes || ed25519_pk 32 bytes || statementSignature 64 bytes || i64 createdAt ) big-endian, Unix milliseconds, UTC ``` The database-assigned sequence is deliberately **not** an input. It is unknown until the insert executes, and order already follows the hash links — so a renumbered or gapped sequence column cannot silently reorder history. Appends must be serialised (the server takes a deployment-wide advisory lock). Two concurrent appends reading the same head would produce two entries claiming the same predecessor, which is indistinguishable from the fork the chain exists to detect. ## 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; - key statement encodings, bindings and nonces (§7.1), including an absent versus empty email, a multi-byte device name, and a sub-millisecond offset-bearing timestamp that must encode identically to its truncated UTC form; - key log entry hashes (§7.2), including the genesis link and a second entry chained to it. 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.