Commit Graph
9 Commits
Author SHA1 Message Date
jaap-jan d07b336868 Free the terminal from the Hosts screen, and fill the room it left
The WebView sat inside the Hosts grid, so navigating to Files or the keychain
hid every open terminal and the strip that named them. A connection you had
opened was invisible from four of the five screens. The window now has two
surfaces rather than one: a nav rail that says which page you are on, and a
terminal strip that is always there and switches the whole content area to a
shell. Screen keeps meaning "which page" and never becomes a sixth kind of
page, which is why this is two properties instead of one enum with a terminal
member in it.

Every screen lives inside one wrapper panel that collapses when a terminal is
showing. That is not tidiness — the WebView hosts a Win32 child window that
composites above everything Avalonia draws, so a screen left visible over its
rectangle is a screen sliced in half, and this window has shipped that defect
once already. One decision point, IsTerminalShowing, and a nested panel rather
than five compound bindings nobody would remember to extend.

The focus choreography is the part no test in this repo can see. Every reveal
path now focuses in the same turn the WebView appeared, so all three of them
post at DispatcherPriority.Loaded and let the native control re-push its bounds
first. Going the other way had a real bug: the screen-changed branch called a
bare Focus() where it had to release the keyboard from the native child, so
switching from a terminal to Files silently ate the first keystrokes. Rare
before this commit and the primary gesture after it.

The tab strip grew a cross inside each tab, a plus that opens the quick-connect
palette, and middle-click close. Nested buttons are correct here: Avalonia
handles a left press on the cross and deliberately does not handle other
buttons, which is exactly what lets middle-click bubble up from the cross as
well as the tab. The test is PointerUpdateKind rather than
IsMiddleButtonPressed, because the latter reports button state and is also true
for a left press made while the middle button happens to be held. The handler
is on the tab and not the strip, so the background closes nothing by
construction. Plus opens the palette rather than a flyout, since a menu
dropping into the WebView's rectangle may or may not composite above a child
HWND and this repo does not make rendering claims it has not photographed.

Everything a user reads now says keychain. The wire, the database and the
cryptographic spec still say vault, deliberately: renaming those is a migration
and a protocol change for a word. That split is written down rather than left
to be rediscovered as an inconsistency.

Four things that were squeezed into the keychain's category rail, or into
nothing at all, now have screens. Pinned host keys get one, with fingerprints
never truncated and a filter that matches them, because comparing what you have
against what the operator published is the whole workflow; the approved date is
read out of the item's UUIDv7 rather than added as a column, and says so, since
it means first approval and not last use. Keys can be generated in the client,
which needed the openssh-key-v1 container written by hand — there is no BCL or
NSec helper, and the PKCS#8 route is unverified in the SSH library this uses.
The armour carries no passphrase: encrypting it needs bcrypt_pbkdf, which is
Blowfish with a swizzle, in a project whose crypto is otherwise entirely
libsodium, for a protection the key's own remarks argue is redundant inside a
vault. Generation fills the existing editor and stops, so SAVE stays the one
thing that writes. ~/.ssh/config can be imported behind a preview that is
ticked per row and writes nothing until the button; IdentityFile records the
path and imports the key material only on an explicit opt-in, because reading
somebody's private key into a vault is precisely the act this product exists to
make deliberate. Match blocks and ProxyJump are reported rather than obeyed —
one cannot be evaluated statically and the other has nothing behind it to route
with, and a preview that implied otherwise would be worse than one that admits
it.

Files can be dragged in all four directions that are honestly available. Remote
to Explorer does not ship and is not pretended to: the shell wants the bytes
during the drop, which needs a virtual file and a native COM data object,
outside what Avalonia offers. Note for the next person that Avalonia 12
replaced the drag model outright — DataObject and DataFormats are no-op stubs
and IDataObject is not in the reference assembly, so every tutorial written for
11 does not compile here.

Hosts can be grouped, flat and never nested. A parent id merged as a scalar
lets two offline clients each re-parent A under B and B under A, producing a
cycle inside an encrypted payload that no server can police and every reader
would have to detect for ever. Membership lives in that payload rather than in
the one plaintext concession ADR 0001 allows, whose test is that the relay
cannot function without it — nothing on the server reads a group, so what
plaintext would hand over is a clustering of the estate for nothing. The
plaintext column reserved for it is dropped, provably always null, and the
server now refuses a client that sends one; it was never populated, was copied
on apply, and was not cleared on delete, so a group id would have outlived the
host it described.

Snippets insert through xterm rather than through the pump, because xterm is
the only thing that knows whether the remote has bracketed paste on, and that
is what makes a shell treat embedded newlines as text instead of as execute.
The host process moves opaque bytes and never parses output, so it would have
to guess, and guessing wrong runs every line. Running is off by default and the
copy says the text goes into whatever is there — the terminal has no notion of
being at a prompt, and may be in vi or at a password prompt with echo off, so
the Enter the user presses themselves is the entire safety property.

Connections and keychain changes are recorded as synced encrypted items, which
is what makes them auditable by a team later and costs the server knowledge of
connection rate and timing from row counts alone. ADR 0001 already concedes it
cannot hide that class of metadata; the trade is now written into it rather
than left implicit. A connection entry is written once, at close, which is what
makes a synced log tractable: nothing to merge, one outbox row, no chance of
colliding with itself. Live sessions come from memory, not from the log. The
write is void by contract and posts to a bounded channel, because putting an
encrypt-and-write on the teardown path of every session is how closing the
application comes to take four seconds. A ticket opened before a lock still
closes afterwards, since a shell outlives the vault. The activity log hooks the
one generic repository every kind writes through, so it cannot miss a caller —
which is also why the log kinds themselves declare they are not audited, or the
first entry would write an entry about writing an entry. It records the names
of the fields that changed and never their values; a log with an old password
in it would be a plaintext credential store with no vault around it. Retention
is 90 days or 5,000 entries, whichever bites first, pruned on the sync loop
rather than on a second timer.

That log traffic then broke the status line, which is worth recording because
the fix is a shape and not a patch: background sync counted its own log rows as
pushed items, so the quiet rule stopped being quiet and every action's message
was overwritten a second later by a sync report. The report now separates log
rows from user items and the rule reads the latter.

S3 buckets appear as a remote in the file browser, behind the same interface an
SFTP session implements, so the queue and both panes did not have to learn what
they are talking to. Uploads go through a pipe, because the queue wants to
write and the SDK wants to read; memory is then bounded by the part size
instead of buffering a file to disk twice.

Finally, the Windows device key store moved out of the session project, which
was the one thing keeping it from being portable — everything else in it is
platform-neutral, and a Windows CNG dependency in the middle of the vault code
meant a second head could not reference it without dragging Windows along. The
seam that made the move free was already there. docs/android-port.md is the
audit behind that: what ports, what does not, in order of cost, the four
decisions taken, and an inventory of every screen and state the interface has
to carry, written so a design can be made from it directly.

dotnet build, dotnet test and dotnet format --verify-no-changes are all clean:
1240 tests at zero warnings, including the end-to-end suite against real
containers. The manual checks that headless Avalonia cannot make — the drag
from Explorer, a generated key against a real host, twelve tabs at the minimum
window width — are listed in docs/manual-checks.md and are still outstanding.
2026-07-31 20:30:05 +02:00
jaap-jan 211eba0666 Keep host key trust in the vault, and make it withdrawable
ci / build and test (ubuntu) (push) Canceled after 0s
ci / build (windows) (push) Canceled after 0s
A fingerprint approved once is now approved on every machine and survives a
restart, because host key trust is a vault item type rather than a dictionary
that dies with the process. InMemoryKnownHostStore was what shipped, so the user
was asked to verify a fingerprint on every single connection — which is the gap
most likely to train somebody to click through the one warning that actually
matters. A warning that appears when nothing is wrong teaches that nothing is
ever wrong.

The fourth item type, and like the third it cost no sync logic: a row, an EF
configuration, a migration, a server kind; a secret, a codec, a merge, a cipher,
a repository facade and a session property. One row in the client registry. The
reconciler, the mirror, the repository, the outbox and the pull filter were not
touched. SyncEntityType.KnownHostKey and AadResourceType.KnownHostKey were
already reserved, so neither the contract nor docs/crypto.md changed.

One item per (host, port, algorithm), because a server legitimately offers
several host keys and which one gets negotiated is not ours to predict. Pinning
per endpoint would make an algorithm change indistinguishable from an attack.

The label is derived rather than stored, which is the one place this type
departs from the other three. A user never names a pin — there is nothing to
name it after but the three fields it already has — and a stored label is a
second copy of data that can disagree with the first after a merge. Relabel
returns the secret unchanged, and says why.

The store answers the handshake without touching the disk. SshNetConnectionFactory
calls FindAsync from inside SSH.NET's synchronous HostKeyReceived event, over
.GetAwaiter().GetResult(), which cannot be avoided; doing SQLite I/O plus an AEAD
open per lookup there would put the handshake behind the cache. So decryption
happens in OpenAsync and RefreshAsync — on unlock and after each sync pass,
exactly where the host and key lists already reload — and FindAsync is a
dictionary read under a lock with no await inside it.

That snapshot is where the one real bug in this change lived. Install originally
merged the live pins over the freshly loaded snapshot, to protect a TrustAsync
that had landed while the read was in flight. It would also have resurrected
every pin the user had just forgotten, and stopped a withdrawal made on another
machine from ever taking effect — the store would have healed the deletion back
into existence on every refresh. Replacing wholesale and discarding the read
instead is correct because writes are the rare case: every write bumps a
generation counter, and a refresh whose stamp is stale throws itself away rather
than winning. Nothing found this but reading the method again; it is the kind of
mistake that passes every test written before it, because the test that catches
it is the one the bug tells you to write.

Forgetting is new, and persistence is what made it mandatory rather than
convenient. A mismatch is a hard refusal with no way to continue — deliberately,
and that stays — so pinning a key permanently is also a way to make a
legitimately rebuilt server permanently unreachable. Before this change the pin
died at exit and the problem solved itself; now it does not.

ForgetAsync drops every algorithm for an endpoint, and it is reachable from the
host editor rather than from the warning. Putting it on the mismatch banner would
have made it two clicks from "this may be an attack" to "connect anyway", which
is the affordance the hard refusal exists to deny. The banner already promised
the key could be removed in the host's settings; that promise is now true and
points at the button.

Trust recorded on another machine becomes visible at the next sync pass, not
immediately, and that is a decision rather than an oversight. The failure it
produces is a first-contact prompt for a host a colleague approved a minute ago:
answerable, and self-correcting on the next pass. The opposite trade — polling
the vault on the handshake thread to close a one-minute window — buys nothing
and costs the property above. The dangerous direction is not reachable at all: a
pin recorded here enters the snapshot as part of recording it, so a refresh can
never discard a local trust decision.

The server learns nothing, and this is the item type where the temptation was
real. A plaintext host column would let a known-hosts screen sort and page
without decrypting anything, and it would hand the operator the map of every
user's estate — assembled, as these things are, out of facts that are each
individually harmless. A host row concedes an address only when relay is
switched on and the database refuses to store one otherwise (ADR 0004); there is
no equivalent excuse here. The table has no column to put one in, and the EF
configuration says so where somebody adding it would be standing.

Two things about the migration in this commit are worth knowing, because both
came out of getting it wrong.

It was hand-written first, including its .Designer.cs, and that version is not
what is here. Verifying it turned up something that had been quietly assumed:
Migration_AppliedCleanly_WithNoPendingModelChanges does not check the model
snapshot. It asserts that migrations applied and that none are pending, which a
wrong snapshot satisfies perfectly — the snapshot only matters as the diff base
for the *next* migrations add, so an incorrect one passes the whole suite and
corrupts the following migration instead. The real check is to generate a
throwaway migration and confirm its Up and Down come out empty. They did, and
the generated designer was byte-identical to the transcribed one across all 1255
lines, so the hand-written work was in fact correct.

Then dotnet ef migrations remove --no-build deleted the wrong migration. With
--no-build the tool reads the previously compiled assembly rather than the files
on disk, and the probe had just changed which migration was last, so it removed
AddKnownHostKeyItem and reverted the snapshot. That turned out to leave exactly
the right diff base, so the migration here is EF's own output rather than a
transcription — a better outcome than the one that was interrupted, arrived at
by accident. Never pass --no-build to migrations remove.

Mutation tested, all three sabotages detected: dropping the algorithm from
KnownHostIdentity.For, merging instead of replacing in Install, and pointing
KnownHostKeyCipher at PortForward — which is what a cast from the wire enum's 10
would silently produce. Each is caught both by an assertion about the mechanism
and by a behavioural test that never mentions it; the resource-type sabotage is
caught by the table from d10a38d and nothing else, which is what that table is
for.

The end-to-end slice now approves the real sshd's host key through the vault,
pushes it, and reads it back on the second simulated machine — including a check
that the server learned no address, and that the second machine answers null for
an algorithm never offered.

845 tests green. Zero warnings, dotnet format clean.

Three things are deliberately not fixed. A tombstone queued over a create that
was never pushed is refused by the server as Invalid and parked; that is
pre-existing for all four item types, and the fix belongs in
VaultItemRepository.DeleteAsync rather than here. Deleting a host, or changing
its address, orphans its pins — both are correct as trust decisions, since a pin
describes an endpoint and not a bookmark, but nothing surfaces the leftovers.
And there is no interface listing pins at all: trust is created at the connect
prompt and withdrawn in the host editor. A known-hosts list is where the orphans
would become visible, and it wants the vault column rework first, for the same
reason the credential editor does.
2026-07-30 11:00:39 +02:00
jaap-jan e24012b039 Sync credentials as a vault item type, and bind one to a host
Closes the largest remaining M1 gap in the data layer: a username and password
can live in the vault, sync between machines, and be named by a host as how it
authenticates. What is not here is the interface for creating one — see the end
of this message.

The third item type, and the first one that cost almost nothing to add. Server:
a VaultCredential row, an EF configuration, a migration, and a CredentialKind.
Client: a secret, a codec, a merge, a cipher, a kind, a repository facade and a
session property. No new reconciliation logic, no change to the sync engine, no
client cache migration. That was the whole point of the item-kind seam, and this
is the evidence it holds.

The narrowest type of the three on plaintext, and not for symmetry. A host has a
deliberate concession — the relay needs an address it can resolve. A key has a
fingerprint, public by nature, which this client still declines to send. A
password has no part that is safe to expose: not its length, not a hash, not a
hint. So CredentialKind refuses every plaintext field there is, hydrates none,
and the table has no column to put one in.

HostSecret.CredentialId is the password counterpart of SshKeyId, and the two are
mutually exclusive. SSH itself would happily try a key and fall back to a
password, but a host naming both leaves "how does this authenticate?" without a
single answer — the interface, the connect path and the user would each be free
to guess differently. TryValidate refuses it. One consequence was not
anticipated: "a full host" stops being a coherent idea, which is what broke
AFullHost_RoundTrips and is now written into that test.

The schema version became a ladder rather than a maximum: credential-bound is 3,
key-bound is 2, neither is still 1. Adding credentials therefore does not drag
every key-bound host in every vault onto a version that clients understanding
keys perfectly well would refuse to edit. A test pins exactly that, because it is
the property the whole content-dependent-version rule exists to provide, and the
obvious implementation would quietly lose it.

Two tests had become false and said so:

- Push_AnUnsupportedEntityType_IsInvalidNotAFailedBatch used Credential as its
  example of a type this server does not implement. It now asks the server's own
  registry what is still missing, so it cannot go stale again, and skips with a
  reason if that set ever empties.
- ThePullFilterNamesEveryTypeThisBuildSynchronises pinned the exact list, which
  is what it is for.

Also fixes ten nullable warnings — eight in SyncEndpointTests, two in a test file
added earlier today. Neither set was introduced here; both were invisible until
an unrelated change forced their project to recompile, which means the
zero-warning claims made earlier in this work only ever covered what happened to
be rebuilt.

777 tests green. Zero warnings, dotnet format clean.

Not done, and deliberately: the credential interface. The vault column is 340
pixels wide and already holds two lists and two editors, kept from clipping its
own buttons at the window's minimum height only by the one-editor-at-a-time rule
added earlier today. A third list and a third editor would recreate that defect
rather than avoid it, so the column needs a shape decision first. Credentials
sync; they cannot yet be created in the interface.
2026-07-29 21:09:08 +02:00
jaap-jan 70b3290a77 Bind an SSH key to a host instead of picking one per connection
A host now names the key it authenticates with, or none, as a field in its
encrypted payload — so the choice follows the host to every machine rather than
being made again each time somebody connects. The per-connection "Use key"
switch it replaces was a stopgap for not having this, and keeping both would
have left two mechanisms answering one question.

This is the first payload schema version bump, and it does not work the obvious
way. A host is written at the *lowest* schema version that can represent it: one
that binds a key is written at 2, one that does not is still written at 1, byte
for byte as it was before the field existed. The version is what makes an older
client refuse to edit an item, so stamping 2 unconditionally would mean
upgrading a single machine and renaming a single host made that host uneditable
on every machine that had not upgraded yet. Confining the cost to the hosts that
actually use the field is the difference between a team noticing a bump and a
team being blocked by one. HostSecretCodec states the rule so the next field
added follows it, and a test pins the version-1 bytes against a literal rather
than against the codec, because the claim is about history: every host already in
every vault has to re-encode to what it encoded before, or the first sync after
an upgrade would push the whole vault as changed.

A binding is an item id, not a copy of the key — a second copy of a private key
is one that goes stale — which means the reference can dangle when the key is
deleted on another machine. Both places that meets are handled the same way, by
refusing rather than falling back:

- Connecting to a host whose key is gone is refused outright. A host somebody
  deliberately set up for key-only access must not quietly start offering a
  password.
- Opening such a host in the editor keeps the binding, selected, labelled as
  missing. The quieter version of the same failure is someone editing the port
  and saving, silently converting the host to password authentication with
  nothing ever having said so.

Two things this found by being falsified:

- The merge was untested for the new field, and "just take the server's value"
  passed the entire suite — a local binding change would have been discarded with
  no conflict recorded. HostSecretMergeTests already had a test written for
  exactly this class of omission; it simply had not been extended.

- Adding a nullable field exposed a defect in HostSecretMerge.Field: it
  short-circuited when the discarded value was null, so the formatter never ran
  for the one case where null is a value rather than an absence, and a field
  whose absence has a name could not report it. Now the formatter always runs,
  and "no key" appears in the conflict log where an empty string used to.

Also fixes eight nullable warnings in SyncEndpointTests left by the server-side
SSH key commit, which had omitted the null-forgiving operator the rest of that
file uses. They were invisible until an unrelated change forced the project to
recompile.

The end-to-end slice now binds its host to its key, so a schema-version-2
payload goes through the real API, the real PostgreSQL and back out on a second
machine.

745 tests green. Zero warnings, dotnet format clean.
2026-07-29 20:42:51 +02:00
jaap-jan e93acc856f Sync SSH keys as a vault item type, over a shared write path
The private key now lives in the vault as ciphertext, syncs between a user's
machines, and is stored on the server so it can later be shared — sharing
itself needs M3's signed grants; this is the storage that makes it possible.

More was already reserved than expected: SyncEntityType.SshKey,
CryptoSpec.AadResourceType.SshKey, ChangeEntityType.SshKey,
SyncPlaintextFields.PublicKeyFingerprint, and SshPrivateKeyCredential wired
through PrivateKeyFile over a MemoryStream so a key never touches disk. The
frozen contract and crypto spec needed no change at all.

What was missing was the server. Rather than copy the push path per item type
— version check, change-log append, exactly-once receipt, advisory lock — it
is now written once over IVaultItem, with everything type-specific behind
IItemKind: which table, which plaintext columns, and what those columns must
satisfy. Ten copies of that logic by M5, with a fix applied to nine, is the
outcome this avoids. The refactor landed first with no behaviour change, so
all 66 existing Host tests were the regression net, and they stayed green.

An interface rather than a base class, deliberately: EF Core maps an
inheritance hierarchy when it can see one, so a mapped base would quietly
become a table-per-hierarchy discriminator across item types — the very
arrangement per-type tables exist to avoid.

ssh_key mirrors host and pointedly has no relay trio. That is the argument
for separate tables rather than one wide item table: the columns a host needs
are columns a key must never have, and a shared table could only make them
nullable and trust the code. A key carrying a relay target is refused with a
reason rather than silently dropped.

A key hydrates PlaintextFields as null, not an empty instance — the
difference is visible on the wire, because an all-defaults instance still
serialises "relayEnabled": false and invites a reader to believe the setting
exists and is off. It has none.

Two things now defended by tests rather than by comments. Each kind states
its own ChangeEntityType instead of casting: the two enums agree numerically
but do not even share member names (Host against SshHost), and filing key
changes under the host type is silent sync corruption — sabotaging it fails
three tests. And EntityTypeAlignmentTests asserts the two enums stay aligned
in both directions and in count, which nothing did before.

The client half is next: SshKeySecret, its codec and merge, the cipher, a
repository, and the UI. Note for that work — SyncEntityType.SshKey is 3 while
AadResourceType.SshKey is 6, so a cast between them would seal key ciphertext
as a vault and nothing would fail.
2026-07-29 15:14:06 +02:00
jaap-jan 1d262b7ccc Run M1's end-to-end slice, and fix the two bugs it found
The whole vertical slice now runs against a real Keycloak, a real API, a
real PostgreSQL and a real sshd: sign in through the browser flow, enroll
with the identity-provider key binding, unlock, create a host, sync it,
read it back on a second machine, unlock again with no network, accept an
unseen host key, and open an interactive shell. Opt-in, because it needs
the development stack; skipped with a message naming the commands.

It found two bugs on its first run, and both are the same class: two
sides of a stub agreeing with each other about something the
specification never said.

**The API never applied DodoSshJsonContext to its HTTP JSON options.**
Minimal APIs therefore used the framework's web defaults, which write an
enum as a number. Every request DTO carrying one failed to bind against a
client writing the specified string form — which is the entire sync
surface, unreachable from the real client, with a 400 naming only the
parameter. The documented guarantee that request bodies reject unmapped
members was likewise not in effect anywhere.

Nothing caught it because the API tests posted with PostAsJsonAsync's
defaults, so they and the server had independently settled on integers.
Those tests now serialise through the contract, which is the deeper fix:
removing the new configuration fails 13 of them. Copying settings into
options a host owns is itself the hazard the context warns about, so
ApplyTo lives beside the settings it mirrors and ApplyToTests pins the
transformation, including that inserting the resolver leaves the caller's
own in place.

**The realm registered a loopback redirect URI Keycloak rejects.**
`http://127.0.0.1:*/callback` looks more explicit than the RFC 8252 form
and is broken: Keycloak's wildcards are trailing-only, so the `*` parses
as a literal port and every authorization request came back "Invalid
parameter: redirect_uri". Providers ignore the port for loopback hosts,
which is the whole mechanism, so the correct registration is
`http://127.0.0.1/callback` — path pinned, port free. The value the
server advertises through the discovery document said the same wrong
thing and now says the right one.

Two smaller things, both documented in docs/platform-flags.md:

- --import-realm skips a realm that already exists, so editing the realm
  file and restarting Keycloak changes nothing and serves stale
  configuration. The container has to be recreated. The compose comment
  claimed the opposite.
- Keycloak marks its session cookies Secure even over plain HTTP, because
  SameSite=None requires it. A spec-conformant client drops them and the
  login POST answers 400 with no message; browsers complete the flow only
  because they exempt loopback. Harmless for the product, fatal for
  automation, so ScriptedBrowser carries the cookies by hand and says why.

Also: the server enforces a 64 MiB floor on the passphrase KDF, so this
suite cannot use the 8 MiB profile the other client suites take for
speed. Those only get away with it because their in-memory servers have
no policy — worth knowing rather than rediscovering.

638 tests. The solution-wide run stays green with the stack down: exit
code 8 means "no tests ran", which the platform reports as failure, so
the opt-in project ignores exactly that code.
2026-07-29 11:37:49 +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 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 98d29bff37 Add HTTP integration harness and the sync authorization matrix (M1)
ci / build and test (ubuntu) (push) Has been cancelled
ci / build (windows) (push) Has been cancelled
27 end-to-end tests over the real HTTP pipeline, against a PostgreSQL container and a
stubbed identity provider. This closes the gap the previous commit flagged.

Authentication is genuinely exercised, not bypassed. StubIdentityProvider serves real OIDC
discovery and JWKS via WireMock and signs tokens with a real RSA key, so the application's
own JwtBearer pipeline validates issuer, audience, signature, lifetime and claims. A
TestAuthHandler that short-circuits authentication would hide exactly the claim-mapping
mistakes that cause real authorization holes. Proven by rejecting: no token, a foreign
signing key, the wrong audience, the wrong issuer, and an expired token.

Authorization denials — the tests that matter most:
- Another user's vault is 404, not 403, for both pull and push. A distinct
  "exists but forbidden" answer is an existence oracle for other tenants' vault ids.
- A denied push writes nothing: no host row and no change-log entry. A denial that still
  mutated state would be worse than no check at all.
- A team vault is denied until M3 rather than falling through to a permissive default.

Behaviour covered: push/pull round trip, cursor advance (and that an empty pull does not
rewind the cursor, which would replay history), tampered cursor rejection, stale-version
conflict returning server state without overwriting, operation-id replay reported Duplicate
and applied once, a mixed batch applying the good and reporting the bad, relay field
enforcement both ways, delete clearing the relay address, tombstones carrying no payload,
and JIT provisioning happening exactly once.

Two configuration problems found by running it:
- appsettings.json carried empty-string placeholders for the connection string and OIDC
  authority. Under minimal hosting those beat anything a test registers via
  ConfigureAppConfiguration, because Program.cs adds its own sources after that callback
  runs. Removed them outright — an empty placeholder turns "not configured" into
  "configured as empty", which defeats failing fast. Tests now use DODOSSH_ environment
  variables, which Program.cs adds last.
- My first fix for minting an expired test token derived notBefore from the expiry, which
  put nbf fourteen minutes in the future for normal tokens and made every valid token 401.
  It needs the earlier of now-1min and exp-1min.

Verified: 0 warnings on a clean rebuild, 173 tests pass (up from 146), format clean.
2026-07-28 15:11:43 +02:00