Commit Graph
4 Commits
Author SHA1 Message Date
jaap-jan 9bc28f1c0f Move the API onto FastEndpoints, without moving the wire
Eight endpoints today, around sixty planned. The minimal-API shape — a static
class per area holding static local functions, route and policy and name
asserted in one fluent chain with the handler somewhere below it — has not hurt
yet, and would. A handler's dependencies are parameters rather than injected, a
group's RequireAuthorization sits far from the handler it governs, and there is
no type to hang an endpoint's own documentation on. FastEndpoints is one class
per endpoint, its route and authorization in Configure(), its handler a method
on the same type.

Nothing about the wire moves, and the evidence is that the 94 existing HTTP
tests pass with zero edits to any of them. Same routes, verbs, route
constraints, status codes, operation ids, and the same RFC 9457 bodies with the
same code values. Every place the idiomatic FastEndpoints answer would have
changed one of those, it was refused:

Endpoints are registered from an explicit List<Type>, not found by scanning.
ADR 0002 rejected reflection discovery by name, and the reason it gave is
sharper here than in general — under WebApplicationFactory the scan reaches the
test assembly, so an endpoint written in a test would be registered into the
host under test. The cost is a line per endpoint that can be forgotten, which is
what the endpoint-inventory test is for. That test is the one ADR 0002 promised
and never got.

Handlers still return Results<Ok<T>, NotFound, ProblemHttpResult> from
ExecuteAsync. The union executes as an ordinary IResult, which is what keeps
problem bodies going through the host's serialiser and IProblemDetailsService,
and what keeps the compile-time record of which statuses an endpoint can
produce. No Send.* call appears anywhere; the moment one does, a response has
left the host's serialiser.

Validation stays in the feature services. A Validator<T> short-circuits before
the handler and answers with FastEndpoints' own envelope, which carries no code
— and the code is the only part of an error the client branches on. Twenty-odd
tests assert a specific code on a 400. It is banned in BannedSymbols.txt rather
than merely avoided, because the framework's documentation leads straight to it
and it looks like an improvement.

Three defects arrived with the framework and were caught in review. All three
were green at the time, which is the part worth remembering. FastEndpoints maps
GET /_test_url_cache_ unconditionally, in every environment, with no policy and
no way to opt out; it answers with the whole endpoint-name-to-route table. It is
short-circuited to 404 — by asking routing which endpoint it selected, after the
first attempt compared the request path with Ordinal and was therefore bypassable
at /_TEST_URL_CACHE_, certified by a test that only ever tried one spelling. The
default request binder writes query-string values over the deserialised body,
which would have let ?identityProviderToken=... put an ID token in a URL and from
there into every proxy log on the path; every endpoint now binds from the body
alone. And a route value read with Route<T>() is invisible to ApiExplorer, so the
generated document named {vaultId} in a path template with nothing declaring it —
invalid OpenAPI, and unusable by the client generators the document exists for.

Two changes to the surface, both deliberate. A body that cannot be deserialised
now answers with a problem document carrying malformed-request, rather than an
empty 400: FastEndpoints' default announces application/problem+json while
sending something else, and names the failing .NET type on the wire, in a
codebase that sets IncludeErrorDetails = false to prevent exactly that. And the
route table above returns 404 where it would otherwise have answered any
authenticated caller.

Each of the three fixes has a regression test that was checked by reverting the
fix and watching it fail — four failures for the route table and the binder, four
for the document. That check is the whole reason to trust them, since all three
defects passed a full green suite on the way in.

950 tests green across 16 projects, 14 of them new and no existing test edited.
Zero warnings, format clean, locked restore clean. FluentValidation, JobQueues
and Messaging are in the graph now and none is used.

Not verified: the generated document's response schemas, which differ from
before — FastEndpoints contributes its own Produces metadata. Nothing consumes
the document yet, and MapOpenApi runs only in Development behind the fallback
policy. It needs pinning if ADR 0002's build-time artifacts/openapi/v1.json is
ever built.
2026-07-31 08:39:06 +02:00
jaap-jan a628762cd1 Add /me and enrollment with the identity-provider key binding (M1)
The last backend piece of M1. A client can now log in, discover it must
enroll, publish its identity key, and get a usable personal vault.

Enrollment is one indivisible act. One transaction writes the key, its
wraps, the device, the key log entry, the vault and the vault key grant,
because none of them is useful alone: a key with no vault leaves a user
unable to store anything, and a vault with no grant is a container nobody
can ever open -- including its owner, since only the client can wrap the
key and it has already moved on.

Two independent checks run, and neither substitutes for the other. The
Ed25519 self-signature proves possession of the private key. The
identity-provider binding proves whose key it is: the client hashed its
statement, used the hash as an OIDC nonce, and the resulting ID token is
the provider's signature over exactly those public keys. This server
cannot mint that signature, so it cannot invent a key for a user who never
enrolled -- which is the attack that would otherwise let an operator read
every vault by publishing its own key as yours.

The binding token is stored verbatim, not just summarised. Clients must
repeat the check against the provider's JWKS fetched directly, and storing
only our conclusion would ask them to trust the server about the one
question the design exists to avoid trusting it about.

Key log appends take a deployment-wide advisory lock. The falsification
matters more than the passing test: with the lock removed,
Enroll_ConcurrentEnrollmentsByDifferentUsers_LeaveAnUnbrokenChain fails
with entry 11 linked to the wrong predecessor. Different users trip no
unique index, so without serialising they all read the same head and the
chain forks -- indistinguishable from the key substitution the log exists
to make detectable, and permanent, because the log is append-only.

Enrollment is idempotent. Vault ids and keys are client-chosen, so a
client whose response was lost re-sends the identical body and gets the
identical result. Without that, a lost response leaves a user enrolled
against a vault they never learned the id of.

Contract change, breaking the v0.1 freeze deliberately. EnrollmentRequest
had DevicePublicKey but no wrap to go with it, which is unsatisfiable:
only the holder of the secret bundle can seal it, so the server could
never fill the gap. Added DeviceWrappedPrivateKey, and PersonalVault so
enrollment can be atomic rather than leaving an unopenable vault behind
two endpoints that do not exist yet. No client exists and no package is
published, which is exactly when PublicAPI.Unshipped.txt expects this.

Sync now requires the Enrolled policy, which until now was a stub whose
name promised a check it never made. The sync denial tests use enrolled
intruders instead of unenrolled ones -- an unenrolled caller is stopped
before the vault check runs, which would have left those tests passing
without exercising the thing they exist to prove.

Also fixed: omitting kdfParameters from the JSON body was a 500. A
record's non-nullable parameters are a compile-time promise, not a runtime
one.

268 tests pass, zero warnings on a clean rebuild, format clean.
2026-07-28 16:06:35 +02:00
jaap-jan 3829217e8a Add sync engine: cursors, push/pull, and the advisory-lock ordering proof (M1)
The vault write path. Push is the only way items change — no per-entity POST/PUT/DELETE —
so one place enforces revisions, the change log and access control.

The concurrency hazard, now proven rather than asserted:
bigserial assigns sequence values when the INSERT runs, not at commit, so transaction A
can take sequence 5 while B takes 6 and commits first. A reader polling in between sees
only 6, advances past 5, and never learns about it. AdvisoryLockOrderingTests reproduces
that gap WITHOUT the lock first — otherwise the with-lock test proves nothing, since it
would pass just as happily if the interleaving never occurred — then shows
pg_advisory_xact_lock removes it, and that 12 concurrent writers produce no gaps.

Cursors are opaque and HMAC-tagged, and carry their vault id. 29 unit tests cover the
rejections, which are the point: an accepted-but-wrong cursor is silent data loss, strictly
worse than an error a client can resync from. Rejected: tampered tag, tampered payload,
foreign signing key, a legitimately-issued cursor from another vault, truncation, and
hostile input (never throws — cursors come from clients).

Push semantics:
- 200 even on partial failure, with per-operation status, so one stale item cannot block
  everything a client queued while offline.
- Conflict returns the server's current row for client-side three-way merge. The server
  cannot merge ciphertext, so never last-writer-wins.
- opId receipts make retries exactly-once per operation, not per batch — a client retrying
  a partially-overlapping batch after a timeout would otherwise double-apply what landed.
- A tombstone beats a late upsert, and delete clears hostname/port: leaving the address
  would keep the server able to resolve a host the user believes they deleted.
- Relay field validation mirrors the DB CHECK so a bad request is a clear Invalid rather
  than a constraint violation surfacing as a 500.

Authorization goes through IVaultAccessService, which returns the same answer for "absent"
and "forbidden" — distinguishing them is an existence oracle for other tenants' vault ids.
Team vaults are explicitly denied until M3 rather than falling through to a permissive
default. JIT provisioning keys on (issuer, subject), never email, and handles the
concurrent-first-request race via the unique index.

Renamed two domain types: Host -> SshHost, because Host collides with
Microsoft.Extensions.Hosting.Host in every file of a web project, and SyncChange ->
VaultChange to stop it colliding with the Contracts DTO of the same name. Aliasing at every
use site would have been permanent friction.

Worth noting: `ef migrations has-pending-model-changes` reported clean after those renames
even though the snapshot still said "DodoSSH.Domain.Host" — it diffs tables, not CLR type
names. The snapshot was regenerated and the emitted DDL diffed against the previous
artifacts/schema/v0.1.sql to confirm the rename produced no schema change.

Also removed ConfigureAwait(false) from test methods: xUnit1030 flags it as bypassing
parallelization limits, which is why MA0004 is suppressed in test projects.

Verified: 0 warnings on a clean rebuild, 146 tests pass (up from 122), format clean.

Endpoint-level tests are the immediate next step: they need a WireMock OIDC/JWKS stub and
real JWT minting, so the "wrong user is denied" matrix does not exist yet for these two
routes. The service-layer authorization and the concurrency property are covered.
2026-07-28 15:02:02 +02:00
jaap-jan d3b14e6bc0 Add configuration, OIDC auth wiring and discovery endpoints (M1)
Options, JWT bearer validation, the /meta and .well-known endpoints, and a dev compose
stack with Keycloak. Verified end to end: compose up, migrate, run, both discovery
endpoints return correct payloads, and readiness reports the schema current.

Configuration:
- Strongly-typed options for Server, Oidc, Relay and Sync, all ValidateOnStart. A
  self-hosted server that boots half-configured and fails later per-request is far harder
  to diagnose than one that refuses to start and names the bad setting.
- Cross-field validation the annotations cannot express: relay needs a WebSocketUrl when
  enabled, idle timeout must be under max session duration, item payload cap under batch cap.
- Startup warnings for combinations that are individually valid but dangerous together:
  RequireHttpsMetadata false outside Development, and AllowEmailLinking (which turns any
  token bearing a victim's email into account takeover, hence default false).

Auth:
- JwtBearer with ClockSkew cut to 30s from the 5-minute default; five minutes of slack on a
  credential granting vault ciphertext access is more than any clock needs.
- IncludeErrorDetails off, and a FallbackPolicy so an endpoint without an explicit policy
  still requires a caller rather than silently being public.

Discovery, per ADR 0002:
- /api/v1/meta reports versions, features and push caps.
- /.well-known/dodossh-configuration is the onboarding story: the user types one server URL
  and the client discovers OIDC authority, client id, scopes and relay endpoint.

Two environment problems found by actually running the stack:
- PostgreSQL 18 changed its data mount point. Mounting /var/lib/postgresql/data — correct
  through 17 — makes the image refuse to start; 18+ wants a single mount at
  /var/lib/postgresql with the cluster in a subdirectory.
- Keycloak moved to host port 18080. An unrelated Apache Tomcat on this machine holds
  127.0.0.1:8080, and a loopback-specific bind beats Docker's 0.0.0.0 publish for
  "localhost". It presents as Keycloak 404ing every realm while its own log says the import
  succeeded, which is a genuinely misleading failure.

Also: CA1848 is enforced, not advisory — warnings are errors, so the .editorconfig comment
claiming otherwise was wrong. Startup and health logging now uses [LoggerMessage]. And a
clean rebuild is back to zero warnings; the incremental build had been hiding 40 in test
projects (banned Guid.NewGuid, an obsolete Testcontainers constructor, and two analyzer
families that are genuinely noise under a test host).

Verified: 0 warnings on a clean rebuild, 122 tests pass, format clean.
2026-07-28 14:33:54 +02:00