Files
DodoSSH/docs/crypto.md
T
jaap-jan 5a899afd78 Decide what Lock does to a running shell, and say it
Pressing Lock nulled and disposed the vault view model and touched nothing else.
TerminalWorkspace is injected from App.axaml.cs and outlives every lock, so the SSH
connection, the pty and the pump all kept running while the window said "Unlock your
vault" — and since 0500e43 collapsed the WebView while locked, that live session was
invisible as well as unstopped. CloseSessionAsync was reachable in production only from
DisposeAsync, i.e. shutdown. None of this was written down anywhere, so it was neither a
policy nor a bug, which is the actual problem.

Shells now deliberately outlive the lock, and every layer says so.

The reason to prefer this over making Lock a disconnect: locking is what a person does
when they walk away from the machine, which is exactly when a long upgrade, build or
transfer is most likely to be in flight. Ending every shell would make Lock a button that
destroys work, and the predictable response is to stop pressing it and leave the vault
open instead. The idle auto-lock this will grow decides it outright — an unattended
timeout that killed a running job would be worse than the exposure it removes. Closing
the channel also buys less than it looks: the session was authorised at connect time by a
credential the remote verified itself, and no vault key participates in keeping it alive,
so locking cannot retroactively un-authorise it any more than removing a member can.

Stated honestly rather than implied, because the lock screen is what hides it:

- The unlock screen shows how many shells are still connected, and that locking closes
  the vault and not the connections — so a machine still holding authenticated SSH
  channels does not present itself as merely "locked". Shown only when there is something
  to disclose. Quitting is what ends them, and the text admits that.
- The Lock button carries the same thing in a tooltip, since its name implies the
  opposite of what it does to a shell.
- README lists it as a third architecture consequence beside non-retroactive revocation,
  which is the same shape of honest limit; docs/crypto.md §10 records it as a threat-model
  boundary; TerminalWorkspace and LockAsync carry the argument next to the code.

LiveSessionCount deliberately does not count dictionary entries. Nothing removes a
session when the remote closes the channel by itself — RunSessionAsync only drops the
renderer registration — so sessions.Count would report a shell that exited half an hour
ago as still running, on the one screen where a user is deciding whether it is safe to
walk away. A completed Run task is what "the shell is gone" actually looks like. While
locked the number can only fall, since opening a session needs the vault, so a stale
value over-reports rather than under-reports.

Both new tests fail when the policy is reverted: the count test times out against
sessions.Count, and the shell test reports "workspace.LiveSessionCount should be 1 but was
0" when Lock closes sessions. ShellFlowTests also stops building its workspace with a real
SshNetConnectionFactory that nothing ever called, which had made the suite's independence
from the network a coincidence rather than a property.

Verified by hand with a live shell, which nothing had done: a harness mirroring
MainWindow.axaml's 340,* grid with a real NativeWebView, the shipped WebAssets, a real
sshd in a container, and an ISshShellSession decorator recording every window-change the
remote is actually told about. Across lock and unlock, no window-change reached the remote
at all, stty size answered 50 118 before and after, the renderer's own buffer came back
byte for byte with the wrapped line intact, and the session stayed live throughout. A
control run that never hides the WebView behaves identically, so nothing above is startup
or idle behaviour. Keystrokes injected while locked reach nothing: twelve of twelve
SendInput events accepted with the harness confirmed as the foreground window, no probe
character in the remote's output, and a following Ctrl-U answered BEL, so nothing was
queued in the line editor either. A hidden WS_CHILD window is not eligible for keyboard
focus, which is what makes surviving the lock defensible rather than merely convenient.

Correction to a claim made in f80b3d4: terminal.js's guard comment listed "a host that
hides the WebView while the vault is locked" among the paths that reach a degenerate fit.
It does not. Collapsing the control hides a native child window without resizing it, so
the page still reports paneWidth 840 and paneHeight 760 with unchanged cols and rows, no
ResizeObserver callback fires and the fit never runs. Establishing that rather than
assuming it: the same cycle with MINIMUM_FITTABLE_PIXELS patched to 0 — the guard fully
disabled — is equally clean. The guard is still right for minimising and for a splitter
dragged to the edge; it is simply not what makes locking safe, and must not be cited as
though it were.

Recorded, not fixed:

- Nothing closes one terminal from the interface, so a user reading "1 shell is still
  connected" can only act on it by quitting. CloseSessionAsync is tested and correct;
  VaultViewModel discards the session id it would need.
- A session whose remote exits keeps its ISshConnection, and the thread ShellStream parks,
  until the process ends.
- Suspected and seen once: before the harness waited for the window's scale to settle, a
  DPI settle pushed a 2202x1328 pane for a window 1180 logical units wide and a later
  re-push reflowed the wrapped line. Three later runs at RenderScaling 1.00 never showed
  it, so it is filed as a lead, not a finding.
- WebView2 fails to initialise with CO_E_SERVER_EXEC_FAILURE when the host executable
  sits under a very long path. Cost an hour on the harness; relevant to packaging.
2026-07-29 14:44:02 +02:00

26 KiB
Raw Blame History

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, mlocked 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) → 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
   └── LocalCacheKey info = "dsh1/localcache/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
   ▼
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.

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

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.

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;
  • 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 the passphrase. 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.