Commit Graph
18 Commits
Author SHA1 Message Date
jaap-jan d5b1a73182 Move the keys when a membership changes, not just the flag
Adding somebody to a team granted them nothing readable and removing them
rotated nothing. Both were honest — the interface said so in as many words — and
both left the actual work to a button somebody had to remember to press, on a
machine that happened to hold the key. Adding now wraps every team vault this
machine can open to the new member, and removing revokes their grants and moves
each of those vaults to a fresh key that goes to whoever is left.

The rotation is where the design had to be decided rather than written. A vault
key is per generation and an item carries the generation it was sealed under, so
advancing the vault and withdrawing the old grants would make everything already
stored unreadable to everybody, including whoever pressed the button. So earlier
grants are kept: a member holds one per generation, /me serves them as
PriorKeyWraps, and VaultKeyring holds a key per generation — the newest for
writing, the item's own for reading, chosen per item on every read path. Sharing
issues one grant per generation held, because a recipient handed only the current
key would open the vault to find most of it undecryptable; revocation takes every
generation, because leaving the history behind leaves them able to read
everything written before the rotation.

The bump itself is one server transaction. POST /vaults/{id}/rekey must name
exactly current + 1 and the vault's xmin token makes that binding, so two admins
rotating at once do not both walk away believing they succeeded — the second is
refused and told to read the vault again. The server contributes the moment and
no cryptography: it cannot generate the key, cannot tell that the one it is
handed differs from the old one, and checks that the caller held the old one the
only way it can, by requiring a live grant at the current generation.

What this does not do is re-encrypt what is already stored, and the product says
so rather than the reassuring version: everything written from the rotation
onwards is unreadable to the person who left, and nothing about the past changes.
That half is deferred and is safe to add incrementally precisely because a vault
at mixed generations stays readable. ADR 0010 records the alternatives — revoking
the old grants, chaining each key under its successor, re-sealing every item in
one request against a server that caps a push at 500 operations — and why each
was rejected.

Two things fell out of the change rather than being asked for. The grant listing
would have shown a member once per generation, so it now returns one row per
holder carrying the best key they hold, which is what makes a row below the
vault's generation mean "still owed the new key". And MarkUnreadable gives up the
write target as well as reporting: a client whose vault was rotated elsewhere
would otherwise have gone on sealing items under its superseded key — readable to
its author, unreadable to everybody else, with nothing to show for it.
2026-08-03 23:05:40 +02:00
jaap-janandClaude Opus 5 b7335743d9 Make a host take what its group lends it, everywhere it is read
Step 4 of docs/adding-hosts-on-the-phone.md. The domain could resolve a host
against its groups; nothing asked it to. This is the wiring, and it is mostly
one change repeated: read the resolved host, not the stored one.

TryBuildAuthentication and TryBuildConnectionRequest now take the resolved
value beside the stored one, which is where group context was being lost. It
is the only authentication resolution in the product — both heads and both
transports come through it — so a host inheriting its binding would otherwise
have been offered a password prompt on every screen at once. The
credential-username fallback becomes three levels, and Complete still refuses
an empty username, but now only after the chain has been walked; refusing
before it would refuse exactly the hosts inheritance exists to serve.

HostRowViewModel carries its ResolvedHost, resolved once when the list is
built. Address, Authentication and Dialled read it, so a row cannot disagree
with itself about what it dials — and MainWindowViewModel.Rank searches
Address, so a host inheriting 2222 that displayed 22 would have been
unfindable by the port it actually answers on.

HostsBoundTo counts over the resolved binding, which is the difference between
a warning and a silence: a key bound once on a group and inherited by twenty
hosts named nobody, would have been deleted, and would then have refused all
twenty at connect time.

HostFields.From is answered by a refusal rather than by threading a group list
through the sync engine. A relay host may not inherit its port. The reason is
stronger than the convenience: a plaintext column is a derived duplicate the
client supplies when it pushes *this* host, so an inherited port would make it
depend on another item — editing a group would change what the relay dials for
every host beneath it, except that nothing re-pushes those hosts, so the server
would keep dialling the old port until each was next touched for some unrelated
reason. A stale wire on the relay path connects the user to the wrong service.

The editor distinguishes unset from explicit in both directions. An empty port
box means "take the group's" and shows what that will be as a placeholder,
following the group picker as it moves — a pre-filled 2222 would have been
indistinguishable from one the user typed, and saving would have pinned it. The
authentication picker gains a fourth entry, offered only to a host in a group,
because for an ungrouped host it would behave exactly like the first.

Which found a real defect while the tests were being written. Filing an
ungrouped host into a group silently pinned it to a typed password: the picker
had no "Inherit" entry when it opened, so it sat on "Password (ask each time)",
and saving wrote that as a decision — the host would have been pinned to a
prompt nobody asked for and the group's key would never have reached it. Two
guards now: the picker is rebuilt when the group changes, and BuildHost writes
AsksForPassword only for a host that had the alternative on offer.

The group editor is here too, and the plan never assigned it a step. Without it
no group can carry a default, so every line above would have been unreachable.
It grows a parent picker that leaves out the group itself and everything
beneath it — a courtesy rather than the guarantee, since a cycle assembled from
two offline re-parents was never offered that list — and three defaults beside
the name, each of which may be left empty because "lend nothing" is an answer.

Tags are stored and not editable. TagIds merges, encodes and resolves; no
screen can set one yet, and the editor carries the set through a save untouched
so a client that can set them does not lose them to somebody editing a port.

Eight new tests, and they dial. That is the point of them: a resolved value
that never reaches SshConnectionRequest is a label, and every one of these
failures would be silent — a host connecting to the wrong port, or being asked
for a password it does not need, with nothing on screen admitting it.

Verified by the whole suite: 1390 tests over nineteen projects, none failing.
Both heads build. Nothing on the phone has changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 10:41:28 +02:00
jaap-janandClaude Opus 5 8c04ba60b0 Build the three things the phone's + needs, before the + exists
Steps 1 to 3 of docs/adding-hosts-on-the-phone.md: the domain half. Nothing
on either head has changed, which is deliberate — the plan orders these first
because everything the editors will bind to has to exist and be merge-safe
before a screen can offer it.

HostGroupSecret gains a parent and four defaults, and the codec gains the
version rule it never had. It stamped CurrentSchemaVersion unconditionally,
which was harmless with one field and one version and stops being harmless
here: upgrading one machine and renaming any group would have made that group
uneditable on every machine still on the old build. It now emits the lowest
version that loses nothing, so a flat group with no defaults still encodes at
version 1, byte for byte, pinned against a literal.

Tags become a real item over the reserved slot. Secret, codec, merge, cipher,
repository, both registries, the EF entity and a generated AddTagItem
migration. TagCipher names AadResourceType.Tag as a constant rather than
casting the wire type, because Tag is 5 on the wire and 8 in the crypto enum
and 5 there is Credential — a cast would seal every tag under the resource
type for a password, encrypt and decrypt perfectly on the machine that wrote
it, and only fail when another implementation refused the item, by which time
the AAD is frozen into stored ciphertext. HostTag stays reserved and unused:
the one thing the join buys over a set on the host is bought instead by
merging TagIds per id.

HostSecret grows TagIds and Port goes nullable, which is the change with the
widest blast radius and the only one that loses an item rather than locking
one. A host with no port of its own omits the property, an older build reads
int Port as 0, and TryValidate refuses it — unreadable rather than read-only.
That cost is confined to hosts which actually inherit, because the version is
a maximum over the fields present; the alternative, writing 22 into every
host, is the lie inheritance exists to stop telling.

One decision the plan did not specify. "Three states where there were two" is
four — key, credential, typed password, or the group's answer — and two
nullable ids carry three. Naming neither id now means inherit, so
AsksForPassword says "a typed password even under a group that lends a key"
out loud. Only true is ever written and a decoded false folds back to null, so
a host that never touched it encodes as it always did. Nothing already stored
changed meaning: no group could lend a binding before this build, so every
existing host resolves exactly as it did.

HostInheritance is the resolver, and its visited set is load-bearing rather
than defensive. Two clients can each re-parent A under B and B under A while
offline; the merge sees one item against one item and the server sees
ciphertext, so nothing upstream can refuse the pair. With inheritance the
chain is walked at connect time, so an unguarded cycle is not an undrawable
sidebar — it is a shell that never opens. Stopping at the first repeat
degrades it to a group that reads as a root, and clearing the parent is the
repair.

A tag set turns out to be the one field on a host that can never ask the user
anything. TagSet.ToIdMap keys by the value, so no key can hold two values, so
the both-sides-moved-differently branch of the keyed merge is unreachable —
asserted over the whole eight-row matrix. The conflict loop is kept anyway,
because that proof is one edit from ceasing to hold and what it would cause is
a discarded tag nothing records.

Three guard tests failed by design and were fixed rather than relaxed: the
ordered pull filter, the AAD pinning table, and the server's refusal of a
plaintext parent — that last one survives with its reason rewritten, because
the refusal now means "the parent is not the server's to hold" rather than
"there is no such thing as a parent". The prose that said groups are flat is
rewritten in all four places it appeared, not deleted.

The five view-model sites that read Port directly now go through the resolver,
which is a down payment on step 4 rather than the whole of it. HostFields.From
still emits the stored port, and that is the one remaining place where an
unresolved read would be a wrong wire rather than a wrong label.

Verified by the whole suite: 1382 tests over nineteen projects, none failing.
Both heads build. Nothing seen on a display, because nothing on a display has
changed yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 10:21:02 +02:00
jaap-janandClaude Opus 5 8a568117df Give the API an image, and unbreak the restore that had to run first
registry-docker.dodotech.cloud/dodotech/dodossh-api, built and pushed by a third ci job
that needs the first. Gating on the tests costs a few minutes on every main commit and buys
the only thing worth having here: an image is not an artefact somebody inspects before
using it, so a red commit must not be able to produce one. Pull requests build the image
and stop, which is where a broken Dockerfile should be found.

Tags are :sha-<short> on every build, :main on main, and for a v* tag :1.2.3, :1.2 and
:latest — the last two only when the version has no prerelease suffix, since v1.3.0-rc1
sorts above v1.2.9 and would otherwise walk :latest onto somebody's server. Only sha- is
immutable, and it is the one to pin a deployment to.

No docker/* actions. The build is single-architecture, so it needs the daemon this runner
already has for the Testcontainers suites and nothing else — no buildx, no QEMU, and no
third-party action whose SHA has to be audited and re-pinned. Step outputs and secrets
reach the shell through env rather than ${{ }} interpolation, because a git tag may contain
a semicolon and interpolation is textual substitution performed before the shell parses the
line.

The image is chiseled: no shell, no package manager, uid 1654. Affordable because
Directory.Build.props already sets InvariantGlobalization, so the ICU and tzdata a normal
base carries are exactly what this product decided not to use. The cost is stated in the
Dockerfile rather than hidden — there is no HEALTHCHECK, because there is nothing to run
one with, and /healthz/ready is anonymous precisely so the orchestrator can ask instead.
Nothing migrates the schema from inside the container either; readiness fails while a
migration is pending and names it, which is the design.

And the restore that all of this depends on did not work. 7a3a521 committed lock files
carrying a net10.0/android-arm64 section into fourteen projects — written there by the
Android head's -p:RuntimeIdentifier=android-arm64 packaging build, which restores the
shared projects with a RID and updates their lock files as a side effect. Any restore
without that RID then fails NU1004 in locked mode, which is every other build there is:
`dotnet restore DodoSSH.slnx --locked-mode` has been failing for eleven projects on a clean
checkout of main since that commit. The sections are removed here and nothing else changed
— deletions only, ILLink.Tasks stays at 10.0.10.

Verified: the solution restores in locked mode, the image builds, and it runs. /healthz/live
answers 200 and /healthz/ready answers 503 naming the database it cannot reach, from a
67 MB image as uid 1654, configured entirely through DODOSSH_-prefixed variables.

The Android head's own lock file still carries the RID and is untouched, because that job
restores it separately and is outside DodoSSH.slnx. Whether its packaging step re-dirties
these fourteen on every CI run is worth a look; it is the same mechanism.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:40:12 +02:00
jaap-jan 7a3a521c59 Give the phone the rest of its screens, and a way in
ci / build and test (push) Failing after 2s
ci / android head (push) Failing after 1s
All seven screens of the design, plus the two it does not draw because it starts at an
enrolled phone: naming a server, and choosing a passphrase.

The five states docs/android-port.md worried about losing at 360dp are all here and none
of them softened. The changed-key refusal is a full-screen panel rather than a bottom
sheet, because a sheet is swipe-to-dismiss by convention and that screen must have no way
forward. The recovery code raises FLAG_SECURE for its own state and lowers it afterwards,
so the sentence about screenshots is true rather than decorative. The delete
confirmations keep their counts and replace the row in place.

Signing in works, and the seam it needed is worth more than the implementation:
IAuthorizationCallback now sits between OidcClient and the loopback listener, so the two
heads differ in where the response arrives and in nothing else. PKCE, the state check,
discovery, the token exchange and the key binding stay one implementation — a second OIDC
client would be a second place for a security bug to live. The phone registers a
private-use scheme with the system rather than binding a loopback port, which on a shared
device any other app can do first.

The accessory key row needed TerminalWorkspace.SendInputAsync: ordinary typing goes from
the renderer straight down the socket, and there was no way in for the keys a software
keyboard does not have. Ctrl latches, because one thumb cannot chord, and the latch is
drawn — a modifier that is on and does not look on is how somebody sends ^L to a database
prompt believing they typed an l.

597 client tests green, including two new ones for the input path and one for the
terminal surface command. Nothing has run on a device.
2026-07-31 21:43:11 +02:00
jaap-jan 5cbda59a34 Merge branch 'main' into claude/host-management-ui-plan-7f20ab
Seven files needed a hand. Most were two branches adding something in the same
place, but three were one branch changing what the other had moved or renamed,
and those are the ones worth reading.

The shell keeps both new fields and both constructor lines: the connection
recorder this branch built and the teams view model main did. Where main put a
teams load inside OnScreenChanged, it now sits beside the logs refresh rather
than inside RaiseSurfaceState — this branch extracted that notification block
and it is called from two properties, so a screen-specific side effect in there
would fire on every terminal switch as well.

Main gave four row types a vault id and a vault name, and this branch had moved
one of them — KnownHostRowViewModel — into its own file when the pinned keys
became a screen. Git resolved that as "deleted here, modified there" and took
the delete, which compiles as long as nobody looks: the moved copy still had
the two-argument constructor and the call site had grown to four. Carried over
by hand, along with the ordering the pins list now does on them.

The status line's quiet rule was the subtle one. Main extracted it into
IsWorthReporting; this branch had changed the same condition to read item
counts rather than raw ones, because every user action queues a log entry a
moment later and this machine reads its own entries back on the next pull. Take
main's structure and the merge builds, passes, and silently restores a bug this
branch existed partly to fix — every save's message overwritten a second after
it appears. The method now reads PulledItems and PushedItems, with the reason
in its remarks.

Two conflicts were prose that had gone stale rather than code. The keychain
screen's comment said team vaults are refused by the server's access service,
which was true when it was written and is not now; main's replacement stands,
in this branch's vocabulary. The design-gaps row for groups was claimed by both
— real host groups here, per-vault headings there — and they are different
things, so both rows stay and the difference is stated: a group is a shelf the
user chose, a vault is who can read the item.

One defect the tests found and the compiler could not. Generating a key opens
the same editor as pasting one, but not through NewKey — so it never set the
target vault main added, and a generated key was filed into whatever vault was
edited last, or none. Both key-generation tests failed on it. Fixed where the
editor opens, with the reason recorded there.

One gap is left deliberately and is written down rather than half-built. Hosts,
keys, credentials and pins are read across every vault this session holds a key
for; groups are read from the active vault alone, so a host a teammate filed
shows under UNGROUPED. Nothing is lost or misfiled — it is what the sidebar
already shows for a group that has been deleted — but closing it needs a vault
id on every group row for rename and delete, and a way to tell two vaults'
identically-named groups apart under a layout with one heading per group. Both
are worth doing and neither is a merge's business. It is in the remarks on
ReloadGroupsAsync and in docs/design-import-gaps.md.

dotnet build, dotnet test and dotnet format --verify-no-changes are all clean:
1282 tests, including the end-to-end suite against real containers.
2026-07-31 20:44:39 +02:00
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 23eca3a21b Merge branch 'main' into claude/m3-implementation-57f9d7
ci / build and test (push) Failing after 2s
Three files conflicted, and two of the resolutions are more than a choice of
side.

QuickConnectTests had both branches fixing the same build break — main's M2
merge left the shell's constructor with an ISftpSessionFactory nobody passed.
Main's version wins because it carries a comment saying why the palette never
needs a session.

VaultSession's conflict is adjacent edits: main added the remembered sign-in
members and this branch changed SyncAsync's summary from "the active vault" to
"one vault". Both kept.

VaultViewModel is the one that matters. Main taught the background pass to
report a sync that had to start over, on the grounds that a machine which
silently re-read a whole vault has had something happen to it; this branch
turned a pass into one report per readable vault. Taking either side alone
would have lost the other, so ResyncedFromStart is now one of the conditions
IsWorthReporting checks, per vault.

Merging also broke something neither branch could have caught alone, and the
build would not have said a word. SyncOnceAsync cleared LastSyncFailed
unconditionally, which was right while a pass was one vault and a failure was
an exception that never reached that line. A failure is now a report — one
unreachable team vault must not stop the others syncing — so the flag was being
cleared over a vault that had just failed, lighting the titlebar SYNCED. It is
computed from the report instead, in the one place both callers go through, so
the manual command gets it as well as the loop. The background pass still
swallows the message and keeps the fact, which is what
AnAutomaticPassThatFails_LeavesTheStatusAlone is there to hold it to.

Two comments the auto-merge left describing a world with one vault in it: the
SCOPES rail's, which said team vaults are refused by the access service, and
the host sidebar's "One heading, for one vault".
2026-07-31 12:26:59 +02:00
jaap-jan 95816de0c5 Share a vault with a team, without the server holding a key
M3's teams, sharing and ACLs. Teams with roles, a public-key directory, the
append-only key log served for clients to check it against, team-owned vaults,
and vault key grants wrapped by a client and stored opaquely by the server.
VaultAccessService resolves team membership to PermissionFlags, so a viewer may
pull and may not push; the desktop client reads and syncs every vault it holds
a key for, and a real TEAMS screen replaces the one that said it did not exist.
No migration: team, team_membership, vault.team_id and vault_key_grant have all
been there since the first one, which is what carrying two unused tables bought.

Membership is authorisation. A grant is access. The obvious model is one
concept — "access", with a role attached, handed out by the server — and this
architecture cannot implement it: a vault key is sealed to each member's X25519
key, and only a client holding the plaintext can seal it for somebody else. So
"give Bob access" decomposes into a database write and a wrap, which happen on
different machines. Adding a member makes the server serve them the vault; it
cannot make it readable. VaultSummary.WrappedVaultKey is null in the meantime
and the vault appears in their list saying it is waiting for a key, because
hiding it until a grant existed would have been tidier and would have implied
the server was the thing granting access. The screen says the same thing after
every add, in the status line. ADR 0009 records the whole decision.

Sharing verifies or refuses. A directory lookup is a claim by the server about
a third party's public key, and wrapping to an unverified claim hands the vault
to whoever made it — no amount of transport security helps, because the server
is inside the threat model. KeyLogAudit reads the whole log, recomputes every
entry's hash from its own contents, checks the chain from genesis, and refuses
unless the offered key appears in it unchanged. There is no override flag: one
that exists gets used on the day the log is briefly unreachable, and the
resulting grant is indistinguishable from a correct one afterwards. What it
still cannot promise is that the key is the right person's, so the fingerprint
comes back for an out-of-band comparison and the success message says so every
time. A test corrupts the fake server's log by one byte and watches the client
refuse rather than warn.

The roles are only the ones that are enforceable. There is no ConnectOnly,
despite the design asking for one and TeamRole having room: SSH terminates on
the client, so a session needs the credential's plaintext on that machine, and
"may connect but may not read the key" cannot be enforced here. Shipping it as
an option in a dropdown would have been a lie. Connect rides along with Read
and is documented as an interface hint. Removal is named for what it does — it
revokes grants and flags the vault for rekey, and claims nothing about what is
already on somebody's laptop.

Three things are deliberately absent, and each is a refusal rather than an
omission. The rekey itself, because re-wrapping every item's data key under a
new vault key needs a client holding the current one; the server records that a
rotation is owed and the interface reports it, which is more honest than a
button that only appears to do it. Ownership transfer, because allowing an
owner to be removed without one leaves a team nobody can administer. And
cross-vault host key trust: a pin in a team vault is listed but not consulted
at connect time, because any member with Write could otherwise pre-approve a
fingerprint another member's client then trusts silently for a host in their
own vault. Scoping trust properly needs a scope on the SSH connect path, which
IKnownHostStore has not got; until then the narrow direction is the safe one
and the cost is in the README rather than hidden.

Reading now spans vaults and writing still does not. Every list on the vault
and hosts screens covers each vault the keyring opened, rows carry the vault
they came from, and an edit goes back to that vault rather than to the active
one — writing it to the active vault would fork the item and only show up when
a colleague wondered why their change never arrived. A new item goes wherever a
picker says, defaulting to the personal vault and never moving on its own,
because an item filed into a team's vault is visible to that team and moving it
back means deleting and retyping. The sidebar heading stops naming one vault
once there are two, and each row names its own.

The server checks what it can and nothing it cannot. It will not record a grant
for a key its recipient no longer holds, for a superseded generation, or for
somebody who is not in the team — each of those would otherwise surface days
later at the far end as a tag failure indistinguishable from corruption. It
does not verify the wrap or the signature, and the grant service says so: that
would be a convenience and never the boundary, and would put an asymmetric
implementation on a machine that is supposed to hold no keys.

Two bugs the tests found. TeamsViewModel's busy gate blocked its own reload, so
a team created a moment earlier was missing from the list it had just been
added to. And syncing every vault turned a failure from an exception into a
report, which made a background pass announce an unreachable vault once a
minute — the exact behaviour AnAutomaticPassThatFails_LeavesTheStatusAlone
exists to prevent. The fact is recorded and the message swallowed, as it was
before; pressing Sync still names the vault and the reason.

Also fixes a build break this branch started with: QuickConnectTests was never
updated when M2 added ISftpSessionFactory to the shell's constructor, so
nothing built at all.
2026-07-31 12:18:28 +02:00
jaap-jan 9608d73747 Come back from a sync position the server will not accept
ci / build and test (push) Failing after 2s
"The server returned 400: The sync cursor is not valid for this vault. Resync
from the beginning." told the user exactly what to do and gave them no way to
do it. The cursor is the only thing a pull sends, so the refusal was permanent:
the next pass read the same stored cursor and was told the same thing, once a
minute, for ever. And because the pull runs first, the exception ended the pass
before it reached the outbox — so the vault stopped receiving other machines'
changes and stopped sending its own. A machine that met this went quietly
read-only until somebody deleted its cache.

The engine now does what the message asks. A pull refused with the
invalid-cursor problem code — the code, never the prose, which is free to
change — drops this vault's position, writes that down, and reads the log again
from the beginning. The restarted request carries no cursor, which is the one
position a server cannot reject, so the retry cannot loop; a refusal of that is
rethrown rather than retried, and a restart is allowed once per pull. The
position is saved before the replay starts, so a process that dies halfway
through begins the next one from the beginning too rather than meeting the same
refusal again.

The mirror is deliberately kept. Replaying rewrites every row the server still
has and applying a change is a blind overwrite, so the re-pull repairs the
mirror on its way past; clearing it first would claim more than the evidence
supports — the position was refused, not the contents — and would leave a
machine that lost its connection mid-replay with less than it started with.
That leaves one gap, named in the remarks rather than left to be discovered:
once tombstone collection exists, a replay stops carrying deletions older than
the retention window.

None of the causes are the user's doing — a rotated cursor signing key, a vault
served from a restored database, a cache copied between machines — so nothing
asks them to decide anything. The report carries ResyncedFromStart and the
status line says the position was not recognised and the vault was read again.
It is kept out of NeedsAttention, because nothing is outstanding, but the
background pass breaks its usual silence for it: a sync that pulled the whole
vault on a day nobody changed anything otherwise reads as a fault.

The fake server grew a switch that refuses cursors the way a rotated signing
key does, including ones it minted itself. Three cases: the vault is re-read
and the change on the far side of the refused position arrives; the edits
waiting in the outbox are still pushed in that same pass, which is the half
that made this worth recovering from rather than merely reporting; and a server
that refuses the beginning itself is surfaced instead of replayed against.

dotnet build is clean at zero warnings, dotnet format is clean, and the sync
and app suites pass — 109 and 101.
2026-07-31 11:32:14 +02:00
jaap-jan 94e11f5e38 update packages
ci / build and test (ubuntu) (push) Canceled after 0s
ci / build (windows) (push) Canceled after 0s
2026-07-31 10:12:05 +02:00
jaap-jan d17a60e7c3 Stop asking the server to delete things it has never seen
Add a host on a laptop with no network, change your mind, delete it: the outbox
holds a tombstone for a row the server has never heard of, the push answers
Invalid, the change is parked, and the user is left looking at a rejected change
for an item they already deleted and a pending count that will never reach zero.
It applies to all four item types, because they all go through the one generic
repository — the known-host path is only the likeliest way to meet it, since
trust is pinned by connecting and withdrawn from the host editor.

DeleteAsync now drops the queued create instead, when the server cannot be
holding the item. A null expected version means the row is a create — including
a create that has since been edited, because coalescing keeps the original
expected version — so there is no server row and no mirror row, and dropping the
queued change makes the item genuinely gone.

The attempt count is what makes that safe rather than merely convenient. Nothing
sent cannot have landed. A parked row cannot have landed either, because parking
is what the pusher does when the server has refused, so the refusal is the
evidence — and a parked create that the user then deletes could not be got rid
of at all before this: the tombstone replacing it was parked in its turn. What
is left is a create that went out and whose answer was never seen. That one
still gets a tombstone, because the server may be holding the item and a local
drop would strand it there for ever. A refused tombstone is recoverable; an
orphan nobody can see and nobody can delete is not.

Eight tests, and the interesting half is the other direction. A repository that
quietly dropped tombstones would pass a suite written only around the bug and
would lose data on every machine but the one that pressed the button.

Which is not hypothetical, because the mutation pass found exactly that hole in
the first draft of these tests. Removing the expected-version guard left every
test passing: after a sync there is no queued row at all, so deleting a synced
item never reaches the shortcut and proves nothing about it. The way to hold an
unpushed Upsert over an item the server holds is to edit it offline, and
EditingASyncedItemOfflineAndThenDeletingIt_StillQueuesATombstone is the test
that was missing. Without the guard it deletes the item here, leaves it on the
server, and the next pull brings it back.

Three mutations, all caught now: removing the shortcut (5 tests), removing the
expected-version guard (1), removing the attempt-count guard (1). The
Upsert check itself is conservative rather than load-bearing — a queued Delete
with no expected version is not reachable from the interface, and completing one
locally would discard a tombstone that might be needed, so it stays and is not
independently covered.

106 tests green in Client.Sync, 8 of them new. Zero warnings, format clean.
2026-07-30 17:15:51 +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 e3fd3e1728 Sync and authenticate with SSH keys on the client
Completes the client half of SSH keys: they sync alongside hosts, appear in
their own list, and can be selected to authenticate a connection instead of
typing a password.

The reconciler and the repository were Host-typed throughout, so the choice was
to generalise them or to keep a second copy per item type. Generalised, because
ItemReconciler's whole premise is that the pull and the push paths must answer
the same collision the same way — two copies would drift the first time one of
them was fixed. What is genuinely per-type now arrives through
IItemKind<TSecret>: the cipher, the merge, the plaintext columns, and the noun
to use when telling a person what happened to their item. Generic where the
server's IItemKind is not, and for the reason that reverses there — the client
needs the concrete type, because it merges field by field.

The pull filter is derived from the same registry that builds the reconcilers.
That is the specific failure being designed out: an item type that encrypts,
merges and lists perfectly and is never once requested from the server, so it
works on the machine that made it and exists nowhere else.

No client cache migration. The item table's primary key and the outbox's unique
index already carry the entity type, and AadResourceTypes already mapped SshKey
— so a host and a key may share an id and never see each other's rows, which
SshKeySyncTests now arranges deliberately.

A key hands the server nothing in plaintext. There is a public_key_fingerprint
column and it would be accepted; leaving it null is deliberate. A fingerprint is
not secret but it is a stable identifier for a key pair, so filling it would let
an operator tell which of their users hold the same key and correlate one across
vaults, for a column nothing reads. The design allows itself one plaintext
concession — the relay address, which the relay cannot work without — and this
is not that.

A key is chosen per connection rather than bound to a host, which works the way
ssh -i does. Binding one needs a field on HostSecret and therefore a payload
schema bump, which makes every host written afterwards read-only on an older
build; worth doing deliberately rather than as a side effect of adding keys.

Three things this found, all of them by being falsified rather than by review:

- Making the reconciler generic silently turned a record comparison into
  reference equality, because == on a type parameter is not value equality. The
  effect would have been a conflict recorded on every pass for an unacknowledged
  create that had in fact landed. Sabotaging the fix left all 73 tests passing —
  nothing covered that branch — so ConflictMatrixTests now has
  AnUnacknowledgedCreateThatDidLand_IsDroppedQuietly, which fails without it.

- A test asserting that a blank passphrase reaches SSH.NET as null was vacuous:
  it exercised the editor, not the credential path, and passed with the guard
  deleted. Resolved by making SshKeySecret.Passphrase normalise an empty string
  to null, so there is one spelling of one state — which also keeps two clients
  from producing different payload bytes for an identical key. That exposed a
  wider gap: SshKeySecret, its codec and its merge had no direct unit tests at
  all. They have 25 now.

- The reason first given for that normalisation was false. It claimed SSH.NET
  rejects a passphrase supplied for an unprotected key; measured against a real
  sshd it ignores it and authenticates anyway. Corrected everywhere it was
  stated and recorded in docs/platform-flags.md. The same test file also closes
  a real hole: SshPrivateKeyCredential had never been exercised against a
  server, because the existing key test builds SSH.NET's auth method directly
  and bypasses the path a vault-held key actually takes.

Only one editor may be open at a time. Both sit in the same 340-pixel column as
Auto rows and their heights together exceed it at the window's minimum size, so
two open editors put the lower one's Save and Cancel past the bottom edge — the
same failure this window already shipped once with the setup screens. Expressed
as a state rule because that is the only form of it this repository can check:
nothing here loads a .axaml. The refusal keeps what was typed, since in the key
editor that is a pasted private key the user may have nowhere else.

The end-to-end slice now carries a key as well as a host, so both item types go
through the real API, the real PostgreSQL and the real crypto in one pass — the
three hand-kept mappings between enums that do not line up are the reason that
is worth doing rather than trusting the unit suites.

735 tests green, including the container-backed SSH and end-to-end suites. Zero
warnings, dotnet format clean.
2026-07-29 20:27:23 +02:00
jaap-jan c4dbd85da0 Add the client's SSH key model, codec, merge and cipher
The client can now seal and open an SSH key item. Nothing consumes it yet —
the repository, the sync engine's per-type handling and the UI come next — but
this is the layer everything above it depends on, and it is the layer where
the crypto has to be right.

SshKeySecret holds the private key as an ordinary string, deliberately, and
says so: a .NET string cannot be wiped, so the material lives until the GC
reuses the memory. libsodium's guarded memory was considered and rejected
because the passphrase protecting the key, the password on the next item and
the JSON the codec just parsed are all strings on the same heap — protecting
one field among them reads as security and buys nothing. What the design does
give is that the key never reaches the disk in plaintext, never reaches the
server at all, and is handed to SSH.NET through a MemoryStream so there is no
temporary key file to leak.

Validation refuses a public key by name. ssh-keygen writes two files whose
names differ by four characters, and pasting the wrong one otherwise produces
a vault item that looks fine and fails at connection time with an
authentication error that says nothing about which file you chose.

The merge redacts the private key and its passphrase from the conflict log.
A host conflict shows both values so the loser can be put back; doing that for
a private key would write the discarded key into a log that is designed to be
read rather than used and is deliberately retained after acknowledgement. Two
different private keys are not something anyone reconciles by reading them
side by side.

And the lesson worth recording, because it nearly shipped: the first version
of AadResourceTypeTests proved nothing. It checked that a key payload does not
open as a host and vice versa — true however both ciphers are misconfigured,
because Seal and TryOpen share one constant, so changing it changes both and
the round trip still works. Sealing every private key as if it were a vault
passed all twelve tests. The tests now open a sealed payload independently
through ItemKeys with the resource type named out of band, and that does fail
under the same sabotage. A test that only compares an implementation against
itself cannot catch a self-consistent mistake.

The trap it defends: SyncEntityType.SshKey is 3, AadResourceType.SshKey is 6,
because the crypto enum also carries None, User, Device and Vault ahead of the
item types. A cast between them is a specification violation that encrypts
cleanly and would only surface when another implementation refused the item.
2026-07-29 15:33:28 +02:00
jaap-jan 49f617b450 Wire the Avalonia shell to the vault
The host list now comes from the vault instead of from a form. A fresh
machine takes a server URL, signs in through the browser, enrolls, and
from then on opens with the passphrase alone.

DodoSSH.Client.Session is the composition layer: where a profile lives,
how it unlocks, and how a machine gets one. ClientPaths picks a
non-roaming per-OS directory — %LOCALAPPDATA% and never %APPDATA%,
because a SQLite cache that roams between two machines is a corrupt one,
and each machine's outbox is its own. SessionOpener needs no transport at
all and could not reach one if it wanted to; that is the offline unlock,
asserted rather than asserted about. A wrong passphrase, a stale KDF and a
grant revoked by a rekey are three different answers, because the remedies
are three different things and telling someone to retype a passphrase that
was never the problem is worse than saying nothing.

The shell's states are the onboarding story. The recovery code gets its
own state that cannot be clicked past: it exists for one moment, losing it
with the passphrase loses the vault, and there is no server-side reset by
design. It is dropped from memory on confirmation rather than merely
hidden.

Sign-in is a delegate over IVaultServer, so the whole state machine runs
in a test against an in-memory server — no browser, no identity provider,
no toolkit. The view models are plain observable objects, which is what
makes that possible. What it does not cover is whether the XAML binds to
the right names; that needs a rendered tree and Avalonia.Headless, and is
its own piece of work.

Three things found by doing it rather than by reading it:

- Pooled SQLite connections keep the database file open after the last
  context is disposed. On Windows that means locked, so the application
  could never replace its own cache — and a test could not clean up after
  itself, which is how it surfaced. Dispose now clears the pool.
- EF's SQLite provider puts the database in WAL mode, so the cache is
  three files. A comment in ClientCacheFactory claimed the opposite;
  reading PRAGMA journal_mode off a real launch settled it. WAL is the
  right mode here — a sync pass writes while the interface reads — so the
  comment was wrong on the merits as well as on the fact.
- Enrolling a device key with nowhere to keep the private half would put a
  wrap on the server nobody can open and make the device list claim this
  machine can unlock without a passphrase. Device binding is now optional
  and the shell declines it until the OS keystore is wired.

Verified on Windows: the client created %LOCALAPPDATA%\DodoSSH\cache.db
and migrated it on first launch, and msedgewebview2 held an established
connection to the data plane while the unlock overlay covered it — which
is the point of covering the WebView rather than collapsing it, since a
NativeWebView that is never laid out is never realised.

630 tests, up from 593. The recovery-code gate and the offline unlock were
each verified by breaking them and watching the right test fail.

Still to do for M1's actual definition of done: the manual run against the
real API and a real Keycloak. Credentials are not a synced entity type
yet, so a connection still asks for a password, and the interface says so
rather than implying otherwise.
2026-07-29 11:02:19 +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