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:
2026-07-28 12:28:44 +02:00
parent 3a81f3c90b
commit ce43f397a6
7 changed files with 551 additions and 0 deletions
+95
View File
@@ -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.
+50
View File
@@ -0,0 +1,50 @@
# ADR 0002 — Minimal APIs with explicitly registered feature modules
- Status: accepted
- Date: 2026-07-28
## Context
The API is roughly 60 endpoints: identity and enrollment, a public-key directory, teams,
vaults and grants, sync, read-only item queries, relay, audit and admin. It is consumed by
one first-party desktop client.
## Decision
Minimal APIs, grouped into feature modules under `Features/`, registered **explicitly** from
`Setup/EndpointRegistration.cs`. Typed results (`Results<Ok<T>, ForbidHttpResult,
ProblemHttpResult>`) throughout, one endpoint per file.
Version with a hard-coded `/api/v1` prefix and **no `Asp.Versioning` package**. Instead ship
capability negotiation:
- `GET /api/v1/meta` — server version, sync protocol version, crypto spec version, feature
flags, `minClientVersion`, and the push caps.
- `GET /.well-known/dodossh-configuration` — OIDC authority, client id, scopes, relay URL.
## Consequences
- Per-endpoint filters and metadata compose cleanly, and typed results give an accurate
OpenAPI document without attribute noise.
- Explicit registration over reflection scanning: predictable startup cost, trimming-friendly,
and every route is greppable. The cost is one line per module, which is worth paying.
- Minimal APIs' real weakness is that a cross-cutting concern is easy to forget. Two
mitigations, both required: group-level `RequireAuthorization`, and an **endpoint-inventory
test** that enumerates `EndpointDataSource` and fails the build if any endpoint is absent
from an explicit authorization expectations table. A new endpoint therefore cannot be added
without making an authorization decision.
- Capability negotiation over version routing is the right trade for a self-hosted product,
where client and server upgrade independently and skew is normal rather than exceptional.
`.well-known` also *is* the onboarding story: the user types one server URL and the client
discovers the rest. `Asp.Versioning` gets added when a v2 actually exists, not before.
- The generated OpenAPI document is for third parties and a future CLI. It is emitted at build
time to `artifacts/openapi/v1.json`, committed and CI-diffed. **The client's real contract is
the `DodoSSH.Contracts` assembly**, guarded by PublicApiAnalyzers so an accidental DTO change
is a build error rather than a runtime deserialisation failure on a user's laptop.
### Rejected
- **MVC controllers.** Would work, but bring filter/model-binding machinery this API does not
need and blur the one-endpoint-one-file structure that keeps the authz surface reviewable.
- **Reflection-based endpoint discovery.** Convenient until a route silently disappears
because an assembly was not loaded, or trimming removes it.
+83
View File
@@ -0,0 +1,83 @@
# ADR 0003 — Revision-based delta sync through a single write path
- Status: accepted
- Date: 2026-07-28
## Context
Clients must work fully offline and reconcile on reconnect, across multiple devices per user.
The server holds ciphertext, so **it cannot merge, validate or inspect item contents**. Every
conflict resolution decision therefore has to happen on a client.
## Decision
### One write path
All vault mutations go through `POST /vaults/{vaultId}/sync/push`. There are no per-entity
POST/PUT/DELETE endpoints. Reads are separate, list/get only, keyset-paginated.
### Change log and cursors
A per-vault `sync_change(seq bigserial, vault_id, entity_type, entity_id, operation,
revision, actor_user_id, occurred_at_utc)` log. Each entity denormalises `change_seq` so a
delta pull joins straight to the row.
Cursors are opaque and HMAC-tagged — `base64url("v1|{vaultId}|{seq}")` — so a tampered cursor
is rejected rather than silently mis-serving someone else's data.
### Push semantics
`expectedVersion` per operation. **HTTP 200 even on partial failure**, with a per-operation
status of `Applied | Conflict | Forbidden | Invalid | Duplicate`. Conflicting operations are
skipped, not aborted, and a `Conflict` returns the server's current row so the client can
merge and re-push. `opId` deduplication via `sync_operation_receipt` makes a retried push
exactly-once at operation granularity.
Caps enforced before the transaction opens: 500 operations per push, 8 MiB per batch,
256 KiB per item.
### The `bigserial` cursor gap — the reason for the advisory lock
`bigserial` hands out values **before** commit. Transaction A takes seq 5, B takes 6 and
commits first; a reader that advances its cursor to 6 **permanently misses 5**. This is silent
sync corruption that only manifests under concurrent writes to a single vault, which is
exactly the case least likely to be exercised by hand.
Mitigation: every push takes, as its first statement,
```sql
SELECT pg_advisory_xact_lock(hashtextextended(@vaultId::text, 0))
```
This serialises writers per vault, so sequence order equals commit order. Contention is
per-vault and a push batch is already one transaction. **It requires `Multiplexing=false` in
the Npgsql connection string** — the default; do not enable multiplexing.
## Consequences
- One place enforces revision, change-log and ACL invariants. That halves both the endpoint
count and the authorization surface, which is the main reason for the single write path.
- Delta pull makes frequent polling cheap, so multi-device feels live; push notification over
SSE or the existing WebSocket can layer on with polling as the fallback.
- Conflict resolution is entirely client-side. The client retains a `BaseCiphertext` common
ancestor and performs a field-level three-way merge for structured items, or creates a
visible conflicted copy for opaque ones. **It must never silently drop a key or a host.**
- Deletes are revisioned tombstones, garbage-collected after 90 days. Sync must therefore be
able to read tombstones, which is the one place that legitimately bypasses the soft-delete
query filter — guarded by an explicit permission check.
- **An `Infrastructure.Tests` case must prove cursor ordering under N concurrent pushes.**
Without it this ADR's central bug is invisible until production.
- Two concurrency mechanisms, deliberately: `version integer` is the client-visible monotonic
item version used for conflict detection; `xmin` is the server-side optimistic guard and is
**never exposed**, because it is not stable across `VACUUM FREEZE` and must not become a
client cursor.
### Rejected
- **Snapshot-watermark cursors** (`pg_snapshot_xmin(pg_current_snapshot())`). Correct without
locking, but materially harder to reason about and to test. Revisit only if per-vault lock
contention shows up in practice.
- **Last-writer-wins.** Cheap, and it loses credentials. Unacceptable for this data.
- **Full pull on every sync.** Simple, but rules out the frequent polling that makes
multi-device sync feel immediate.
- **Server-side merge.** Impossible by construction: the server cannot read the payloads.
+101
View File
@@ -0,0 +1,101 @@
# ADR 0004 — Relay as a raw TCP tunnel, and how its targets are authorized
- Status: accepted
- Date: 2026-07-28
## Context
Some hosts are not reachable from a user's laptop. The obvious answer is a server-side jump
host — but that conflicts directly with [ADR 0001](0001-e2ee-trust-model.md): a server that
terminates SSH needs plaintext credentials for the target.
A server that dials arbitrary addresses on an authenticated user's behalf is also a textbook
SSRF and lateral-movement primitive, aimed at the operator's own network.
## Decision
### The relay forwards bytes, it does not terminate SSH
The client opens a WebSocket to the backend; the backend pipes raw bytes to `host:port`. The
SSH handshake still terminates on the client, so the relay sees only SSH ciphertext.
Zero-knowledge survives. Subprotocol `dodossh.relay.v1`; no framing of our own, because SSH is
already a byte stream.
### Two steps, so the WebSocket carries no API authority
1. `POST /relay/tickets` — full bearer JWT, full ACL context. Takes **`{ hostId }`, never a
client-supplied address.**
2. `GET /relay/connect` — WebSocket upgrade with the ticket only, zero API access.
Tickets are `IDataProtectionProvider` payloads (key management and rotation for free) carrying
`jti, userId, deviceId, hostId, targetIps[], targetPort, exp ≤ 30s`, single-use via a unique
insert into `relay_ticket_use`. The ticket travels in
`Sec-WebSocket-Protocol: dodossh.relay.v1, ticket.<token>`, since constrained WebSocket clients
cannot set arbitrary headers; a `?ticket=` fallback exists and **requires query scrubbing in
access logs and OTel spans**.
### Anti-SSRF, in order of importance
1. **The server resolves the target from `host_id`.** `relay_enabled = true` and non-null
`hostname`/`port` are enforced by a database CHECK constraint. Arbitrary targets are not
supported in v1 at all.
2. **DNS resolves at ticket-issue time and the resolved IPs go into the ticket; the relay
dials the IP, never the name.** This is what defeats DNS rebinding, which otherwise breaks
naive allow-listing.
3. **A non-overridable deny list with no configuration escape:** loopback, link-local,
`0.0.0.0/8`, multicast, cloud metadata (`169.254.169.254`, `100.100.100.200`,
`fd00:ec2::254`), and **IPv4-mapped IPv6 normalised and then re-checked** — a classic
bypass.
4. **A configurable layer:** `Relay:AllowPrivateNetworks` defaults **true**, because RFC1918 is
the primary use case for a self-hosted SSH tool, plus allow/deny CIDRs. Ports allow
everything except SMTP. Database ports stay open, because forwarding to 5432 is a headline
feature — **the real control is the ACL**: you can only dial hosts in a vault you hold
`Connect` on.
5. **Limits:** 10 concurrent sessions per user, 200 per node, 12 h maximum, 10 min idle, 5 s
connect.
### Why host addresses are plaintext
Encrypting hostnames sounds strictly better and is not. The relay must resolve its target
server-side or point 1 above collapses and the relay becomes an authenticated open TCP proxy —
a worse risk than the metadata it would protect. Plaintext address and port are therefore
stored **only when the user opts that host into relay**, enforced by the CHECK constraint.
Everything else about a host — username, notes, jump chain, options — is always ciphertext, and
there is no plaintext host label at all, because ACL administration runs on the client, which
can decrypt names.
Searchability is explicitly *not* the argument: vaults hold thousands of items, not millions,
so the client syncs everything and searches in memory. The relay and audit arguments are the
real ones.
## Consequences
- **No session recording or command auditing is possible in relay mode.** The relay sees
ciphertext. Do not promise otherwise. Recording would need a separate, explicitly
non-zero-knowledge "recorded bastion" mode opted into per host.
- Useful audit remains: `target_host:port`, duration, byte counts, close reason, client IP.
- Backpressure comes from `System.IO.Pipelines` with `pauseWriterThreshold: 1 MiB`. When the
WebSocket peer is slow, `FlushAsync` stops completing, which stops reading the socket, which
lets TCP's own receive window throttle the origin end-to-end. No custom flow control, ~2 MiB
bounded per session — which is what makes 200 sessions per node viable.
- **The relay must not hold a `DbContext` for the session lifetime.** Insert the row, dispose
the scope, relay, then open a fresh scope to write the close row. Otherwise the connection
pool dies around 100 concurrent sessions.
- Tickets are stateless-verifiable and `relay_ticket_use` is the only shared state, so any node
accepts any ticket: plain round-robin, no session affinity. That table is also the extraction
seam — a standalone relay process needs the Data Protection key ring and one table, no ACL
code. Extraction is worthwhile eventually because long-lived connections and short requests
have opposite scaling and rollout profiles.
- Graceful shutdown sends close 1001 and drains for 30 s, so a `docker compose up -d` does not
guillotine live shells.
- On the client, SSH.NET cannot be handed a pre-connected stream, so the relay is reached via a
loopback TCP bridge. The same bridge provides ProxyJump via a SOCKS5 dynamic forward — one
mechanism, two features.
### Rejected
- **Server terminates SSH (a true bastion).** Enables recording and central credential
control; destroys zero-knowledge. Out of scope, possibly a separate product line.
- **Client-supplied target address.** One line of convenience, and the relay becomes an open
proxy into the operator's network.
- **Allow-listing by hostname.** Defeated by DNS rebinding.
+55
View File
@@ -0,0 +1,55 @@
# ADR 0005 — No separate Application layer
- Status: accepted
- Date: 2026-07-28
## Context
A conventional layering for a .NET service of this size would be Domain → Application →
Infrastructure → Api, with a mediator and one handler per use case.
DodoSSH's server is, in substance, CRUD plus one interesting write path
([ADR 0003](0003-sync-protocol.md)) plus a byte relay ([ADR 0004](0004-relay-authorization.md)).
It performs **no cryptography on secrets and no business logic over item contents**, because it
cannot read them. The genuinely hard logic — merge, key wrapping, trust decisions — lives on
the client.
## Decision
Three projects: `DodoSSH.Domain` (entities, enums, invariants, no EF), `DodoSSH.Infrastructure`
(DbContext, configurations, migrations, queries), `DodoSSH.Api` (host, with vertical slices
under `Features/`). Plus `DodoSSH.Contracts` for shared DTOs and `DodoSSH.Crypto` for the
envelope format.
No `Application` project, no mediator.
## Consequences
- A feature is one folder under `Features/`: endpoint, request/response mapping, and the query
or command inline. Reading an endpoint means reading one file, not tracing a request through
a handler, a validator, a behaviour pipeline and a repository interface.
- The testability argument for an Application layer does not apply here. The tests that matter
for this server are authorization tests and sync-protocol tests, and both must run against a
real PostgreSQL to be meaningful — an in-memory handler test would pass while the EF query
filter, the advisory lock and the CHECK constraints all went unexercised. Integration tests
with Testcontainers are the primary suite, so an extra seam buys nothing.
- Business invariants that are genuinely invariant (permission algebra, relay target
validation, cursor encoding) live in `Domain` as pure functions and are unit-tested there.
That is where the fast tests belong.
- **Accepted risk:** if the server later grows real domain logic — server-side policy
evaluation, workflow, notifications with side effects — endpoints will start to get long.
The mitigation is a rule, not a layer: when an endpoint file exceeds ~80 lines, extract a
named service into `Features/<Area>/`. Revisit this ADR if that happens three times in one
area.
- `DodoSSH.Crypto` is referenced by the API but only for format and fingerprint constants. It
must stay free of any code path that could decrypt a payload server-side, so that the
dependency cannot quietly become a capability.
### Rejected
- **Application layer with MediatR.** Adds a hop and a handler per feature. Its usual payoffs —
transaction/validation/logging behaviours, and decoupling from the web framework — are either
already provided (endpoint filters, ProblemDetails) or irrelevant (there is no second host).
- **Repository interfaces over EF Core.** `DbContext` is already a unit of work and EF's global
query filters *are* the authorization backstop ([ADR 0002](0002-minimal-apis.md)). Wrapping
it would hide the mechanism that keeps the API failing closed.
+71
View File
@@ -0,0 +1,71 @@
# ADR 0006 — OpenTelemetry with built-in ILogger, not Serilog
- Status: accepted
- Date: 2026-07-28
## Context
The server is self-hosted by people who did not write it and who will debug it themselves. It
also handles data it cannot read, which changes what observability can and should capture.
## Decision
**Logging:** the built-in `ILogger` with source-generated `[LoggerMessage]` partial methods.
No Serilog. `AddJsonConsole` outside Development; self-hosters capture container stdout.
**Telemetry:** OpenTelemetry via `OpenTelemetry.Extensions.Hosting` with ASP.NET Core, HttpClient
and runtime instrumentation, Npgsql's own `ActivitySource`, and custom sources `DodoSSH.Api`,
`DodoSSH.Sync`, `DodoSSH.Relay`. OTLP exporter honouring the standard `OTEL_EXPORTER_OTLP_ENDPOINT`
and `OTEL_SERVICE_NAME` variables, because self-hosters expect them. Sampling
`ParentBased(TraceIdRatioBased(0.1))` by default, with tail sampling for errors done in the
collector. Optional Prometheus scrape on a separate port, off by default.
**Redaction is a hard requirement, not a nicety.** `Microsoft.Extensions.Compliance.Redaction`
with `[PrivateData]` on DTO properties, plus an `ActivityProcessor` that strips `url.query` — the
relay ticket fallback lives there ([ADR 0004](0004-relay-authorization.md)) — and drops
`Authorization` and `Idempotency-Key`. Sync request bodies are never logged: they are ciphertext,
but size and shape still leak.
**Health:** `/healthz/live` checks the process only. `/healthz/ready` additionally checks
PostgreSQL, OIDC discovery and JWKS reachability, the Data Protection key ring, and that no
migrations are pending.
## Consequences
- One telemetry pipeline instead of two configuration systems. OTel logs are first-class in
.NET 10, so Serilog's usual payoff — sinks and enrichers — is redundant once a collector is in
the compose file, and dual configuration is a real support burden ("why is my log level being
ignored?"). If someone wants file logs, that is the collector's file exporter, not a second
logging framework.
- `[LoggerMessage]` is allocation-free and produces structured events by construction. CA1848 is
a warning during early development and is raised to error when the logging pass lands in M4.
- **Liveness deliberately excludes dependencies.** A transient PostgreSQL outage must not cause
the orchestrator to restart the container, because that would kill every live relay session for
a fault that has nothing to do with them. Readiness only removes the instance from load
balancing, which is the correct response.
- **Audit is a separate concern from logs.** `audit_event` is a partitioned, append-only table
(`REVOKE UPDATE, DELETE` from the application role) with a per-day hash chain. It records who,
when, which item id, which operation, outcome and source — plus non-secret shape data such as
`fieldsChanged` and `payloadBytes`. Its `detail` jsonb must contain ids, counts and *field
names* only, enforced by a serializer whitelist and a test.
- Given no plaintext is visible, audit is nonetheless strong on the thing that matters: every
ciphertext fetch is recorded, and there is no server-side path that reads a secret without a
client fetch. Bulk fetch of an entire vault is a high-signal exfiltration indicator and should
alert.
- Honest limit on tamper evidence: a compromised server can rewrite the chain from any point
forward unless heads are anchored externally. Publishing the daily head to clients, which cache
it, makes truncation detectable — the same trick as the key log in
[ADR 0001](0001-e2ee-trust-model.md).
- Human-readable audit is a **client** concern: rows carry `subject_id`, and the client, which
holds the keys, resolves ids to names.
- Behind a reverse proxy, `UseForwardedHeaders` needs `KnownProxies`/`KnownNetworks` configured.
**Log a startup warning if forwarded headers are enabled with no known proxies**, because
self-hosters will get this wrong and it silently turns per-user rate limiting into per-proxy.
### Rejected
- **Serilog.** Excellent library; the second configuration system is the problem, not the code.
- **Application Insights or another vendor SDK.** Wrong for a self-hosted product. OTLP lets the
operator point at whatever they already run.
- **Logging request bodies for sync.** Ciphertext, so it looks harmless, and it leaks item sizes
and access patterns while ballooning log volume.