Public Access
Records the decisions the milestone plan already made, with their costs stated rather than only their benefits: - 0001 e2ee-trust-model: key hierarchy, the AAD-to-row binding that stops the server moving ciphertext between rows, and the four-layer public-key trust story. States plainly that revocation is not retroactive, that Connect cannot be a security boundary, and that the IdP becomes a key-distribution trust root. - 0002 minimal-apis: feature modules with explicit registration; capability negotiation instead of Asp.Versioning, since client and server upgrade independently when self-hosted. - 0003 sync-protocol: single write path, revision cursors, and the bigserial pre-commit sequence gap that silently corrupts sync — plus the per-vault advisory lock that fixes it and the test that must prove it. - 0004 relay-authorization: relay forwards bytes rather than terminating SSH, so zero-knowledge survives; server-resolved target IPs in the ticket to defeat DNS rebinding; why host addresses must be plaintext when relay is enabled. - 0005 no-application-layer: why the usual Application/mediator layer earns nothing here, with the trigger that would make us revisit it. - 0006 observability-stack: OTel plus built-in ILogger; liveness excludes dependencies so a database blip cannot restart the container and kill live SSH sessions. Also adds a README covering layout, build, enforced conventions and milestones.
96 lines
5.8 KiB
Markdown
96 lines
5.8 KiB
Markdown
# 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:
|
||
`SHA-256("dsh1\n" + purpose + resourceType + resourceId + keyId + keyGeneration + schemaVersion)`.
|
||
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.
|
||
- **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.
|