7 Commits
Author SHA1 Message Date
jaap-janandClaude Opus 5 ffab2be22a Drop the formatting step, which spent minutes agreeing with the build
ci / build and test (push) Successful in 1m11s
ci / android head (push) Failing after 4s
ci / api image (push) Successful in 43s
`dotnet format --verify-no-changes` re-analysed the whole solution before the build did, to
reach a verdict the build reaches on its own: IDE0055 is an error in .editorconfig,
EnforceCodeStyleInBuild is on and warnings are errors, so a misformatted file fails the
build step. What the separate step bought was hearing about it a few minutes earlier, and
it charged those minutes on every run.

Checked rather than assumed, because the whole justification rests on it: appending a
badly-spaced member to a source file produces three `error IDE0055` lines and a failed
build with no format step in sight.

Three places said the old arrangement out loud and would now be wrong on their own — the
comment on the IDE0055 line, the conventions list in the README, and a note in
platform-flags telling people to run dotnet format before pushing or CI would fail them.
They say the build enforces it now. dotnet format is still how to fix what the build
complains about; it just no longer gates anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 21:43:32 +02:00
jaap-jan 8d2416a602 Add the encrypted local cache and the sync client
Three new client projects, and the wire-contract fix they needed.

DodoSSH.Client.Domain holds the decrypted item model and the three-way
merge, with no I/O at all — so the suite that decides whether a
credential can be lost runs in milliseconds with nothing to mock.
Scalars defer to the server on a genuine clash so every replica resolves
the same triple identically and two clients cannot ping-pong; directives
merge per name so two people each adding one both keep theirs; the jump
chain merges as a whole value because its order is the route. Whatever
loses is returned rather than dropped.

DodoSSH.Client.Storage is EF Core on SQLite, no SQLCipher: the rows are
already ciphertext, so an encrypted file would protect protected bytes
at the cost of a native dependency. It keeps the server's state and the
outbox in separate tables, which is what preserves the common ancestor a
merge needs. One pending operation per item, enforced by a unique index.

DodoSSH.Client.Sync is the pull/apply/push loop. Pulling never decrypts
— a change with no local work pending is plumbed as ciphertext — so a
first sync of thousands of items does not run twice as many AEAD
operations for nothing.

Contracts: EncryptedPayload gains WrappedDataKey and DataKeyId. The
specification has required a per-item data key since crypto.md §3, the
columns have existed since the first migration and DshAad.ItemPayload
binds the id, but this record had nowhere to put either — so a
spec-compliant item could not be transmitted at all. Found by writing
the client that has to produce one. Also closes a hole in
AadResourceType, which had no value for the HostTag and HostCredential
that SyncEntityType has always listed.

Four bugs the tests found, not review:

- SQLite refuses to order or compare its own DateTimeOffset mapping, and
  throws at execution rather than model build. Collecting tombstones and
  listing conflicts are both that shape, so this was a crash waiting for
  the first user with a deleted host. Timestamps are integers now, by
  convention so a later field cannot be the one left unconverted.
- SQLitePCLRaw 2.1.11, which EF resolves, is covered by
  GHSA-2m69-gcr7-jv3q. Pinned forward as a family.
- Resurrecting content from a remote deletion cleared the original
  before queueing the copy. Two transactions, so a crash between them
  lost the work; reversed, and the rescued id is derived from the
  tombstone so a replay coalesces instead of duplicating.
- Several equality assertions went through Shouldly's ShouldBe, which
  compares IEnumerable element-wise and so tested nothing about the
  Equals these types exist to provide. Corrected; the falsification that
  caught it went from 2 failures to 6.

The push response's cursor is deliberately ignored. It sits after this
client's own writes, so adopting it skips anything another client
committed at a lower sequence in the window between a pull and a push —
permanently. Re-reading one's own writes is idempotent and costs a page.
The Contracts doc that invited the shortcut now says so.

593 tests, up from 448. The delete-versus-edit rules, the ancestor
retention, the fresh operation id on coalesce and the cursor safeguard
were each verified by breaking them and watching the right test fail.
2026-07-29 10:27:37 +02:00
jaap-jan e6673f0bf2 Fix the CI formatting gate, which was already failing
dotnet format --verify-no-changes exits 2 on main: the async-suffix naming
rule fires on every async test method. I reported this gate as clean when
finishing the sync engine and it was not.

Test names are documentation. Push_WithAStaleVersion_ReportsConflict says
what is asserted; adding Async says the same plus an implementation detail
nobody reading a failure report needs. The suffix convention exists so
callers can spot awaitables, and a test method has no callers -- so the
rule is switched off under tests/ rather than the names being changed.
2026-07-28 16:05:52 +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
jaap-jan 06d04b490b Freeze DodoSSH.Contracts v0.1 (M1)
The second M1 gate. This assembly, not the OpenAPI document, is the client's contract,
so PublicApiAnalyzers now tracks all 540 public members: a renamed DTO property becomes
a build error rather than a runtime deserialisation failure on someone's laptop.

Contract surface:
- EncryptedPayload carries the envelope plus the KeyGeneration and AadVersion columns
  needed to recompute AAD, since AAD is derived from the row rather than transmitted.
- Sync: push with per-operation status (Applied/Conflict/Forbidden/Invalid/Duplicate) so
  one stale item cannot block a whole offline queue; a Conflict returns the server's row
  for client-side three-way merge, because the server cannot merge ciphertext.
- Enrollment: KeyStatement whose hash becomes the OIDC nonce, so the identity provider
  signs over the public keys and this server cannot fabricate a key for a user who never
  enrolled.
- Meta and .well-known configuration: capability negotiation instead of URL versioning,
  which is what a self-hosted product needs when client and server upgrade independently.
- SyncPlaintextFields deliberately has no label or name field. ACL admin runs client-side
  where names can be decrypted, so the server never needs a searchable title.

Two design problems found by writing the tests rather than assuming:
- Hand-constructing JsonSerializerOptions and merely pointing its resolver at the context
  silently discards every source-generated setting. JsonSerializerDefaults.Web replaces
  NumberHandling.Strict with AllowReadingFromString, so "1" would be accepted where 1 is
  meant — invisible until two implementations disagree. Callers now use ResponseOptions or
  StrictRequestOptions; StrictRequestOptions is derived by copying so it cannot drift.
- StrictRequestOptions had a static-initialisation cycle: it read the generated Default
  property from the same type's initialiser and got null. Now lazy.

Requests reject unmapped members so a client typo is a 400; responses tolerate them so an
older client can read a newer server. Enums cross the wire as strings, so reordering one
cannot silently reinterpret stored data.

Also: excluded source-generator output from PublicApiAnalyzers. The JSON generator emits a
public member per serialisable type, which would have added hundreds of mechanical entries
and drowned the ones describing the actual wire contract. And disabled MA0048's
one-type-per-file rule: splitting SyncPullRequest from SyncPullResponse makes a reviewer
open two files to understand one endpoint.

Verified: 0 warnings, 95 tests pass, format clean.
2026-07-28 13:28:02 +02:00
jaap-jan b15af836a3 Freeze DSH1 crypto specification and implement the core (M1)
docs/crypto.md is now the normative, frozen specification. This had to land before
anything else in M1: the server holds ciphertext and no keys, so it can never
re-encrypt, and a format change after users hold data is a coordinated client rewrite
with no rollback.

Specification:
- DSH1 envelope layout, canonical 64-byte AAD encoding, SealTo construction, key
  hierarchy, Argon2id profiles, fingerprints, and the change rules for each version field.
- AAD encoding is fixed-width binary rather than delimited string concatenation, so no
  field value can forge a field boundary. This supersedes the illustrative form sketched
  in ADR 0001, which now points here.
- UUIDs are RFC 4122 big-endian. Guid.ToByteArray() emits the first three groups
  little-endian and would have made our ciphertext unreadable by any other implementation
  of this spec, failing only at a cross-implementation boundary.

Verified rather than assumed:
- PrimitiveAvailabilityTests proves X25519, Ed25519, XChaCha20-Poly1305, Argon2id and
  HKDF-SHA512 all function on net10.0. NSec 26.4.0 targets net9.0 and is consumed by
  forward compatibility; this closes one of the two package questions the plan flagged.
- Argon2Profile exists because NSec's MemorySize is in KIBIBYTES, not bytes. Passing bytes
  gives either a 256 GiB allocation or a 256 KiB KDF that cracks instantly. The type takes
  mebibytes so the unit cannot be got wrong at a call site. Found by benchmarking: the
  first measurements were ~1000x too slow, which turned out to be 19 GiB of work.
- Parameters measured, not guessed: 256 MiB/t=4 is 323 ms on this machine; the table of
  candidates is in the spec.

Implementation and tests (83 total, up from 17):
- AadDescriptor, DshEnvelope, DshCrypto (Seal/Open/SealTo/OpenSealed/fingerprints).
- Decryption returns null rather than throwing: ciphertext comes from a server that is
  explicitly not trusted, so a failed tag is an expected outcome.
- Envelope readers reject unknown algorithms and any non-zero flag bit, so an envelope
  that is not fully understood fails closed.
- Executable form of the spec's substitution claims: a server cannot move ciphertext
  between resources, roll back a key generation or item version, repurpose a payload as
  metadata, or confuse the two constructions.
- Golden vectors in tests/fixtures/crypto/vectors.json guard the format. Mutation-checked:
  a one-byte schema version change trips four tests including the guard.

Two build-infrastructure bugs found and fixed along the way:
- .editorconfig forced camelCase on const and static readonly fields. PascalCase is the
  .NET convention for both; the config was wrong, not the code.
- The golden fixture was resolved with [CallerFilePath], which ContinuousIntegrationBuild
  rewrites to /_/... under deterministic source paths. It passed locally and would have
  failed only in CI. Now copied to the output directory and read from there.
2026-07-28 13:18:29 +02:00
jaap-jan 3a81f3c90b Restructure into src/tests and add build foundation (M0)
Moves the scaffold to src/DodoSSH.Api and establishes the repo conventions the rest
of the milestones build on.

Structure:
- src/{Contracts,Crypto,Domain,Infrastructure,Api}, tests/{Contracts,Crypto,Domain}.Tests
- DodoSSH.slnx rewritten with src/ and tests/ solution folders

Build:
- Directory.Build.props centralises TFM, nullable, deterministic builds and
  TreatWarningsAsErrors; Directory.Packages.props pins every version centrally
- packages.lock.json committed so CI restores in locked mode
- NuGet.config clears machine-level sources, which both fixes NU1507 under central
  package management and makes restore reproducible off this machine
- Microsoft.OpenApi pinned to 2.11.0: ASP.NET Core 10.0.10 resolves 2.0.0, which is
  covered by GHSA-v5pm-xwqc-g5wc (high, patched in 2.7.5)

Analyzers:
- AnalysisLevel is Recommended, not All. With warnings-as-errors, All turns opinionated
  naming rules into build breaks and trains people to blanket-suppress.
- BannedSymbols.txt bans DateTime.UtcNow (TimeProvider), Guid.NewGuid (CreateVersion7),
  sync-over-async, MD5/SHA1, PBKDF2 and SecureString
- CA1711/CA1724 disabled: both are .NET Framework CAS-era naming rules
- PublicApiAnalyzers on Contracts only, since that assembly is the client's real contract

API:
- weather-forecast template removed
- UseHttpsRedirection removed; TLS terminates at the reverse proxy and redirecting
  behind one causes loops
- /healthz/{live,ready,startup}. Liveness deliberately checks no dependencies so a
  transient database outage cannot restart the container and kill live SSH sessions.

Notes:
- No coverage collector yet. Microsoft.Testing.Extensions.CodeCoverage pulls an MTP 1.x
  MSBuild extension that throws TypeLoadException against the MTP 2.3.x xunit.v3 brings.
  Coverage gates are an M3 concern; revisit with an MTP 2.x-aligned version then.

Verified: dotnet build (0 warnings), 17 tests pass, format check clean, API serves
health and OpenAPI endpoints.
2026-07-28 12:25:34 +02:00