# 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](../crypto.md). 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](0004-relay-authorization.md).
- **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](0004-relay-authorization.md) for why that is a
  security requirement rather than a convenience.
- **The connection and activity logs widen that leak, deliberately.** They are ordinary vault
  items — every field sealed, no plaintext column of any kind, not even a timestamp — but
  there is one row per connection and one per keychain edit, and rows have `updated_at`. So
  the operator can read a user's connection *rate and timing* off the change log without
  decrypting anything: how many machines somebody touched this morning, and at what hour they
  stopped. That is a real increase over what item counts alone gave away.

  It is the price of the logs being auditable at all. Kept on the machine that produced them
  they cannot be read by an administrator, cannot survive a reinstall, and cannot be checked
  against anything — which makes them a diagnostic rather than an audit trail, and the point
  of them is the audit trail once shared vaults land. Retention bounds the exposure rather
  than removing it: ninety days or five thousand entries per kind, whichever bites first.

  Two things keep it as narrow as it can be. The payload records the host's *label* and the
  address as dialled but **not** the SSH username — "who in this organisation opened a shell"
  is the audit question, and "which account they logged in as" is a detail of the host, whose
  own logs already have it. And the activity log records the **names** of the fields that
  changed and never their values — the same rule
  [ADR 0006](0006-observability-stack.md) imposes on the server's own `audit_event.detail`,
  arrived at independently on the other side of the encryption boundary.
- **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.
