Public Access
Add ADRs 0001-0006 and README (M0)
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.
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user