Commit Graph
30 Commits
Author SHA1 Message Date
jaap-jan 0b49cfb3c6 Merge branch 'claude/api-fastendpoints-migration-020431'
ci / build and test (ubuntu) (push) Canceled after 0s
ci / build (windows) (push) Canceled after 0s
2026-07-31 10:10:22 +02:00
jaap-jan 9a76eced14 Give hosts and terminals their own screen, and the rest of the vault another
ci / build and test (ubuntu) (pull_request) Canceled after 0s
ci / build (windows) (pull_request) Canceled after 0s
Rebuilds the client's shell from an imported design: a titlebar and nav rail
it draws itself, real multi-session tabs over the one WebView, a Ctrl+K host
search, and a vault screen that merges keys, passwords and pinned host keys
into one table. Hosts left the vault column for their own screen beside the
terminal, which is what the design asks for and turned out to be the better
split anyway.

Two screens the design shows have nothing behind them yet — file transfer
and teams — and say so plainly rather than rendering invented data; every
other gap between the design and this build is recorded in
docs/design-import-gaps.md.
2026-07-31 08:39:37 +02:00
jaap-jan 9bc28f1c0f Move the API onto FastEndpoints, without moving the wire
Eight endpoints today, around sixty planned. The minimal-API shape — a static
class per area holding static local functions, route and policy and name
asserted in one fluent chain with the handler somewhere below it — has not hurt
yet, and would. A handler's dependencies are parameters rather than injected, a
group's RequireAuthorization sits far from the handler it governs, and there is
no type to hang an endpoint's own documentation on. FastEndpoints is one class
per endpoint, its route and authorization in Configure(), its handler a method
on the same type.

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

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

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

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

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

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

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

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

Not verified: the generated document's response schemas, which differ from
before — FastEndpoints contributes its own Produces metadata. Nothing consumes
the document yet, and MapOpenApi runs only in Development behind the fallback
policy. It needs pinning if ADR 0002's build-time artifacts/openapi/v1.json is
ever built.
2026-07-31 08:39:06 +02:00
jaap-jan f86791e817 Finish revoking a device, instead of half of it
ForgetDeviceAsync stopped this machine unlocking without a passphrase and left
the server's row exactly where it was, so the account went on listing a device
nobody could account for. ADR 0007 recorded that as a deliberate gap needing an
endpoint. This is the endpoint, and the two things that turned up behind it.

DELETE /api/v1/me/devices/{id}. The device row is not the dangerous half: a
kind=device wrap is the user's identity bundle sealed to a key somebody may be
holding, and that is what has to go. It goes on the foreign key's cascade rather
than a second statement, and RevokeDevice_TakesItsWrapWithIt asserts the cascade
rather than trusting the configuration to keep saying so.

Scoped to the caller's own account, which is the only authorisation check there
is. The id is an unguessable v7 GUID, but unguessable is not a permission —
without the scope one user could withdraw another's device key by pasting an id
they saw once, and the victim's next launch would ask for a passphrase with no
explanation. 404 rather than 403 for somebody else's device, so a stranger does
not learn the id exists.

Never refused for being the last device. ADR 0001 makes an enrolled device a
recovery path, so removing the last one does cost the user something — but the
machine being revoked is most likely the one they have just lost, and a server
that argued about it would be refusing the one request that has to work
immediately. The passphrase wrap is untouched either way, which
RevokeDevice_LeavesThePassphraseWrapAlone pins.

--- Two things found on the way ---

Registering twice from one machine left two devices on the account. The server
is idempotent on the public key, but the client generates a fresh key pair every
call and the keystore holds one — so the second registration orphaned a wrap
whose private half had just been overwritten, which is precisely the leftover
this change exists to remove. Registering now withdraws the previous device.
Found by a test that asserted the property and failed.

And the fakes were lying about it. FakeAccountServer's comment claimed the real
service's idempotence while handing back a fresh Guid on every call, which is
invisible until something revokes by id — at which point a test would be
revoking an id the server never issued, and passing. Both fakes now issue one id
per public key and drop the wrap with the device, as the cascade does.

--- Reachable at all ---

ForgetDeviceAsync had exactly one caller and it was a test, so "Stop unlocking
here" now sits in the account bar where "Use Windows Hello here" was. Its own
flag rather than the negation of that one: a machine with no TPM and a machine
that is already registered are both "cannot register", and only the second has
anything to take back.

No confirmation prompt, deliberately. The cost of pressing it by accident is one
passphrase and one re-registration; the cost of a dialog is a moment's
hesitation at the point somebody has realised a machine is in the wrong hands.

Offline it does the local half and says so rather than refusing. Whether this
machine may unlock itself is decided entirely by the local cache and the local
keystore — the unlock path never asks the server — so forgetting here is what
actually revokes, and "you are offline, so this machine will go on unlocking
itself" would be the worst available answer. DeviceRevocation.LocalOnly is what
the interface reports and the status line explains what is left to do.

The local half runs first for the same reason, and the keystore call is the
first thing in the method that can yield: on Windows it raises a consent dialog,
and a dialog wants the thread it was called from. That ordering is currently
load-bearing and shakier than it looks — see the open device-unlock hang.

Four mutations, all caught: dropping the user scope from the server query
(1 test), skipping the stale-device revoke on re-registration (2), skipping the
server call in ForgetDeviceAsync (2), and the earlier version of the client that
never called it at all.

930 tests green across 16 projects, 13 of them new. Zero warnings, format clean.
2026-07-30 17:33:31 +02:00
jaap-jan 573f5d5668 Keep the device key in the TPM, behind a consent Windows enforces
The last of ADR 0007's three pieces, and it does not implement what that ADR
originally decided — because writing it exposed a flaw in the decision.

The ADR said "a Windows Hello gesture gating a protected blob". That does not
deliver what the rest of the document claims for it: a gate inside the process is
not a gate. A store that showed a prompt and then read a DPAPI blob would be
bypassed by malware that skipped the prompt, read the file and called
CryptUnprotectData itself — which is exactly the attacker the whole decision was
made against, and exactly the reason DPAPI alone was rejected. The presence
requirement has to be a condition of using the key, enforced below the
application, or it is decoration.

So the device key is encrypted to an RSA key created in the Microsoft Platform
Crypto Provider — the TPM — under CngUIProtectionLevels.ProtectKey. Windows
requires consent to use that key, so the prompt is not something this code can be
talked out of showing. Malware can ask for the key; it cannot answer the dialog.
That is strictly stronger than the ADR described, and most of what option D was
being saved for: the wrapping key genuinely never leaves hardware. The X25519
device key still lands in memory to open the wrap, because DSH1 fixes that wrap at
a curve the TPM cannot do — the remaining gap, and now a smaller step than it was.

CngKey is in-box, so this needed no WinRT projection and no Windows target
framework. Which is worth stating plainly because the opposite was planned: the
piece was scoped as "where the Windows TFM lands", and it turned out a platform
guard on one class was enough. Client.App and its two test projects stay on
net10.0.

Two things were measured on real hardware rather than assumed, and the second
changed the shape of the work.

The platform provider works here and holds an RSA key — confirmed by creating and
deleting one before writing anything that depended on it.

And ProtectKey prompts at key *creation*, not only at use. The comment in the
first draft of this file said the opposite, with a confident explanation: sealing
uses only the public half, so it should be silent. It is not. CngKey.Create blocks
on a dialog, because the policy means "protect this key with a PIN" and Windows
asks the user to set that up there and then. Found by writing tests around save
and forget and watching the suite hang for ten minutes waiting for somebody to
type one.

That has two consequences worth knowing before touching this file. SaveAsync is
user-facing code — it belongs on a UI thread, behind a button somebody pressed,
never on a background pass. And almost nothing in the store can be covered
automatically: two tests remain, availability and the empty-blob case, both of
which provably reach no dialog. Disabling the UI policy to make the rest testable
would remove the one property worth having.

The interface offers two things and hides both where they cannot work. "Use
Windows Hello" appears on the unlock screen only when this machine has a cached
wrap and a keystore still willing to release the key; "Use Windows Hello here"
appears in the account bar only when the machine can keep a key and has not
already registered one, so it is spent once used. Absent rather than disabled, in
both cases: a greyed-out button on a machine that never had a TPM reads as
something broken, and the passphrase box beside it is not a fallback — it is the
ordinary way in.

Both unlock paths now share AdoptAsync rather than each opening the known-host
store, building the vault and starting auto-sync. The ordering in there is
load-bearing and a second copy would be a second chance to get it wrong.

The shell's tests drive a fake keystore. Not for speed: the real one prompts on
every save and load, so a suite using it would block forever. What the shell has
to get right is which buttons appear and what happens when one is pressed, and a
fake answers exactly that. It is shared from Client.Session.Tests by source link
rather than reimplemented.

882 tests green, 6 of them new. Zero warnings, dotnet format clean.

Not verified, and not verifiable here: the dialogs. Whether the consent prompt
appears at the right moments, reads sensibly, and returns to a usable window when
declined needs the application run by a person on a machine with a TPM. That is
the remaining half of outstanding item #7, and it is now the only thing between
this feature and being finished.
2026-07-30 15:17:30 +02:00
jaap-jan 7016ce36f1 Key the local cache to the identity, not to the door it was opened through
Groundwork for a device key, and a spec change rather than a feature. ADR 0007
records the decision it clears the way for: a Windows Hello gesture guarding a
protected blob, with the passphrase kept as a permanent fallback.

The reason that decision needed this first is that a device key cannot open a
session on its own. SessionOpener derived two things from the passphrase master
key — the bundle, and the local cache key — and a device wrap is
SealTo(device_x25519_pk), which yields the bundle and never computes a master key
at all. A device unlock could therefore have opened the identity and still not
read the cache it had itself written.

So LocalCacheKey now derives from the bundle: dsh1/localcache/v1 → v2, specified
in crypto.md §3.2. Every wrap that opens a vault ends up holding the bundle, so
every door reaches the same cache.

Extract-and-expand, not expand alone. Everything derived from the master key uses
HKDF-Expand directly, which is sound because an Argon2id output is uniformly
random over its whole length. The bundle's encoding is not — it opens with a
fixed 14-byte label and carries a version, a generation and a timestamp before
reaching any key material — so it needs the extract step to become a pseudorandom
key first.

Two consequences fell out, both improvements and neither the point:

- A passphrase change no longer discards the local cache. The bundle is unchanged
  by a re-wrap, so the cache key is too. Under v1 changing a passphrase silently
  orphaned every cached row and the next launch re-pulled the whole vault.
- Recovery-code unlock is fixed before it ships. It derives a different master key
  from a different secret and a different salt, so under v1 it would have had the
  same defect as the device path, and nobody would have noticed until it landed.

The cache becomes unreadable exactly when the identity is rotated, which is the
correct moment to discard it. Existing caches are discarded and re-pulled on
upgrade — already the specified behaviour for a stale cache, and the reason the
label is versioned rather than reused: a v1 cache must fail to open rather than
decrypt to nonsense.

One stated guarantee got weaker and now says so. crypto.md §10 claimed locking
meant "nothing on disk can be read again without the passphrase." Where a device
wrap exists that is no longer true, and it would have been untrue under either
candidate design — the alternative was storing a copy of the cache key in the
device blob, which is the same door with an extra key lying next to it. The
wording now points at ADR 0007, because what guards the device key is a platform
decision and not a property of this specification.

A golden vector was quietly lying, which is the part worth reading twice. The
"local-cache" entry pinned HKDF-SHA512-Expand over a fixed PRK — a construction
the cache key no longer uses. Regenerating it would have produced a green suite
describing a derivation this code does not perform. It is replaced by a vector
over a bundle whose every byte is pinned: the label, version 1, generation 1, a
fixed timestamp and two recognisable key scalars, all visible in the fixture so a
second implementation can check itself against it. UserSecretBundle.TryDecode is
internal for this, because Create draws fresh randomness and so can never produce
a reproducible input.

Mutation tested, and this one earns its keep: dropping the extract step now fails
CommittedVectors_MatchCurrentImplementation. The vector it replaced could not
have caught that, because it never touched the bundle at all.

One test became false and says so. ARecordSealedUnderAnotherPassphrase is now
ARecordSealedByAnotherIdentity: a different passphrase deliberately no longer
changes the cache key, and TheLocalCacheKey_SurvivesAPassphraseChange pins that.
What must still be unreadable is another user's cache. CacheHarness therefore
generates an identity rather than deriving from a passphrase, and has no
passphrase parameter left — the cache key is not a question about passphrases any
more.

SyncHarness's two simulated machines now derive the same cache key, which is what
keying on the bundle means: they are the same user holding the same identity. They
still have separate cache databases, so nothing is shared between them but the key
that would open either. Both harnesses lost a MasterKey field that existed only to
make a protector.

858 tests green. Zero warnings, dotnet format clean.

Not done: the device key itself. Three pieces remain, and the middle one was a
discovery rather than a plan — EnrollmentService.AddDevice runs only during
enrollment, so every already-enrolled account, which is all of them, needs an
endpoint to add a device wrap while unlocked. The client proves possession by
producing the wrap, so that shape falls out of the crypto. After that: the
protector seam with the wrap cached locally for offline unlock, then the Hello
implementation and the unlock-screen UI, which is where the Windows TFM lands and
where automated testing stops.
2026-07-30 12:46:55 +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 586cb303d5 Merge branch 'claude/gallant-brahmagupta-1f8244'
ci / build and test (ubuntu) (push) Has been cancelled
ci / build (windows) (push) Has been cancelled
Writes down that locking the vault leaves shells running, and shows the count on
the unlock screen rather than leaving it to be inferred.

Conflict resolution:

- ShellFlowTests' fixture keeps main's FakeSshConnectionFactory. The branch added
  an IdleSshConnectionFactory for exactly what main's fake already does — a shell
  that is open, silent and never closes on its own — so FakeSshConnections.cs is
  dropped rather than merged, leaving one fake SSH stack in the suite instead of
  two that would drift apart.
- MainWindowViewModel and TerminalWorkspace: both sides added their own members,
  so both are kept.
- TerminalWorkspaceTests was added by both branches, with the renderer gate on
  one side and session lifetime on the other. Merged into one class over one set
  of helpers; the gate tests now use FakeConnectionFactory rather than an
  NSubstitute stub, since the suite already has the fake. gallant's polling
  Timeout constant is PollTimeout, which no longer reads as the renderer's.
- platform-flags.md keeps main's measured focus section and drops the short
  "nothing hands the terminal keyboard focus" entry the branch still carried,
  which that section supersedes.

One genuine disagreement between the branches, left visible rather than
flattened: this branch measured that a collapsed WebView cannot be typed into and
attributed it to a hidden WS_CHILD window being ineligible for keyboard focus,
while main's focus work measured Win32 focus still held by that hidden window and
added a lock path that moves the keyboard off it. Both results stand; the
mechanism sentence now defers to the focus entry, which makes the input barrier
something the lock path maintains rather than something the platform guarantees.

Full suite green, including the container-backed SSH tests.
2026-07-29 15:46:41 +02:00
jaap-jan 74341d41e0 Merge branch 'claude/distracted-ritchie-53fc70'
Bounds the renderer wait, so a WebView2 that never initialises reports itself
instead of hanging Connect with the busy flag stuck.

Conflict resolution, all of it in the App test suite, which main had changed
under the branch when sleepy-chebyshev landed:

- The workspace fixture keeps main's fake SSH factory and its FakeRenderer-aware
  page, and takes the branch's RendererTimeout on top. One second rather than
  the branch's 250 ms, because the timeout now also bounds FakeRenderer's own
  wait for the attach it just made.
- FakeRenderer arrived on main after the branch was cut and still called the
  no-argument WaitForRendererAsync. Both sides merged cleanly and left the build
  broken; it now passes its own token.
- ConnectingWithNoRenderer's remark claimed the suite never starts the workspace
  and never attaches a renderer. Both are false here, so it now says what is
  true of the test: it is the one connect test that attaches no renderer.
2026-07-29 15:40:25 +02:00
jaap-jan 9270d0cba5 Merge branch 'claude/sleepy-chebyshev-cda68d' 2026-07-29 15:35:02 +02:00
jaap-jan d459dac600 Stop a dead WebView2 hanging Connect with the busy flag stuck
VaultViewModel.ConnectAsync awaited TerminalWorkspace.WaitForRendererAsync
with no timeout and no token, and RunAsync clears IsBusy only after the
work returns. Whether the renderer attaches at all depends on a runtime
this application does not install: with a missing or policy-blocked
Evergreen runtime, or an AppContainer that cannot reach loopback, the
socket never arrives — so Connect never returned, the window stayed
disabled on "Connecting…" for the rest of the session, and nothing on
screen said why. Left out of 0500e43 to keep that change focused, and
recorded in docs/platform-flags.md as worth fixing on its own merits.

The gate itself is unchanged and has to stay: TerminalDataPlane.SendAsync
drops frames when no renderer is attached rather than queueing them, so a
session opened before the renderer arrives loses its SessionOpened frame
and then streams output at a terminal that was never created. Only the
wait changed — RendererAttached.WaitAsync(timeout, cancellationToken),
with the command's own token threaded through.

Fifteen seconds, on TerminalWorkspaceOptions.RendererTimeout. Attaching is
normally near-instant, since WebView2 starts with the window and the page
has usually attached while the passphrase was still being typed, but a
first run on a cold profile creates a user-data directory and starts a
process tree of some thirty-five processes first, which on a loaded
machine is seconds rather than milliseconds. A renderer that will never
attach will not attach however long the wait is, so being generous costs
only how long a broken runtime takes to say so, while being tight costs
telling someone their runtime is broken when it was merely slow.
Injectable because both new tests would otherwise sit out that budget.

The timeout is caught in VaultViewModel rather than left to RunAsync's
generic handler, because TimeoutException.Message is "The operation has
timed out" — which sends someone looking at their network or their host.
The status now names the WebView2 runtime and says to install it.

TerminalWorkspaceTests covers the half that was missing: the wait gives up
(329 ms against a 250 ms budget) and obeys its token (2 ms against a
five-minute one). Before the bound, the first of those would have hung
rather than failed. ShellFlowTests never starts its workspace, which from
the view model's side is indistinguishable from a WebView2 that failed to
initialise, so it asserts that the status names WebView2 and that IsBusy
is cleared; changing the catch to another exception type makes it fail
with "The operation has timed out.", so neither assertion is vacuous. The
success path is untouched and still covered end to end by
TerminalEndToEndTests against a real sshd container, which now passes the
test's cancellation token.

One byproduct: the doc comment on WaitForRendererAsync carried two
double-encoded em dashes, fixed now that the block is rewritten.
2026-07-29 15:26:53 +02:00
jaap-jan 5a899afd78 Decide what Lock does to a running shell, and say it
Pressing Lock nulled and disposed the vault view model and touched nothing else.
TerminalWorkspace is injected from App.axaml.cs and outlives every lock, so the SSH
connection, the pty and the pump all kept running while the window said "Unlock your
vault" — and since 0500e43 collapsed the WebView while locked, that live session was
invisible as well as unstopped. CloseSessionAsync was reachable in production only from
DisposeAsync, i.e. shutdown. None of this was written down anywhere, so it was neither a
policy nor a bug, which is the actual problem.

Shells now deliberately outlive the lock, and every layer says so.

The reason to prefer this over making Lock a disconnect: locking is what a person does
when they walk away from the machine, which is exactly when a long upgrade, build or
transfer is most likely to be in flight. Ending every shell would make Lock a button that
destroys work, and the predictable response is to stop pressing it and leave the vault
open instead. The idle auto-lock this will grow decides it outright — an unattended
timeout that killed a running job would be worse than the exposure it removes. Closing
the channel also buys less than it looks: the session was authorised at connect time by a
credential the remote verified itself, and no vault key participates in keeping it alive,
so locking cannot retroactively un-authorise it any more than removing a member can.

Stated honestly rather than implied, because the lock screen is what hides it:

- The unlock screen shows how many shells are still connected, and that locking closes
  the vault and not the connections — so a machine still holding authenticated SSH
  channels does not present itself as merely "locked". Shown only when there is something
  to disclose. Quitting is what ends them, and the text admits that.
- The Lock button carries the same thing in a tooltip, since its name implies the
  opposite of what it does to a shell.
- README lists it as a third architecture consequence beside non-retroactive revocation,
  which is the same shape of honest limit; docs/crypto.md §10 records it as a threat-model
  boundary; TerminalWorkspace and LockAsync carry the argument next to the code.

LiveSessionCount deliberately does not count dictionary entries. Nothing removes a
session when the remote closes the channel by itself — RunSessionAsync only drops the
renderer registration — so sessions.Count would report a shell that exited half an hour
ago as still running, on the one screen where a user is deciding whether it is safe to
walk away. A completed Run task is what "the shell is gone" actually looks like. While
locked the number can only fall, since opening a session needs the vault, so a stale
value over-reports rather than under-reports.

Both new tests fail when the policy is reverted: the count test times out against
sessions.Count, and the shell test reports "workspace.LiveSessionCount should be 1 but was
0" when Lock closes sessions. ShellFlowTests also stops building its workspace with a real
SshNetConnectionFactory that nothing ever called, which had made the suite's independence
from the network a coincidence rather than a property.

Verified by hand with a live shell, which nothing had done: a harness mirroring
MainWindow.axaml's 340,* grid with a real NativeWebView, the shipped WebAssets, a real
sshd in a container, and an ISshShellSession decorator recording every window-change the
remote is actually told about. Across lock and unlock, no window-change reached the remote
at all, stty size answered 50 118 before and after, the renderer's own buffer came back
byte for byte with the wrapped line intact, and the session stayed live throughout. A
control run that never hides the WebView behaves identically, so nothing above is startup
or idle behaviour. Keystrokes injected while locked reach nothing: twelve of twelve
SendInput events accepted with the harness confirmed as the foreground window, no probe
character in the remote's output, and a following Ctrl-U answered BEL, so nothing was
queued in the line editor either. A hidden WS_CHILD window is not eligible for keyboard
focus, which is what makes surviving the lock defensible rather than merely convenient.

Correction to a claim made in f80b3d4: terminal.js's guard comment listed "a host that
hides the WebView while the vault is locked" among the paths that reach a degenerate fit.
It does not. Collapsing the control hides a native child window without resizing it, so
the page still reports paneWidth 840 and paneHeight 760 with unchanged cols and rows, no
ResizeObserver callback fires and the fit never runs. Establishing that rather than
assuming it: the same cycle with MINIMUM_FITTABLE_PIXELS patched to 0 — the guard fully
disabled — is equally clean. The guard is still right for minimising and for a splitter
dragged to the edge; it is simply not what makes locking safe, and must not be cited as
though it were.

Recorded, not fixed:

- Nothing closes one terminal from the interface, so a user reading "1 shell is still
  connected" can only act on it by quitting. CloseSessionAsync is tested and correct;
  VaultViewModel discards the session id it would need.
- A session whose remote exits keeps its ISshConnection, and the thread ShellStream parks,
  until the process ends.
- Suspected and seen once: before the harness waited for the window's scale to settle, a
  DPI settle pushed a 2202x1328 pane for a window 1180 logical units wide and a later
  re-push reflowed the wrapped line. Three later runs at RenderScaling 1.00 never showed
  it, so it is filed as a lead, not a finding.
- WebView2 fails to initialise with CO_E_SERVER_EXEC_FAILURE when the host executable
  sits under a very long path. Cost an hour on the harness; relevant to packaging.
2026-07-29 14:44:02 +02:00
jaap-jan dbddbcd711 Hand the terminal the keyboard on connect, and take it back on lock
After a successful connect the first keystrokes went to the shell's UI rather
than the remote shell. The page's own term.focus() focuses the textarea inside
the document, which does nothing while the window's keyboard focus is still on
the Connect button, so the terminal had to be clicked before it would accept
anything.

The obvious guess about the fix — that reaching a native child window needs
SetFocus through P/Invoke — is backwards, and measuring it first is what kept
this small. NativeWebView overrides Focusable to true and its OnGotFocus calls
the adapter's Focus(), which on Windows is
ICoreWebView2Controller::MoveFocus(PROGRAMMATIC). So a plain Avalonia
Terminal.Focus() really does move Win32 focus into WebView2. Measured in a
standalone harness with no DodoSSH code, on the same 340,* grid as the shell,
reporting GetFocus() and the page's own document.hasFocus() at each step: focus
lands on the Chrome_WidgetWin_1 child and the page reports hasFocus: true.

It is the return trip the package does not implement. OnLostFocus calls the
adapter's ResignFocus(), and on Windows that method body is empty, so Avalonia's
focus and Win32's diverge: after textBox.Focus() the focused element is the text
box while the keyboard is still on WebView2 — a caret that silently receives
nothing. Window.Activate() and Window.Focus() were both measured and neither
recovers it, so the hand-back is a SetFocus on the top-level, in
Views/NativeKeyboardFocus.cs. A real mouse click does recover it, because
Avalonia's window sets focus on pointer input, which is why this is invisible to
anyone who clicks before typing.

That turned up a worse defect than the one being fixed, and it shipped in
0500e43. Collapsing the WebView does not release the keyboard: focus stays on
the hidden holder — measured held by a window reporting visible=False — while
Avalonia's focused element becomes (none). So a user who had clicked the
terminal and then pressed Lock got an unlock screen that swallowed the
passphrase. Locking now hands the keyboard back and focuses that box.

Ctrl+Shift+F6 is the way out for someone using only a keyboard. It has to be
handled in terminal.js and posted to the host as a web message, because once the
child window owns Win32 focus Avalonia receives no key events and no KeyBinding
could fire; the package also subscribes MoveFocusRequested and discards it, so
there is no Tab-out to lean on. Not Escape, which vim alone rules out, and not a
bare F6, which TUIs bind — Ctrl+Shift is the range terminal emulators
conventionally keep for themselves and never forward to the remote. Verified
rather than assumed: the posted string arrives verbatim in Body, and the chord
reaches the page as F6 with both modifiers.

Order matters and is now recorded. Focus() on a collapsed control is a measured
no-op and is not replayed when it is revealed, so focus survives a lock/unlock
cycle only because a session can be opened solely from an unlocked vault, which
is what reveals the control in the first place.

The view models still reference no view. VaultViewModel raises SessionOpened on
the success path only, the shell forwards it as TerminalSessionOpened through the
generated OnVaultChanged hook so unlock, lock and dispose all attach and detach
in one place, and the view holds the whole focus policy. An event rather than a
bound flag because connecting a second host while one is open has to move focus
again, and no state change describes that.

Three tests, and what they do not cover is the point. They cover the plumbing:
focus is asked for once per session, a failed connect does not ask at all — a
host-key prompt needs the keyboard on its own buttons — and locking stops the
forwarding. They cannot cover the focus call, because headless Avalonia has no
native window, so a headless test would focus correctly and confirm the wrong
belief; that is measured in the harness and written down in docs instead.
Dropping the forwarding fails two of them and dropping the detach fails one;
deleting the raise outright does not compile, since the event would be unused.

Reaching the connect path at all needed two new fakes. FakeRenderer attaches the
way the real page does — fetch the served page, read back the token and socket
URL the host substituted into it, then open the socket with both subprotocols —
rather than being handed the token, so the part of the handshake that has been
got wrong before stays under test. FakeSsh replaces a factory that would need a
reachable sshd, which DodoSSH.Client.Ssh.Tests already covers against a
container. The suite also never called workspace.Start(), so nothing served the
page and no renderer could have attached.

DllImport rather than the source-generated LibraryImport, which requires
AllowUnsafeBlocks for the whole project. The signature is blittable so there is
no marshalling stub to improve on, and turning unsafe code on across a client
that handles key material to gain nothing is a poor trade.

Correcting an earlier entry: docs/platform-flags.md described this as a
focus-plumbing gap and offered "click inside the terminal first" as the
workaround. Both true, and both stop short of the half that matters — focus
crosses into the WebView readily and never comes back on its own, which is the
same mechanism as the text boxes that mysteriously stopped accepting keystrokes
in the airspace entry above it, not a separate fault.
2026-07-29 14:31:09 +02:00
jaap-jan ea271d980a Give the realm's users their roles, and sign in as one in the E2E suite
Signing in failed at the token exchange with `400 Offline tokens not allowed
for the user or client`. A user declared in a realm import gets no role
mappings at all unless realmRoles lists them — not even the realm's own
default-roles composite, which Keycloak grants automatically to a user created
through the admin API or the registration form. alice and bob had none, and
offline_access lives inside that composite, which the desktop client requests.
Verified against the running Keycloak: alice's role-mappings were {} before and
resolve to default-roles-dodossh, offline_access, uma_authorization after.

The authorization request succeeds and the failure lands one step later, at the
code redemption, which makes it read like a client bug. It is not.

The E2E suite could not catch this because it created its own account through
the admin API — exercising a provisioning path no real user takes, and passing
while the account the README tells you to use could not sign in at all. It now
signs in as the realm's own alice, which is sound because the Keycloak and
PostgreSQL containers are per-run so the account is pristine, and this assembly
holds one test. Removing the roles again fails it with exactly the reported
message; that is what makes the coverage real rather than nominal.

Two traps recorded in docs/platform-flags.md, the second found by shipping it
for a moment: Keycloak's RealmRepresentation deserialises with
FAIL_ON_UNKNOWN_PROPERTIES enabled, so the "_comment" key I first used to
explain the roles inside the JSON did not get ignored — the import threw and
the container refused to start. Explanations go in the docs, not in the realm
file.
2026-07-29 14:11:16 +02:00
jaap-jan f80b3d4351 Harden the WebView collapse, and replace its evidence with a measurement
An adversarial review of 0500e43 did not refute the fix but closed the gap I
had left open and found three hazards around it. A standalone spike — a 60-line
Avalonia app with no DodoSSH code — reproduces the airspace bug on a 340,* grid,
and a second harness mirroring the data plane's handshake measures what I had
only reasoned about: with IsVisible=false set before the window is ever shown,
the adapter is created, the page is fetched and the WebSocket 101 is sent, with
frames arriving over the socket while hidden. A cold WebView2 profile behaves
the same. Revealing recomputes bounds in about 7 ms.

So the docs no longer cite "35 msedgewebview2 processes" as the confirmation
that the renderer attaches. A process count cannot show that a socket was
accepted — the same shape of mistake, one level down, as the one that entry was
already correcting. It now cites the handshake, quotes Avalonia's maintainer on
airspace being by design, and links the still-open upstream issue.

Three changes to the fix itself:

- terminal.js skips the fit below 40px in either axis. The vendored fit addon
  floors its proposal at 2 columns by 1 row rather than refusing, so a
  degenerate viewport reflows the *remote* pty through window-change and
  mangles wrapped scrollback unrecoverably. Reachable today by minimising, and
  by dragging a splitter to the edge once splits land — a guard where the sizes
  arrive, not a special case for one caller.
- FallbackValue=False on the binding. A compiled binding with no DataContext
  yields UnsetValue, IsVisible falls back to true, and the occlusion returns
  silently. Not reachable at runtime; it is what the previewer does.
- The comment now says why it must be IsVisible on this control: detaching
  destroys the native control and the whole WebView2 process tree, so
  conditional content would pay a cold start per unlock, and hoisting the
  binding to an ancestor is unverified because NativeWebView's own
  bounds-and-scaling re-push fires only for its own IsVisible.

Also recorded, not fixed: hiding does not suspend the page (visibilityState
stays "visible" and rAF keeps firing at ~115/s, which is *why* the handshake
completes while hidden); the conflict log can squeeze the terminal row toward
nothing; and nothing hands the terminal Win32 focus after Connect, so the first
keystrokes go to the shell's UI rather than the remote shell.
2026-07-29 13:42:23 +02:00
jaap-jan 7226e70b8a Record that a sub-path server URL is silently dropped
Found while sweeping for the stale default. The client uses the typed address
only as HttpClient.BaseAddress and every request path is root-absolute, so
https://example.test/dodossh reaches https://example.test/api/v1/... with the
prefix discarded and no error — which rules out hosting under a sub-path, the
usual arrangement behind a proxy fronting several services. The server
already publishes a canonical apiBaseUrl the client could normalise against
and ignores.

Recorded rather than fixed: it is a deployment-shape decision, not a bug in
the screen that prompted this.
2026-07-29 13:27:12 +02:00
jaap-jan 0500e43e02 Stop the terminal's WebView painting over the setup screens
The shell layered its setup and unlock screens over the terminal, which does
not work: NativeWebView attaches a real Win32 child HWND through
NativeControlHost, and a child window composites above everything its parent
paints regardless of visual-tree z-order. The cards rendered sliced at the
terminal column's left edge; at the window's default width every one of their
buttons fell inside the WebView's rectangle, so the flow could only be
completed by keyboard, and a click in that region handed Win32 focus to
WebView2 so the text boxes silently stopped accepting keystrokes.

The WebView is now collapsed while the vault is not unlocked. The comment
that previously forbade this — hiding it means never realising it — was
wrong: NativeControlHost creates the native attachment on attach to the
visual tree, never consulting layout or visibility, and NativeWebView replays
a Source assigned before its adapter exists. A collapsed WebView still starts
WebView2, loads the page and lets the renderer attach. Confirmed: 35
msedgewebview2 processes with the control collapsed. What the first
connection after unlocking actually depends on is the existing await on
WaitForRendererAsync, since the data plane drops frames when no renderer is
attached.

Also fixes the second visible defect: the default server URL was
https://localhost:7217, the API's *second* launch profile, while the README,
its appsettings and a plain `dotnet run` all use http://localhost:5233 — so
nothing was listening, and an HTTPS client against a plaintext port reports
"The SSL connection could not be established", which reads as a certificate
problem. The default now matches, a missing scheme is rejected by name
instead of parsing as scheme "localhost", and that specific TLS failure now
suggests http://. Both new tests fail when the fixes are reverted.

Corrections to claims I made earlier and should not have:

- docs/platform-flags.md asserted the opposite of the mechanism above and
  cited an established msedgewebview2 connection as verification. That
  observation was taken while the overlay was showing but, because of this
  very bug, the WebView was uncovered and in plain view — so it confirmed
  only that a visible WebView is realised. A process-level check cannot
  verify a rendering claim. The entry was also filed under "Local cache".
- ITerminalHost was documented as the live seam the app plugs into, with a
  stub standing in for headless tests. It has no implementation anywhere and
  no test uses it; the view navigates the control directly. It also counted
  Avalonia.Controls.WebView and NativeWebView as two interchangeable
  backends when they are one component, with the Linux backend backwards.
- The README claimed the shell's whole path was covered by tests. Its state
  machine is; its layout is covered by nothing, and a headless test could
  not have caught this — headless has no native window, so it would have
  rendered correctly and confirmed the wrong belief.

Verified by screenshotting the running app: the card renders complete and
centred at the default size, with the button clickable.
2026-07-29 13:26:30 +02:00
jaap-jan 34304b989b Make the end-to-end suite self-contained with Testcontainers
It needed a hand-started stack and an opt-in flag, so it ran on one machine
and never in CI. It now brings up PostgreSQL, Keycloak and an OpenSSH server
itself, applies the committed migrations and starts the API as a child
process, which makes it part of the ordinary test run at ~25s.

The API runs as a process rather than through WebApplicationFactory. The
client builds its own HttpClient for a URL the user typed, so there is no
seam to hand a test handler through without inventing one that exists only
for tests — and a test host would replace the entry point, Kestrel and the
content root, so it would never prove that Program.cs composes or that the
committed appsettings is found and layered in the documented order. Running
out of the API's own output directory is what makes its configuration real.

The suite still consumes what ships: the realm file from deploy/keycloak,
the EF migrations, the API's own appsettings. Only Oidc:Authority is
overridden, because the container's port is assigned at start. Falsified by
reintroducing the wildcard-port redirect URI the realm once had — Keycloak
rejects the authorization request and the suite fails at sign-in, which is
what proves the committed file is the one imported. Skipping the migration
step likewise fails, and the failure names the pending migration.

A fresh Keycloak per run also sidesteps the --import-realm trap: editing the
realm file and rerunning now always tests the edit.

DodoDbContextFactory gains a Create(connectionString) so the fixture and
dotnet ef place the migrations history table in exactly one place. If they
disagreed the API would report every migration pending, which is how the
readiness gate catches it.
2026-07-29 12:09:15 +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 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
jaap-jan a878c2b6bb Add the server client and client-side enrollment
A typed client over DodoSSH.Contracts, and the orchestration that turns a
passphrase into an enrolled identity: generate keys, have the identity
provider sign over them, wrap the bundle three ways, create the personal
vault, publish.

Ordering here is forced, not chosen. The secret bundle's AAD binds to the
server-assigned user id, so /me has to be read before anything can be
wrapped -- which is exactly why /me provisions the account and returns its id
even while reporting that enrollment is required. That constraint was
designed into the server earlier; this is the first code that depends on it.

The grant tuple now has a real canonical encoding (crypto.md 7.3) rather
than the placeholder signature I would otherwise have had to invent and then
keep. §7 named the tuple without specifying how to encode it; this fills that
in with the same conventions as 7.1, and the self-grant at enrollment is
already in its final format. The signature covers SHA-256(wrappedKey) rather
than the key, so a verifier can check attribution without holding the vault
key at all.

The most valuable tests are the negative ones about the request body: the
server is meant to be unable to read what it stores, and a refactor that put
a passphrase or a private key into the enrollment request would be invisible
to every other test in the repository. So one asserts the body contains
neither the passphrase, the recovery code, nor any private key in base64 or
hex. Another opens the same bundle three ways -- passphrase, recovery code and
device key -- which is what makes a passphrase change a one-row update.

ClientEnrollment depends on IKeyBindingAuthorizer rather than the whole
OidcClient. It needs exactly one capability, and depending on the full client
would drag discovery and token exchange into every test of key binding.

Two things fixed while building it. The recovery code buffer was sized one
separator short, so every enrollment threw IndexOutOfRange -- caught
immediately because nine of ten tests failed identically. And the crypto
enum collided with Domain.GrantKind in the server, so it is GrantPurpose
there; the numeric values still have to match, which the doc and a test both
say.

448 tests pass, zero warnings on a clean rebuild, format clean.
2026-07-28 22:42:56 +02:00
jaap-jan 5fccd53824 Add the Avalonia app and the xterm renderer, and fix two real bugs
The terminal works end to end. A new integration test drives a real sshd in
a container through a real PTY, the real pump, the real loopback WebSocket
with its token and origin checks, and a ClientWebSocket standing in for the
page: the login banner arrives, typed input round-trips, and `stty size`
reports the 100x30 the session asked for. The only untested link left is
xterm drawing bytes it was handed.

The WebView is de-risked on Windows, which was the plan's largest risk. Not
by assertion: with the app running there is an established TCP connection
from msedgewebview2 to the data plane port, so WebView2 launched, navigated
to the loopback page, executed terminal.js, and completed the WebSocket
handshake against the real token and origin checks. Linux remains unproven
and the package's own release notes now corroborate the concern -- Linux uses
a WPE backend, and it ships a NativeWebDialog described as useful where
embedded WebViews may be unavailable.

Two bugs found by building it, both of which would have shipped:

- ShellStream.Write buffers and needs an explicit Flush. Without one a
  keystroke is accepted, reported as written, and never reaches the remote:
  the terminal displays output perfectly and simply stops responding to
  input. SSH.NET's own WriteLine flushes, which is why the earlier spike
  never hit it. Found by isolating the pump against real SSH and reading
  BytesRead=51 -- banner and prompt through, nothing after.
- The Windows app manifest needs a supportedOS list, or Avalonia's native
  control host fails outright and the terminal never starts.

Also fixed a genuinely flaky test I happened to catch: SyncCursorTests
tampered with the *last* base64url character, whose low bits the decoder
ignores when the input length is not a multiple of three -- so a tampered
cursor sometimes decoded to identical bytes and verified. It failed roughly
one run in thirty, depending on a random key. Now tampers the penultimate
character, which is fully significant at every length; 40 consecutive runs
are clean.

xterm 6.0.0 plus the fit and webgl addons are vendored as UMD bundles rather
than built with npm, so a clean clone needs only the .NET SDK. Provenance
and licences are recorded next to them, along with the UMD global names
terminal.js depends on -- a bundle that switched to ES modules would load
without error and leave Terminal undefined.

The renderer acknowledges output from term.write's completion callback, not
on receipt. Acknowledging early would return flow-control credit for bytes
the screen has not caught up with, which is the one thing the credit window
exists to measure.

TerminalWorkspace moved into DodoSSH.Client.Terminal: it has no Avalonia
dependency, and having it there is what let the end-to-end test exist at all.

404 tests pass, zero warnings on a clean rebuild, format clean.
2026-07-28 22:30:42 +02:00
jaap-jan 94f66be5e8 Add the OIDC client: PKCE loopback sign-in and the key binding flow
Authorization Code with PKCE on a loopback redirect, per RFC 6749, RFC 7636
and RFC 8252. Zero package references: the flow is fully specified, and the
one thing a library would own for us -- nonce generation and validation -- is
exactly what the key binding needs to control. Duende's OidcClient generates
and validates its own nonce as an internal detail, and the binding requires
the nonce be a specific value: the hash of the key statement being enrolled.
Fighting that is worse than owning the flow.

AuthorizeKeyBindingAsync is the client half of the primary trust anchor. It
runs a second authorization with nonce set to the statement hash and
prompt=login, so the ID token that returns is the provider's signature over
exactly those public keys, attesting to a user present now rather than to a
session opened at some unknown earlier time. It requests only openid -- a
second refresh token would be one more long-lived credential for no benefit
-- and rejects a token whose nonce is not the one it asked for, because
enrolling that would store evidence verifying against keys we are not
publishing.

The nonce is read without validating the ID token's signature. Sanctioned by
OIDC Core 3.1.3.7: for a token received by direct communication with the
token endpoint, TLS server authentication may stand in for signature
checking. That reasoning does not extend to another user's binding, which
arrives via the DodoSSH server and must be verified against JWKS fetched
directly -- the directory work in M3.

Raw TcpListener rather than HttpListener for the redirect: an ephemeral port
can be bound and read atomically instead of picking one and hoping it is
still free, there is no HTTP.SYS URL-ACL question on Windows, and the whole
surface is one request line. It answers 404 on other paths and keeps
waiting, because a browser asks for /favicon.ico first and treating that as
the callback would abort every sign-in. 127.0.0.1 rather than localhost: RFC
8252 permits either, but the name resolves through the hosts file.

20 tests, driving the real listener over TCP with a fake browser that
actually fetches the redirect -- injecting a fabricated callback would skip
the parsing, path filtering and response writing that can break. Mostly
negative, because the loopback port is reachable by every local process: a
response with the wrong state is rejected *and* never reaches the token
endpoint, metadata declaring an issuer other than its own authority is
rejected (RFC 8414 3.3, without which a mix-up attack works), a provider
offering only 'plain' is fatal rather than a silent downgrade, and the
verifier sent is checked against the challenge advertised so PKCE is not
theatre that only fails in production.

Two bugs caught by writing the tests: the authorize URL builder dropped
client_id entirely after a refactor, and CancellationTokenSource.CancelAfter
has no TimeProvider overload -- so the browser timeout is now constructed
with the clock and a test can advance it instead of waiting five minutes.
2026-07-28 21:13:35 +02:00
jaap-jan e65d738912 Add the client key hierarchy: bundle, master key, vault and item keys
Everything crypto.md section 3 describes below the identity key, which is
what the desktop client needs before it can enroll or store anything.

DshAad gives every descriptor in the specification a named constructor. The
AAD binding is the most valuable structural property in the design -- it is
what stops a server holding every ciphertext from pasting one row's bytes
onto another, rolling a row back to a superseded generation, or replaying a
revoked grant -- and all of it depends on callers getting purpose, resource
type and ids right at every single call site. Hand-constructing descriptors
makes that a matter of care; picking a method name makes it a matter of
spelling.

UserSecretBundle holds private keys in libsodium's guarded, mlocked
allocations rather than a byte[], so they are not paged out and do not land
in a core dump. They are created exportable, deliberately: re-wrapping the
same bundle for a passphrase change or a new device needs to re-encode it,
and the alternative -- a long-lived managed array so the keys need not be
exportable -- keeps the identical secret in strictly worse memory. Every
export is into a buffer zeroed before the method returns.

Two spec changes, both found by implementing it, which is the argument for
writing code before calling a spec frozen:

- MK is 64 bytes, not 32. Skipping HKDF-Extract is correct for an Argon2id
  output (RFC 5869 3.3), but it means MK *is* the PRK, and .NET's
  HKDF.Expand rejects a PRK shorter than the hash output -- so a 32-byte MK
  cannot be expanded with SHA-512 at all. Widening it keeps the specified
  primitive; the alternatives were dropping to SHA-256 or adding an Extract
  step that conditions nothing.
- The bundle encoding is a fixed 92-byte layout rather than canonical CBOR.
  Canonicality is not load-bearing here -- unlike a key statement the bundle
  is never hashed or signed, only encrypted -- so CBOR's one advantage does
  not apply, while its canonicalisation rules are a real source of
  cross-implementation disagreement. It also costs a dependency
  System.Formats.Cbor is not in the shared framework. Safe to change now
  and not later: no bundle has ever been stored.

53 new tests. The encoding is checked against an independent codec written
in the test rather than by round-tripping production code against itself --
a round trip passes just as happily when both directions are wrong the same
way, and this format cannot change after one bundle is stored. The pinned
92-byte hex constant is the golden vector for the layout.

Most of the rest are negative, because a binding is only demonstrated by
the substitutions that fail: a wrap for another user, a grant from a
superseded generation, a payload pasted onto another item, a metadata blob
offered as a payload, a version rolled back.
2026-07-28 21:02:52 +02:00
jaap-jan 885fb17bdc Clear the SSH gate: window-change reaches the remote, and licence as MIT
Licence is MIT, set solution-wide rather than only on the packable project:
DodoSSH.Contracts is published so clients can build against it, and a
package with no licence expression is one a corporate policy scanner
rejects outright.

The SSH.NET spike is the M1 client gate and it passes. SSH.NET 2025.1.0
exposes ShellStream.ChangeWindowSize, but a method existing is not the
remote observing it, so the tests read `stty size` back from a real sshd
after resizing rather than asserting the call did not throw. Repeated
resizes each take effect too, which matters because dragging a window edge
produces a stream of them. The IChannelSession fallback is not needed.

Also verified against a real sshd: password and public-key auth, that the
host key arrives as a raw blob we can fingerprint ourselves rather than
reading SSH.NET's MD5 property, and that refusing the key via CanTrust
actually aborts the connection -- without which the TOFU dialog would be
decoration.

Kept as a permanent suite, not deleted after the spike. An upgrade that
silently stopped sending the request would present as wrapped output only
after a resize, which is easy to misattribute to the terminal emulator.

Two bugs in the test itself, both worth naming because either would have
been read as "resize does not work":

- A PTY emits CRLF, and the anchored regex rejected the CR. The output
  visibly contained `24 80` while the match failed.
- Each read can begin with output still buffered from the previous command,
  including its size line. Taking the first match would have reported the
  pre-resize size.

platform-flags.md now records window-change as resolved rather than
unverified -- a stale flag is worse than none -- plus the three real SSH.NET
limits found on the way: ShellStream does not override ReadAsync so every
idle session parks a pool thread, one connection cannot serve both
SshClient and SftpClient, and agent forwarding needs an upstream change.
2026-07-28 16:52:09 +02:00
jaap-jan b7325b78ca Record the platform flags that were only in conversation
ci / build and test (ubuntu) (push) Has been cancelled
ci / build (windows) (push) Has been cancelled
Development and testing are Windows-only, so anything unverified elsewhere
needs to be written down or it gets assumed to work. Several of these have
already cost time once: PostgreSQL 18 moving its data directory silently
gives a carried-over compose file an empty volume, and a loopback-bound
Tomcat beat Docker's 0.0.0.0 publish for `localhost`, making every Keycloak
realm 404 while the container looked healthy.

The largest entry is the Avalonia WebView on Linux, which remains the
biggest risk in the plan and is why the terminal sits behind ITerminalHost.

Also records two things this milestone deliberately left undone -- no rate
limiting on the enrollment and sync write paths until M2, and /me not
touching last_seen_at_utc -- so neither reads later as an oversight.
2026-07-28 16:08:04 +02:00
jaap-jan d2a2ed8a29 Specify the key statement encoding and key log chain (crypto.md 7.1, 7.2)
Section 7 always required "a canonical, length-prefixed encoding" for
signatures without ever specifying one. That gap had to be closed before
enrollment could exist: the client hashes the key statement and uses the
result as an OIDC nonce, so the provider signs over those exact bytes. Two
implementations disagreeing by one byte produce two nonces and an
enrollment nobody can verify -- and it only shows up against a real
provider, never in a local test.

JSON cannot be the hashed form. Property order, number formatting, Unicode
escaping and whitespace all vary between serialisers. So the statement is
transmitted as JSON and hashed as a fixed binary encoding, and the two are
independent by construction.

Three details are load-bearing rather than stylistic:

- The presence byte before each string is what makes the encoding
  injective. Without it an absent email and an empty one encode
  identically, and two different statements share a binding.
- Timestamps truncate to milliseconds. PostgreSQL stores microseconds, so
  a statement that has been through the database must still hash to what
  the client hashed. The same applies to the key log, where an entry that
  cannot reproduce its own hash after being read back makes the chain
  unverifiable.
- The key log entry hash deliberately excludes the database sequence. It
  is unknown until the insert runs, and order already follows the hash
  links -- so a renumbered or gapped sequence column cannot silently
  reorder history.

KeyStatementFields is separate from Contracts.KeyStatement on purpose: one
may gain JSON fields freely, the other cannot change without invalidating
every stored binding, and Crypto must not depend on the contract assembly.
KeyStatementDriftTests makes a field added to one and not the other a
build failure, because a wire field outside the binding is unauthenticated
data the server can change undetected.

54 new tests and two new golden vector sections. The vectors pin the
absent-versus-empty email case and confirm that an offset-bearing
sub-millisecond timestamp encodes identically to its truncated UTC form.
Only additions to vectors.json; nothing existing moved.
2026-07-28 16:06:11 +02:00
jaap-jan b15af836a3 Freeze DSH1 crypto specification and implement the core (M1)
docs/crypto.md is now the normative, frozen specification. This had to land before
anything else in M1: the server holds ciphertext and no keys, so it can never
re-encrypt, and a format change after users hold data is a coordinated client rewrite
with no rollback.

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

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

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

Two build-infrastructure bugs found and fixed along the way:
- .editorconfig forced camelCase on const and static readonly fields. PascalCase is the
  .NET convention for both; the config was wrong, not the code.
- The golden fixture was resolved with [CallerFilePath], which ContinuousIntegrationBuild
  rewrites to /_/... under deterministic source paths. It passed locally and would have
  failed only in CI. Now copied to the output directory and read from there.
2026-07-28 13:18:29 +02:00
jaap-jan ce43f397a6 Add ADRs 0001-0006 and README (M0)
Records the decisions the milestone plan already made, with their costs stated rather
than only their benefits:

- 0001 e2ee-trust-model: key hierarchy, the AAD-to-row binding that stops the server
  moving ciphertext between rows, and the four-layer public-key trust story. States
  plainly that revocation is not retroactive, that Connect cannot be a security
  boundary, and that the IdP becomes a key-distribution trust root.
- 0002 minimal-apis: feature modules with explicit registration; capability negotiation
  instead of Asp.Versioning, since client and server upgrade independently when
  self-hosted.
- 0003 sync-protocol: single write path, revision cursors, and the bigserial
  pre-commit sequence gap that silently corrupts sync — plus the per-vault advisory
  lock that fixes it and the test that must prove it.
- 0004 relay-authorization: relay forwards bytes rather than terminating SSH, so
  zero-knowledge survives; server-resolved target IPs in the ticket to defeat DNS
  rebinding; why host addresses must be plaintext when relay is enabled.
- 0005 no-application-layer: why the usual Application/mediator layer earns nothing
  here, with the trigger that would make us revisit it.
- 0006 observability-stack: OTel plus built-in ILogger; liveness excludes dependencies
  so a database blip cannot restart the container and kill live SSH sessions.

Also adds a README covering layout, build, enforced conventions and milestones.
2026-07-28 12:28:44 +02:00