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,96 @@
|
|||||||
|
# DodoSSH
|
||||||
|
|
||||||
|
A self-hosted, team-oriented SSH client with an end-to-end encrypted vault.
|
||||||
|
|
||||||
|
Manage hosts, credentials and keys in a desktop app; sync them across your devices and share
|
||||||
|
them with teammates through a server you run yourself. **The server stores ciphertext and never
|
||||||
|
holds a key** — the operator cannot read the credentials it stores.
|
||||||
|
|
||||||
|
> Status: early development. See [the milestone plan](#milestones) for what exists today.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Teams either scatter SSH credentials across individual `~/.ssh` directories with no sharing
|
||||||
|
story, or pay per-seat for a hosted product that holds their infrastructure credentials.
|
||||||
|
DodoSSH keeps the convenience of a synced, shareable vault while remaining self-hostable and
|
||||||
|
zero-knowledge.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
| Component | Choice |
|
||||||
|
| --- | --- |
|
||||||
|
| Backend | ASP.NET Core on .NET 10, PostgreSQL + EF Core |
|
||||||
|
| Client | Avalonia (C#) for Windows/Linux/macOS; terminal pane is a WebView running xterm.js |
|
||||||
|
| Auth | OIDC, provider-agnostic (Entra ID, Keycloak, Auth0, Authentik) |
|
||||||
|
| Vault | End-to-end encrypted; X25519 + Ed25519 + XChaCha20-Poly1305, Argon2id unlock |
|
||||||
|
| Connections | Client-direct SSH by default, with an optional raw-TCP server relay |
|
||||||
|
|
||||||
|
Two consequences worth knowing before you read further:
|
||||||
|
|
||||||
|
- **Revocation is not retroactive.** A removed member keeps what they already downloaded. The
|
||||||
|
real remediation is rotating the SSH credential, so offboarding is built around a rotation
|
||||||
|
checklist rather than a button that implies more than it delivers.
|
||||||
|
- **No session recording in relay mode.** The relay forwards SSH ciphertext, so it cannot see
|
||||||
|
commands. That is the cost of the relay not being able to read your traffic.
|
||||||
|
|
||||||
|
The reasoning behind each major decision is recorded in [`docs/adr/`](docs/adr/), starting with
|
||||||
|
[the E2EE trust model](docs/adr/0001-e2ee-trust-model.md).
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
DodoSSH.Contracts DTOs shared with the client — the real API contract
|
||||||
|
DodoSSH.Crypto DSH1 envelope, AAD derivation, key wrapping
|
||||||
|
DodoSSH.Domain entities and invariants, no EF
|
||||||
|
DodoSSH.Infrastructure DbContext, configurations, migrations
|
||||||
|
DodoSSH.Api the host
|
||||||
|
tests/ one test project per source project
|
||||||
|
docs/adr/ architecture decision records
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
Requires the .NET SDK pinned in [`global.json`](global.json) (10.0.x).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build DodoSSH.slnx
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test DodoSSH.slnx
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the API locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet run --project src/DodoSSH.Api
|
||||||
|
```
|
||||||
|
|
||||||
|
It listens on `http://localhost:5233`, serving `/healthz/live`, `/healthz/ready` and — in
|
||||||
|
Development — `/openapi/v1.json`.
|
||||||
|
|
||||||
|
### Conventions the build enforces
|
||||||
|
|
||||||
|
- Warnings are errors. `dotnet format --verify-no-changes` gates CI.
|
||||||
|
- Package versions are centralised in `Directory.Packages.props`; `packages.lock.json` is
|
||||||
|
committed and CI restores in locked mode.
|
||||||
|
- [`BannedSymbols.txt`](BannedSymbols.txt) bans `DateTime.UtcNow` (use `TimeProvider`),
|
||||||
|
`Guid.NewGuid` (use `CreateVersion7`), sync-over-async, MD5/SHA1 and PBKDF2.
|
||||||
|
- Public members of `DodoSSH.Contracts` must be declared in `PublicAPI.Unshipped.txt`, so a
|
||||||
|
contract change is a build error rather than a client-side surprise.
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
|
||||||
|
- **M0 — foundation.** Repo structure, build conventions, CI, ADRs. *Done.*
|
||||||
|
- **M1 — vertical slice.** OIDC login → enroll → create a host → open a shell. Gated on
|
||||||
|
freezing `DodoSSH.Contracts` and the crypto AAD, plus two client spikes (Linux WebView,
|
||||||
|
SSH.NET window-change).
|
||||||
|
- **M2 — full personal vault**, robust sync, relay.
|
||||||
|
- **M3 — teams**, sharing, ACLs.
|
||||||
|
- **M4 — hardening and ops**, packaging, self-hosting guide.
|
||||||
|
- **M5 — multi-provider OIDC**, key rotation, per-item content keys.
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
Not yet chosen.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user