# 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 |

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) → 64 B
   ▼
MK — master key, RAM only, never persisted, never transmitted
   │ HKDF-SHA512-Expand with domain-separated info labels
   └── KEK_pp        info = "dsh1/kek/passphrase/v1"   32 B
   ▼
UserSecretBundle — fixed binary, 92 B (see 3.1)
   stored server-side as N independent wraps of the SAME bundle:
     kind=passphrase → symmetric AEAD under KEK_pp
     kind=device     → SealTo(device_x25519_pk)   one row per enrolled device
     kind=recovery   → symmetric AEAD under KEK_rc = Argon2id(recovery code)
     kind=escrow     → SealTo(team_breakglass_pk) opt-in, M5
   │ HKDF-SHA512 extract-and-expand over encode(bundle) — see 3.2
   ├── LocalCacheKey info = "dsh1/localcache/v2"       32 B
   ▼
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)
```

> **Changed 2026-07-28: MK is 64 bytes, not 32.** Skipping HKDF-Extract — correct, because an
> Argon2id output is already uniformly random, per RFC 5869 §3.3 — means MK *is* the PRK of the
> expansion. .NET's `HKDF.Expand` rejects a PRK shorter than the hash output, so a 32-byte MK cannot
> be expanded with SHA-512 at all. Widening MK keeps the specified primitive; the alternatives were
> dropping to SHA-256 or adding an Extract step that conditions nothing. Extra Argon2id output is
> free. Discovered by implementing it, which is the argument for writing the code before declaring a
> spec frozen.

### 3.1 UserSecretBundle encoding

> **Changed 2026-07-28**, from "canonical CBOR" to the fixed layout below. This reverses a stated
> choice rather than clarifying an unstated one, so the reasoning is recorded here. It is safe to
> make now and would not be later: nothing has been implemented against CBOR and no bundle has ever
> been stored, so there is nothing to migrate.

```
bundle = "dsh1/bundle/v1"        14 bytes, literal
      || u16 version              big-endian
      || u32 keyGeneration        big-endian
      || i64 createdAt            big-endian, Unix milliseconds, UTC
      || x25519_sk                32 bytes, raw scalar
      || ed25519_sk               32 bytes, raw seed
                                  = 92 bytes, fixed
```

Three reasons for the change:

- **Canonicality is not load-bearing here.** Unlike a key statement (§7.1), the bundle is never
  hashed or signed — only encrypted. Any deterministic encoding is sufficient, so the one property
  CBOR was chosen for does not apply. Canonical CBOR's rules (definite-length maps, sorted keys,
  shortest-form integers) are a source of cross-implementation disagreement bought for nothing.
- **It costs a dependency.** `System.Formats.Cbor` is not in the .NET 10 shared framework. Keeping
  `DodoSSH.Crypto` down to NSec alone matters for a client that wants trimming.
- **Consistency.** §7.1 and §7.2 already establish a fixed big-endian layout with an explicit
  domain label. One convention to learn and to review beats two.

Forward compatibility is unaffected: the bundle is versioned, and only our own clients ever read it,
so a new field means bumping `version` — which a fixed layout handles as well as CBOR would.

Readers **must** reject a bundle whose length, label or version does not match exactly. This is the
root of everything a user can read; there is no safe way to guess at a malformed one.

### 3.2 LocalCacheKey

> **Changed 2026-07-30**, from deriving under `MK` with info `"dsh1/localcache/v1"` to deriving
> under the bundle with `"dsh1/localcache/v2"`. Recorded here because it reverses a stated choice.

```
LocalCacheKey = HKDF-SHA512(ikm = encode(bundle), salt = none,
                            info = "dsh1/localcache/v2", L = 32)
```

**Extract-and-expand, not expand alone.** Everything derived from `MK` uses HKDF-Expand directly,
which is sound because an Argon2id output is uniformly random over its whole length. `encode(bundle)`
is not: it opens with a fixed 14-byte label and carries a version, a generation and a timestamp before
reaching any key material. The extract step is what turns that into a pseudorandom key.

Derived from the bundle rather than from `MK` because a passphrase is only one of four ways to open a
vault, and the cache has to be readable through all of them. Under v1:

- a **device** unlock opens a `SealTo` wrap and never computes `MK`, so it could open the identity and
  still not read the cache it had itself written;
- a **recovery-code** unlock derives a *different* `MK` — different secret, different salt — and so
  would silently derive a different cache key and orphan every cached row;
- an **escrow** unlock (M5) has the same problem as device.

Keying on the bundle also means a **passphrase change no longer discards the cache**, which is a
consequence worth stating rather than discovering: the bundle is unchanged by a re-wrap, so the cache
key is too. The cache becomes unreadable exactly when the *identity* is rotated, which is the correct
moment to discard it.

The label is versioned, so a client holding a v1 cache fails to open it and re-pulls rather than
decrypting to nonsense. That is the whole reason for bumping rather than reusing the label.

**What it seals, and the one entry that is not vault content.** Three kinds of record: the plaintext
columns the server needs, the values a merge overrode, and — added 2026-07-31 — the OIDC **refresh
token** this machine may resume its sign-in with, bound as `LocalCache(User, userId)`. The third is
different in kind from the other two: it is a credential for the *account*, not for the vault, and
sealing it here is a deliberate choice about what a stolen cache file is worth. A refresh token kept in
the clear beside the ciphertext would let a copied profile reach the server as its owner without the
passphrase ever being guessed; under this key it can only be read by a process that has already opened
the vault. The cost is stated rather than worked around: **a locked client cannot reach the server at
all**, because the token it would present is behind the same lock as everything else.

### 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.** Re-keying N items re-wraps N × 32-byte data keys and never touches
   content blobs. A 10,000-item vault re-keys 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.

> **Added 2026-08-03: what a vault key rotation actually does.** Advancing a vault to a new
> generation does **not** re-wrap the items already in it. Each item keeps the generation it was
> sealed under, in its row and in its AAD, so a rotated vault holds items under two or three keys
> at once and every read chooses the key its item names. That is why a member's grants for earlier
> generations are kept rather than revoked, why `VaultSummary` serves all of them, and why sharing
> issues one grant per generation held: a client holding only the newest key would read the vault's
> whole history as tag failures.
>
> Moving those items onto the new key is a **separate pass** (`VaultResealer`), run last and resumable
> because a vault at mixed generations is readable. It opens each item and seals the same plaintext
> again under a fresh data key — the envelope is re-made rather than only the wrap, because the AAD
> binds `keyGeneration` into both. The plaintext is never decoded, so an item written by a newer client
> crosses a rotation untouched. See [ADR 0010](adr/0010-vault-key-rotation.md).

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, bound to its own row |

> **Changed 2026-07-29:** `LocalCache` binds `resourceType` and the record's own id rather
> than the user id. The user is already bound by the key — `LocalCacheKey` derives from that
> user's MK — so binding it again in the AAD constrained nothing, and left cache records
> interchangeable between rows of the same cache. For a plaintext column such as a
> relay-enabled host's address, swapping two rows would aim one host's connection at
> another's. No cache has been written, so nothing needs migrating.

### 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, `12` HostTag,
`13` HostCredential, `14` ConnectionLogEntry, `15` ActivityLogEntry, `16` ObjectStore.

> **Added 2026-07-29:** `12` and `13`. `Contracts.SyncEntityType` has listed `HostTag` and
> `HostCredential` as syncable since the contract was frozen, but this table had no value for
> either — so an association row's payload had no resource type to bind to, and the first
> implementation to need one would have had to invent a value or reuse a neighbour's. Append
> only, and no such item has been stored.

> **Recorded 2026-08-04:** `14`, `15` and `16`. Unlike `12` and `13` these were not a hole — they
> were added to `CryptoSpec.AadResourceType` when the two logs and the buckets shipped, and items
> **have** been sealed under them. What was missing was this table, which had gone three item kinds
> without being updated. Nothing changes on the wire or on disk; the normative list now says what
> the code has been doing.
>
> **They are the first members that are not a fixed offset from their `SyncEntityType`
> counterparts** — the two enums drifted apart when `12` and `13` closed the hole above, so
> `ConnectionLogEntry` is `11` there and `14` here. Nothing casts between them, and
> `AadResourceTypeTests` pins each pairing by name for exactly that reason.

`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.

### 7.3 Vault key grant — canonical encoding

> **Added 2026-07-28.** §7 named the grant tuple without specifying its encoding. This fills that in,
> using the same conventions as §7.1. Pinned by `GrantStatementCodecTests`.

```
grant = "dsh1/grant/v1"           13 bytes, literal
     || u32 keyGeneration          big-endian
     || u8  grantKind              1 = Member, 2 = Recovery, 3 = Escrow
     || vaultId                    16 bytes, RFC 4122 big-endian
     || granteeUserId              16 bytes, all-zero for a non-member grant
     || granteeKeyFingerprint      32 bytes
     || SHA-256(wrappedKey)        32 bytes
     || granterUserId              16 bytes
     || granterKeyFingerprint      32 bytes
     || keyLogHead                 0x00, or 0x01 followed by 32 bytes
     || i64 grantedAt              big-endian, Unix milliseconds, UTC
```

Signed with context `dsh1/sig/grant/v1`.

- **The digest of the wrapped key, not the key.** A verifier must be able to check who issued a grant
  without holding the vault key, which is the whole point of separating attribution from access.
- **`grantKind` values are load-bearing.** They must match `DodoSSH.Domain.GrantKind` exactly; the
  crypto-layer enum is named `GrantPurpose` only to avoid a name collision in the server, where both
  are visible. Renumbering either would make every grant of the changed kind fail verification
  permanently.
- **The key log head is optional, with a presence byte.** Absent for a self-grant: there is no third
  party whose key could have been substituted, and the log entry that would supply a head is written
  by the server in the same transaction, so a client cannot have signed over it. Without the presence
  byte, "no head" and "a head of 32 zero bytes" would be indistinguishable.

## 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;
- **a locked vault on a machine with open sessions** — locking zeroes the identity keys, the vault
  keys and the cache key, so nothing on disk can be read again without re-opening the bundle: a
  passphrase, or any other wrap the user has registered. Where a device wrap exists, whatever guards
  it on that machine is therefore as strong as the passphrase for reading the cache — which is the
  decision recorded in [ADR 0007](adr/0007-device-key-protection.md), not a property of this spec. It
  does not
  touch an SSH channel that is already open: that channel was authorised at connect time by a
  credential the remote host verified itself, and no vault key participates in keeping it alive.
  Sessions therefore survive lock **by design** (the client says so on its unlock screen, and the
  README explains why), which means a locked client can still hold authenticated access to remote
  hosts. Ending that is quitting the client, or rotating the credential — the same non-retroactive
  limit as revocation, one layer down;
- 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.
