Files
DodoSSH/docs/adr/0001-e2ee-trust-model.md
T
jaap-jan b15af836a3 Freeze DSH1 crypto specification and implement the core (M1)
docs/crypto.md is now the normative, frozen specification. This had to land before
anything else in M1: the server holds ciphertext and no keys, so it can never
re-encrypt, and a format change after users hold data is a coordinated client rewrite
with no rollback.

Specification:
- DSH1 envelope layout, canonical 64-byte AAD encoding, SealTo construction, key
  hierarchy, Argon2id profiles, fingerprints, and the change rules for each version field.
- AAD encoding is fixed-width binary rather than delimited string concatenation, so no
  field value can forge a field boundary. This supersedes the illustrative form sketched
  in ADR 0001, which now points here.
- UUIDs are RFC 4122 big-endian. Guid.ToByteArray() emits the first three groups
  little-endian and would have made our ciphertext unreadable by any other implementation
  of this spec, failing only at a cross-implementation boundary.

Verified rather than assumed:
- PrimitiveAvailabilityTests proves X25519, Ed25519, XChaCha20-Poly1305, Argon2id and
  HKDF-SHA512 all function on net10.0. NSec 26.4.0 targets net9.0 and is consumed by
  forward compatibility; this closes one of the two package questions the plan flagged.
- Argon2Profile exists because NSec's MemorySize is in KIBIBYTES, not bytes. Passing bytes
  gives either a 256 GiB allocation or a 256 KiB KDF that cracks instantly. The type takes
  mebibytes so the unit cannot be got wrong at a call site. Found by benchmarking: the
  first measurements were ~1000x too slow, which turned out to be 19 GiB of work.
- Parameters measured, not guessed: 256 MiB/t=4 is 323 ms on this machine; the table of
  candidates is in the spec.

Implementation and tests (83 total, up from 17):
- AadDescriptor, DshEnvelope, DshCrypto (Seal/Open/SealTo/OpenSealed/fingerprints).
- Decryption returns null rather than throwing: ciphertext comes from a server that is
  explicitly not trusted, so a failed tag is an expected outcome.
- Envelope readers reject unknown algorithms and any non-zero flag bit, so an envelope
  that is not fully understood fails closed.
- Executable form of the spec's substitution claims: a server cannot move ciphertext
  between resources, roll back a key generation or item version, repurpose a payload as
  metadata, or confuse the two constructions.
- Golden vectors in tests/fixtures/crypto/vectors.json guard the format. Mutation-checked:
  a one-byte schema version change trips four tests including the guard.

Two build-infrastructure bugs found and fixed along the way:
- .editorconfig forced camelCase on const and static readonly fields. PascalCase is the
  .NET convention for both; the config was wrong, not the code.
- The golden fixture was resolved with [CallerFilePath], which ContinuousIntegrationBuild
  rewrites to /_/... under deterministic source paths. It passed locally and would have
  failed only in CI. Now copied to the output directory and read from there.
2026-07-28 13:18:29 +02:00

6.0 KiB
Raw Blame History

ADR 0001 — End-to-end encrypted vault and its trust model

  • Status: accepted
  • Date: 2026-07-28

Context

DodoSSH stores SSH credentials — passwords, private keys, key passphrases — on a server so they can sync across a user's devices and be shared with teammates. Authentication is OIDC against a provider the operator chooses.

The product is self-hosted. Its buyers are teams who currently refuse to put infrastructure credentials into a SaaS vault. "Trust us with your production keys" is exactly the promise we cannot make.

Decision

The vault is end-to-end encrypted. The server stores ciphertext and never holds a key.

  1. A vault passphrase separate from OIDC. OIDC authenticates but yields no secret we can derive a key from, and SSO compromise must not equal vault compromise. The passphrase goes through Argon2id (m=256 MiB, t=4, p=1) to a master key that never leaves RAM.

  2. A per-user keypair, wrapped many ways. The master key wraps a ~200-byte UserSecretBundle (X25519 + Ed25519 private keys). The same bundle is stored under several independent wraps: passphrase, one per enrolled device, recovery code, and optionally escrow. A passphrase change therefore re-wraps 200 bytes and updates one row — no re-encryption of vault data and no coordination with other members.

  3. Vault keys, then per-item data keys. A vault key is sealed to each member's X25519 key; each item has its own data key wrapped under the vault key. Rotating a vault key re-wraps N × 32-byte data keys and never touches content blobs.

  4. AAD bound to row identity. Every ciphertext's AAD is recomputed from the row's plaintext columns rather than stored, over purpose, resourceType, resourceId, keyId, keyGeneration, itemVersion and schemaVersion.

    The normative byte encoding is docs/crypto.md §4. It is fixed-width binary rather than delimited string concatenation, so that no field value can forge a field boundary.

  5. Layered public-key trust — see Consequences.

Consequences

What this buys

The AAD binding is the most valuable structural property here, and it is not something ACLs can provide. A malicious server cannot paste credential A's ciphertext onto host B, cannot roll a row back to an earlier key generation, and cannot replay a revoked grant: each of those changes the AAD and fails the authentication tag on the client.

A stolen database dump, a rogue administrator, a TLS-terminating proxy and the relay operator all see ciphertext only. OIDC account takeover alone yields nothing readable.

What it costs, stated plainly

  • Revocation is not retroactive and cannot be. A removed member keeps whatever they already downloaded, along with the cached keys. Rotating the vault key protects only items written after the rotation. The only real remediation is rotating the SSH credentials themselves, so offboarding is built around a credential-rotation checklist rather than a "revoke access" button that implies more than it delivers.

  • Connect cannot be a security boundary. SSH terminates on the client, so opening a session requires the credential's plaintext on that machine. "May connect but may not view the key" is unenforceable in this architecture. The flag exists as a UI hint and must never be documented as access control.

  • Forgotten passphrase with no recovery code and no enrolled device means permanent loss of personal vault contents. Team vault contents survive, because a remaining member with Share can re-wrap. That asymmetry is a feature: it makes team vaults the right default even for a team of one plus a backup admin.

  • Public-key distribution is the real security boundary. Every guarantee is downstream of "the key I wrapped to is really Alice's". Four layers, deployed together: an IdP-signed key binding (the enrollment statement's hash is the nonce in an ID token, verified against JWKS fetched directly from the IdP and not proxied through us); TOFU fingerprint pinning with blocking warnings; an append-only key log whose head is embedded in every signed grant, so a forked view must stay consistent forever to go unnoticed; and safety numbers for out-of-band verification.

    The residual is honest and must stay in the docs: this makes the IdP a key-distribution trust root, and in a self-hosted deployment the person running Keycloak is frequently the person running DodoSSH. It raises the bar from "one compromised service" to "one compromised service plus a detectable artefact in the key log" — not to zero.

  • No server-side session recording is possible in relay mode, since the relay forwards only SSH ciphertext. See ADR 0004.

  • Metadata leaks. The server sees item counts, sizes, timestamps, access patterns and the complete sharing graph regardless of settings. Host addresses are plaintext when relay is enabled for that host; see ADR 0004 for why that is a security requirement rather than a convenience.

  • Supply chain becomes the largest practical hole. An operator who wants the secrets attacks the client, not the crypto. Release signing with a key not held by the server, and eventually reproducible builds, matter more here than in a conventional product.

Rejected

  • Server-side envelope encryption (KMS-held master key). Far simpler and it would permit a recording bastion, but a server compromise or a rogue admin exposes every credential. That is the exact promise the product exists to avoid making.
  • Admin escrow of user identity keys. Turns every operator into a silent global reader and destroys the property being sold. Non-negotiable. Team-scoped break-glass escrow with Shamir M-of-N is a separate, opt-in, clearly-labelled M5 feature.
  • Storing an Argon2id(passphrase) verifier server-side so the server can pre-validate. It creates an offline-crackable verifier on the very server being defended against, for no gain: the AEAD tag on the bundle wrap already proves the passphrase.