Rotating a vault re-keyed the vault and not its contents, which was the deal struck last time: everything already stored stayed sealed under the generation it was written with, every remaining member kept the older keys, and the guarantee was narrowed to "nothing written from now on". That left one gap worth closing — somebody who walked off with the old key could still open old ciphertext they later got hold of — and the reason it was safe to defer is the reason it was cheap to add. A vault at mixed generations reads perfectly well, so the pass that moves items across can stop half way and be run again. VaultResealer walks the vault and rewrites each item as an ordinary upsert against the version the server holds. It never decodes the plaintext: an item is opened and the same bytes are sealed again under a fresh data key, so an item written by a newer client crosses a rotation untouched rather than being re-encoded through this build's codec and quietly losing the fields this build has no concept of. It also means nothing in the pass knows what an item is, which is why one loop covers every type including the ones added after it. A conflict is counted and skipped rather than merged — there is nothing to merge, since no content changes — and the next pass picks the item up at the version the other client left. The half that a pass over stored items cannot see is a change queued before the rotation and pushed after it, which would put a brand-new item into the vault under the key the person who just left still holds. So the push path re-seals a stale payload as it dispatches it, writing the revision back to the outbox first so that a retry sends the same bytes rather than a fresh envelope. Between the two, nothing reaches the server under a superseded generation at all. Queued items are therefore deliberately left alone by the pass: rewriting one there would overwrite the user's unpushed work with the version the server holds, which is the one thing a re-keying pass must never do. Removal runs it last, after a sync — a mirror that is behind produces a batch of conflicts instead of a re-sealed vault — and the status line distinguishes the two guarantees, because they are not the same: a vault fully re-sealed is closed to the person who left, and one with items outstanding is closed only to what happens next. Six tests, and three mutations run against them: making the re-seal return the payload unchanged fails five of the six, making the push path skip re-sealing fails the queued-edit test and only that one, and counting conflicts as applied fails the write-elsewhere test. One of the six was wrong before it was right — it modelled a third-party write by re-pushing an existing payload at a bumped version, which no real client would do, and it took reading the AAD to see that the test was lying rather than the code.
30 KiB
DodoSSH cryptographic specification (DSH1)
- Status: frozen as of 2026-07-28. Version
1. - Normative.
DodoSSH.Cryptomust agree with this document exactly, andtests/fixtures/crypto/vectors.jsonpins 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.
ECDiffieHellmanis 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.IsSupportedis 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 withKeyExportPolicies.None, which is real protection against heap scraping and core dumps. Abyte[]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.
MemorySizeis in kibibytes
NSec.Cryptography.Argon2Parameters.MemorySizeis KiB, not bytes. Passing bytes gives either a catastrophically weak KDF or an absurd allocation:
Intent Correct If bytes were assumed 256 MiB 262144268435456→ 256 GiB, allocation failure262144bytes → 256 KiB, ~1 ms, trivially crackable
DodoSSH.Cryptotherefore never accepts a raw integer here.Argon2ProfiletakesMemoryMebibytesand 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.Expandrejects 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.Cboris not in the .NET 10 shared framework. KeepingDodoSSH.Cryptodown 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
MKwith 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
SealTowrap and never computesMK, 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_ppfrom 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
- 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.
- Narrow sharing. A single item can be re-wrapped to another vault key or user key.
- Nonce hygiene. Each key encrypts about one message.
- 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
VaultSummaryserves 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 bindskeyGenerationinto both. The plaintext is never decoded, so an item written by a newer client crosses a rotation untouched. See ADR 0010.
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:
LocalCachebindsresourceTypeand the record's own id rather than the user id. The user is already bound by the key —LocalCacheKeyderives 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:
12and13.Contracts.SyncEntityTypehas listedHostTagandHostCredentialas 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 —
resourceIddiffers, tag fails; - cannot roll back a row to an earlier key generation —
keyGenerationdiffers; - cannot replay a revoked grant blob —
keyGenerationandresourceIddiffer; - cannot repurpose a bundle wrap as a vault grant —
purposediffers; - cannot substitute an item's metadata blob for its payload blob —
purposediffers.
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.
flagsexists so a reader can fail closed on an envelope it does not fully understand.- Minimum lengths: 46 bytes for
alg_id = 1, 34 for2, 78 for3. 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
keyStatementin 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.
createdAtis 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.
grantKindvalues are load-bearing. They must matchDodoSSH.Domain.GrantKindexactly; the crypto-layer enum is namedGrantPurposeonly 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 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, 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.