416f23365713bb65425ea7e7f317553615628c44
32
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
4925dcf179 |
Give the phone the + it was promised, and the editors behind it
Steps 5 and 6 of docs/adding-hosts-on-the-phone.md, which finishes it. The phone can now put things in a keychain rather than only read one. The floating + arrives three designs after it was first asked for. It was refused twice on the honest grounds that it would open an editor this head had not got — and that stopped being true when steps 1 to 4 landed. It raises a sheet rather than a form, because "add" on this screen has been two operations since groups existed: a host, or a group to file hosts under. Both editors are cards in the list's own row, swapped for the list rather than stacked over it, following SnippetsScreen and for the reason written there: a form on top of the list hides what it is about. There is still no dialog and no editor screen anywhere on this head. The port and username boxes may be left empty, and the greyed text inside each is what the host will actually use — following the group picker as it moves, so the form says what leaving a box blank will do rather than making it a guess. The back gesture gains a guard above the switch rather than a case inside it. An editor is not a screen and has no entry there, and it is strictly nearer: the sheet sits over the editor's own screen, so back lowers whatever is topmost before it considers moving between screens. A group editor needs a way in, and the phone draws no groups panel — so the heading in the host list gains a pencil. A + that adds groups with no way to correct one is the same strange thing to ship as one that adds hosts with no way to correct one, and worse: a group's defaults are what every host beneath it falls back to. Scouting step 6 turned up a bug worth more than the prose it was looking for. ReloadGroupsAsync read the active vault alone, which was a cosmetic limitation while a group carried only a name — a teammate's host showed under UNGROUPED and nothing else was lost. Since a group began lending a port, a username and a binding, the same omission silently dropped all three: that host would dial 22 as nobody while the machine is on 2222 as deploy, with nothing on screen saying why. The resolution map now spans every readable vault. The editable list stays narrow, because a row shown across vaults needs a vault id for rename and delete and two vaults may hold groups with one name — but the map needs none of that, since it is only ever asked what an id says. An adversarial review of this change found something I had written a comment denying. The + was a child of the outer Panel rather than of the list's row, so it anchored to the bottom of the screen and sat squarely over the new EDIT button and the lower edge of CONNECT — and because a Panel hit-tests its last-declared child first, tapping the right end of EDIT would have raised the add sheet. The comment beside it claimed it was in the list's row precisely so that could not happen, and manual-checks 8.1 names that exact failure. It is in Grid.Row 2 now. The same pass caught a connect bar that was disabled rather than hidden while its own comment said hidden, a scrim that would have flashed its default pressed chrome across the screen, a fab comment claiming an accent fill no other button had when Button.primary has it, and a back-gesture remark naming two view-model properties that do not exist anywhere in the repo. That last one came from this plan, which invented IsVaultsSurface and IsConnectionsSurface, and pointed step 6 at a ConnectionsScreen.axaml that has never existed. Both are corrected in the plan rather than quietly worked around, along with a fourth "groups are flat" site in README.md that step 1 missed. The phone's rectangles are not measurable and structurally never will be: the layout suite is net10.0, the Android head is net10.0-android, and Avalonia's application is a one-shot process global — so a second head cannot share the process even if the reference were possible. Everything the sheet and both editors bind to is shared and is tested headlessly here; the pixels go to docs/manual-checks.md phase 8, nine checks, which is where this project already sends what it cannot assert. Tags remain the one thing asked for that did not ship. Tag is a full item kind and TagIds merges, encodes and resolves, but no screen draws a chip or offers to add one, so the tags a client can store are ones nothing here can see. Both editors carry the set through a save untouched so a client that can set them does not lose them. Recorded as half-shipped in docs/design-import-gaps.md rather than quietly left out. Verified by the whole suite: 1402 tests over nineteen projects, none failing. Both heads build. Nothing seen on a display — phase 8 is what that costs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3627021420 |
Give the desktop the second design too, and the window the size it now needs
The desktop v2 design is the other half of the one the phone took last commit, and this is its chrome: a 190-pixel labelled sidebar where the 54-pixel icon rail was, a titlebar with the search box centred in it, and session tabs drawn as pills. The palette was already here — it is shared, and moved when the phone's did — so what this changes is shape rather than colour. **The window's minimum grew, and by exactly what v2 added.** The sidebar is 136 wider and the chrome 14 taller, so 880x560 became 1016x574. That is not a round number somebody liked: it leaves every screen the same 826x464 it was designed against, which is the arithmetic the layout suite is built on. Four of the tables stop fitting at 690 wide, so widening the sidebar and leaving the window alone would have broken them somewhere no test was looking. LayoutHarness carries the new constants and the suite still passes at the minimum, which is the whole reason it exists. The rail's five-character abbreviations are gone with the width that caused them — PINS and SNIPS are Pins and Snippets again — and each row gains a glyph and a count. A count is drawn only where one is real, so SFTP, Logs and Preferences show nothing rather than a zero: a transfer queue's depth is not how many files a screen holds, and a log has no total until it is read. The count beside Pins is the vault's own, not the Pins screen's VisiblePins, which is the filtered list and would have made the sidebar count whatever somebody had just typed into a filter box on another screen. Teams has no count for a related reason: they are read from the server when that screen is opened rather than on unlock, so a number there would read 0 until somebody had already been to look. One colour moved with it, finishing what the repalette started: the live-session summaries on the unlock and sign-out cards were Info, so the two heads disagreed about a fact the phone paints green. They match again. **Buckets became a destination rather than a mode**, which is what the design draws and what the phone already does. The HOST / BUCKET pair inside the files screen is gone; ShellScreen.Buckets draws the same TransfersScreen with the other picker, and the sidebar entry is what sets it. That also settles an old disagreement rather than merely moving it: TotalItemCount is keys plus passwords and excludes buckets, so the number beside the keychain used to disagree with the list under it, and now counts what that screen shows. There is one session behind both file destinations, so asking for the other kind while something is open is refused rather than obeyed — and refusing means staying put. An earlier turn of this had it move anyway and only decline to switch the picker, which put the S3 entry in the sidebar over a screen still listing an SFTP host: two pieces of chrome disagreeing about where you are, which is worse than the navigation simply not happening. The message that says so goes to Transfers.Status, which turned out to be drawn in the same grid cell as the connected chip — survivable while it was mostly read before connecting, and not once a refusal reports itself there. It has its own column now. The design has nine entries' worth of screens and draws five. Pins, Teams, Import and Preferences are built, working screens, so they keep their entries — the sidebar is labelled now and has the room, and dropping an entry would have stranded a screen rather than simplified anything. The Team vault card the design pins to the foot is not drawn: it is a second route to a screen already in the list, carrying a seat count nothing here produces. **The status bar survives the design that deletes it**, cut down to one thing. Two of the three facts it carried moved into the titlebar with v2 — the sync word is beside its dot and the shortcut hint is inside the box that uses it — so those are gone from it rather than printed twice. The third is Vault.Status, the only channel this application has for saying a save failed or a merge picked a winner. The design is a mock-up of an afternoon that goes well and has nowhere to put a sentence like that; dropping the bar would have meant dropping the sentence or repeating it on nine screens. What v2 draws and this does not is in docs/design-import-gaps.md, and it is the same list as the phone's for the same reasons: the forwarding screen and both its chips, the host detail's fingerprint, tags and last-session cards, the keychain's rotate button, the logs' FOLLOW pill and severity filters, and the session footer's latency. The terminal is not inset behind a rounded frame either — it is a native child window that composites above everything Avalonia paints, so the frame would clip nothing, which is the same answer the phone gave. **The light theme is not built.** Its accent is #6D5AE6, a different hue rather than a tint of the dark one, so it needs every colour doubled, a variant to switch on, the renderer's own page switching with it, and contrast checked twice. That is a piece of work rather than a setting, and it is separable from the layout — which is why this commit is the layout. The screens themselves are restyled through the shared vocabulary rather than rebuilt: corner radii, chips, cards and the accent's ink, all in App.axaml, so every screen moves at once. Their layouts are left alone deliberately. The design draws read-only detail panes and these screens carry the editors and forms it has no equivalent of, so replacing a layout with the mock-up's would have lost the half that is actually used. Verified by the whole suite: 1309 tests over nineteen projects, none failing, including the 68 layout cases that stand up real Avalonia and measure every screen at the new minimum. Both heads build. Not run on a machine with a display — see docs/manual-checks.md for what wants looking at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AZE3u99BNt6LzgTC5jhbz2 |
||
|
|
5593f337b6 |
Give the phone the second design, and both heads the palette it arrives with
The Android v2 design is what this head draws now: four destinations in a bottom bar — Hosts, Terminal, Keychain, More — with snippets, SFTP, S3, logs and preferences one tap deeper behind the last. The first design's four had nothing behind them, which is what made a hub worth building. The palette moved from green-black to blue-black, and it moved in the shared project because that is where it lives and the desktop v2 specifies the same seventeen tokens. One colour changed meaning rather than value, and it is the only semantic change in the file. Green used to *be* the accent, so Ellipse.dot.live filled with Accent and "the thing to press" and "a shell is open on this host" were the same colour by construction. v2 makes the accent blue and keeps a green for status alone, which finally separates them: Live is that green and nothing merely interactive may use it. The accent is also two colours now — Accent fills, AccentText writes — because a row of chips in the fill colour is a row of things that all look like the primary action. A palette is not one file, which is the part worth knowing before the next one. Nine hex literals lived outside it: the nav bar's own label colours, the accessory keys and their Ctrl-latched state, two scrims, the window background Android paints before Avalonia has a frame, and the launcher vector. The two C# sites now resolve from the dictionary by name rather than restating it. The renderer's page cannot — it is served to a WebView over a loopback socket — so terminal.css and terminal.js keep hand-copied values and say so at both sites. ShellScreen gained More and Buckets, appended rather than slotted in. SFTP and S3 are one screen over one TransfersViewModel differing only in which picker they offer, and the kind is set by the button that navigates rather than on arrival — doing it in OnScreenChanged made every arrival at Transfers force the picker back to hosts, including the desktop's own rail arriving at a screen with a bucket already open. It refuses to change kind while a session is live, because there is one session behind both destinations and switching under it would title a screen S3 while it listed an SFTP host. What the design draws and this does not, on the usual grounds. The FORWARDING screen: nothing here forwards anything, so every toggle would be a control with no effect — it is a paragraph on the hub naming the absence, for the reason the desktop keeps TEAMS in its rail. The terminal's `23 ms · fwd 5432`. An ED25519 badge and a SHA256 line on keychain cards, which need an algorithm field and a fingerprint the item type does not have. An `agent` chip, for an agent that does not exist. Snippet run history and exit codes. The Logs FOLLOW pill, which claims a live tail over records that are written once at close and read when the screen opens, and the severity filter, which has nothing to count — that chip row is spent on the real choice, which of the two logs. S3 bucket totals and lifecycle. And the + on HOSTS, which would open a host editor this head has not got. SFTP is browse, open and delete. Both transfer commands work, and what they work against is the local pane: QueueDownloads writes to Path.Combine(LocalPath, name), and LocalPath starts at SpecialFolder.UserProfile, which on Android is the application's own private directory. A download would have reported success and left the file where the person who asked for it cannot open it, which is worse than not offering it — a refusal is visible and a file in /data/user/0/ is not. The queue is not drawn either, since nothing here can put anything in it. Both return with the document picker. The foreground service still counts zero transfers, and the reason moved rather than went away. Four defects worth naming, because three of them are the kind that compile. A Button as a ListBox ItemTemplate swallows the pointer press before the list sees it, so the files listing selected nothing and every command reading the selection did nothing — the row is a Border now and the phone-only single-tap-to-open is a Tapped handler, which also keeps a desktop single click from walking into directories. Avalonia type selectors are exact, so TextBlock.fingerprint never matched SelectableTextBlock and every fingerprint on this head rendered proportional and unwrapped: that was breaking the never-truncated rule on the host-key sheet already. The new two-level hierarchy had no handler for the system back gesture, so back left the application from a log screen. And the tab's close cross had shrunk to a 30x32 target flush against the select target, which is the one control here that ends a shell with no confirmation and no undo. Fingerprint unlock is raised on arriving at the lock screen rather than waiting for its button, which is still there. Only at launch: a lock the user asked for is not answered with an immediate request to unlock, which makes LOCK look inert and trains the reflex of authenticating at a prompt nobody asked for. And once, because a declined gesture leaves the passphrase box exactly where it was and a prompt that came back after being dismissed would be a modal you cannot get out of to type into it. Two fixes fall on the desktop. Its file listing coloured directories with Info and executables with Accent, which was blue against green and is now two steps of one blue; an executable is Live now. And a bucket's folders were drawn with a 0001-01-01 timestamp, because a prefix has no modification time — blank now, for the reason a directory's size is blank. Verified by the whole suite: 1309 tests over nineteen projects, none failing, including the layout suite that stands up real Avalonia and parses every desktop screen. Both heads build. Not verified on a device — nothing in this head ever has been; see docs/android-port.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AZE3u99BNt6LzgTC5jhbz2 |
||
|
|
ffab2be22a |
Drop the formatting step, which spent minutes agreeing with the build
`dotnet format --verify-no-changes` re-analysed the whole solution before the build did, to reach a verdict the build reaches on its own: IDE0055 is an error in .editorconfig, EnforceCodeStyleInBuild is on and warnings are errors, so a misformatted file fails the build step. What the separate step bought was hearing about it a few minutes earlier, and it charged those minutes on every run. Checked rather than assumed, because the whole justification rests on it: appending a badly-spaced member to a source file produces three `error IDE0055` lines and a failed build with no format step in sight. Three places said the old arrangement out loud and would now be wrong on their own — the comment on the IDE0055 line, the conventions list in the README, and a note in platform-flags telling people to run dotnet format before pushing or CI would fail them. They say the build enforces it now. dotnet format is still how to fix what the build complains about; it just no longer gates anything. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
093f3904c1 |
Apply pending migrations at startup instead of asking for a second command
The API deliberately never migrated: it failed readiness while a migration was pending and named it, and a separate step applied them. That is the right split for a deployment with a release pipeline and the wrong one for a self-hosted server, where it means an image that boots, refuses traffic, and waits for somebody to know that dotnet ef exists. The schema and the code that expects it ship in the same image, so the image is where the two are reconciled now. Before RunAsync rather than in the background. A migration racing the first requests would let them through against a half-applied schema, and the first authenticated request is the one that provisions accounts. Failing to migrate therefore fails to start, which is the loudest signal available and the one an orchestrator already acts on. Concurrent starts take a Postgres advisory lock first. Without it two replicas rolled out together read the same empty history table, both apply the same migration, and the second dies on an object that already exists — a crash loop on the day of a schema change, which is the worst day to have one. The lock is held on a connection of its own because EF opens and closes one per command, and a session lock belongs to the connection that took it. The exception is a database that does not exist yet: there is nothing to hold a lock in, so that path migrates without one and says so. Two instances creating it at once still converges — one wins, the other restarts into the ordinary locked path — and refusing to start would leave a fresh deployment stuck on the step this removes. Database:AutoMigrate turns it off for the deployments that own their schema: a migrator job, a rollout where new code must run against the old schema first, or a database user denied DDL. With it off the behaviour is exactly what it was, and the health check now explains which of the two situations a pending migration means. Verified against a throwaway PostgreSQL container: an empty database gets all seven migrations applied before the port opens, the tables land in the dodo schema, and a second start logs the schema up to date and serves. The API suite passes, which exercises the startup path once per assembly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
43d76d0f2d |
Let the suite run on Linux, and fix the three things that stopped it
The pipeline finally reached the tests and found four failures. None was the pipeline's, and only one of the four was a test being fussy about a platform rather than telling the truth about one. The local pane's roots bar was the real bug. LocalDirectory.Roots built it from DriveInfo.GetDrives on every platform, and its own summary — "the drives on Windows, and the root elsewhere" — had been describing an intention rather than the code for as long as nobody ran it off Windows. On Unix that call answers with every mount the kernel holds: /proc, /sys/fs/bpf, one per installed snap, /run/user/1000/doc, some forty on an ordinary laptop. The transfers screen draws a button per root, so the bar ran to about five thousand pixels inside an eight-hundred pixel window. Anybody running the Linux build has been looking at that. Filtering GetDrives is not the fix and the comment now says why at length, because it is the obvious thing to try: DriveType answers Fixed for / and /home and equally for every squashfs snap, for efivarfs and for tracefs, while /boot/efi comes back Removable, and DriveFormat would need a hand-kept list of every virtual filesystem Linux might grow. So Unix now names what somebody would want instead of subtracting what they would not — the root, their home, and whatever is mounted under /run/media/<user>, /media, /mnt or /Volumes. Anything else is still reachable by navigating from /, which is what the pane is for. Windows is untouched. ClientPathsTests looked for "odoSSH" in the profile directory. ClientPaths spells it DodoSSH on Windows and dodossh on Unix deliberately, one per platform convention, and that substring was clever enough to survive either spelling of the leading D while still only ever matching one of them. Now OrdinalIgnoreCase. WhyTheWindowItselfIsNeverShown asserted a COMException with HResult RPC_E_CHANGED_MODE, which is WebView2 refusing an MTA thread — a Win32 component raising a COM error. On Linux the adapter is a different implementation with no apartment to disagree about, so showing the window works and Should.Throw catches nothing. Skipped there rather than loosened to accept both outcomes: the assertion is the documentation in that test, and one that passed everywhere would have stopped recording the constraint it exists to record. The fourth was CI's alone, and the diagnosis is the useful part. All 69 layout tests failed on the runner while 6 failed here, which looked like missing fonts and was not: Avalonia's headless renderer is Skia, libSkiaSharp.so links against libfontconfig, and without it the suite dies in HeadlessUnitTestSession with a TypeInitializationException on SKImageInfo naming none of its actual subjects. The job installs the one library now. Verified in a container where fc-list returns zero and the suite passes regardless, because the application carries Inter itself — fonts were never the problem, only the thing that would have looked for them. The whole solution now passes on Linux: 19 suites, 1295 tests, 0 failures, 4 skipped, the end-to-end Testcontainers suite included. README and platform-flags.md said testing was Windows-only, which CI now contradicts on every push, so both say what is true instead and the two findings are written down where the next person will look for them. macOS is still untested and now says so on its own rather than hiding inside "not Windows". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7a3a521c59 |
Give the phone the rest of its screens, and a way in
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. |
||
|
|
81e7e6d939 |
Write down what the phone found, and stop it rotting
docs/android-port.md was an audit of work not started; it now says what is built. Three of its statements needed correcting rather than extending, and they are marked where they sit: the Android version question is settled and was never as open as it looked, because Avalonia.Controls.WebView ships only a net10.0-android36.0 assembly and nothing lower can resolve it; cleartext to loopback has to be permitted explicitly, which the audit missed entirely; and the spike produced a structural change it did not anticipate, in DodoSSH.Client.Shell. A CI job of its own, because the head is deliberately not in DodoSSH.slnx and a project outside the solution is a project nobody notices breaking. It packages as well as builds: a native library with no Android ABI and an assembly that will not dex are both invisible to a compile, and both are exactly what this head is exposed to. The README says plainly that signing in is not built, that a fingerprint re-enrolment destroys the device key, that a notification appears while a shell is open, and that none of it has run on a device. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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". |
||
|
|
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. |
||
|
|
91438fb382 |
Ask before deleting, and connect a host by double-clicking it
DELETE on a host, an SSH key, a stored password or a file on the host now puts a question where the button was, and only answering it deletes anything. It is a state rather than a dialog, which is the arrangement signing out already had and for the same reason: this is the moment that has to be able to say what is about to go before it goes. What the question says is counted rather than generic, because a confirmation that only asks whether you are sure is a click to train people out of. A key names the hosts that authenticate with it and says they will refuse to connect afterwards rather than falling back to a typed password, which is what the connect path actually does. A host discloses a terminal open on it, because deleting the host does not close the session. Every vault deletion says how far it travels and whether this machine can push the tombstone yet or is queuing it. Deleting on the host carries the strongest warning of the four on purpose: everything else here is a tombstone against a copy the server still holds, and a file on somebody's machine is bytes with nothing behind them — so that one names the full path, since a bare name identifies nothing. The armed request carries the item's entity id, so nothing that moves the selection between the question and the answer can redirect it, and answering about something that has since gone says so instead of doing nothing quietly. Disarming compares ids rather than rows, which is the subtle half: a reload replaces every row object, so the naive rule would have let the pass that runs every minute take the card away from somebody halfway through reading it. Forgetting a pinned host key is deliberately still unguarded. It costs one fingerprint check on the next connection and it is the safe direction to be wrong in — the dangerous button there is the one that adds trust, and that one is already a prompt at connect time. Discarding a stopped transfer is likewise unguarded: it removes a resumable part file and leaves the source alone. Double-clicking a host in the sidebar connects to it, wired as a gesture in the control exactly as the transfers screen opens a directory. CONNECT stays, since it is the button with the password box beside it. Ten existing delete call sites now go through arm-and-confirm helpers, and eight new flow tests cover asking first, cancelling, the counted warning, disarming on a selection change and on an editor opening, surviving a sync, and the stale-item guard. Three layout tests measure the new shapes — the sidebar card is the one card in the application a user cannot scroll — and one of them also asserts the card renders its text, because a card whose compiled bindings did not resolve would lay out perfectly as empty rows. The double-click test performs the real gesture and proves it reached the connect command through a refusal that never touches a network. dotnet build, dotnet test and dotnet format --verify-no-changes are all clean: 853 tests, including the end-to-end suite against real containers. |
||
|
|
240aadb746 |
Merge branch 'main' into claude/vault-unlock-logout-autosync-a84c35
ci / build and test (push) Failing after 3s
Four files needed a hand, and all four were two branches adding something in the same place rather than either changing what the other did. The shell's constructor now takes both new parameters: main's SFTP session factory, which it must have because it builds the transfers view model, and this branch's optional resume handler, which stays last so every existing test that constructs a shell without one still gets a shell that can only be online because somebody signed in during this run. App.axaml.cs, ShellFlowTests and QuickConnectTests pass the pair; the layout suite keeps both of its new fields. Signing out now detaches the transfers screen exactly as locking does, and the confirmation says that an open transfer session survives it. That is the same policy both sides already argue for their own case: signing out destroys this machine's copy of the vault, not work that authenticated before it. QuickConnectTests did not compile on main — the SFTP commit added a constructor parameter and the quick-connect suite, merged from a parallel branch just before it, was still calling the old one. Fixed here rather than worked around, since the merged tree has to build. dotnet build, dotnet test and dotnet format --verify-no-changes are all clean: 980 tests, including the end-to-end suite against real containers. |
||
|
|
0b261c4d39 |
Stay signed in, come back online by itself, and let a machine be given up
Three things a machine that has been set up could not do. Unlock now takes Enter, which is the gesture everybody makes after typing a password and which did nothing until they found the button. Signing in survives a relaunch. The refresh token is kept in the local cache, sealed under the vault's own cache key, so a later launch resumes the session through the refresh grant with no browser and nobody present — and because it is sealed under that key, only an unlocked vault can resume it. A locked client therefore cannot reach the server at all, which is a consequence worth stating rather than working around; docs/crypto.md §3.2 records it. Every sync pass asks the shell for a connection rather than reading one captured at unlock, so a laptop that unlocked on a train is online within a minute of finding a network, with nothing pressed. Unlocking itself still never waits on a socket. Signing out empties this machine: the profile, the cached items, the outbox and this machine's device key, with the account's row withdrawn when the server can be reached. It asks first and says what it costs — the outbox count when the vault is open, an admission that it cannot be counted when it is not, and the shells that keep running either way. The vault is on the server and is untouched, which is what makes the same button the only honest answer to a forgotten passphrase, so it is on the unlock screen as well as in preferences. It cannot end the session at the identity provider, and says so. Two defects surfaced on the way. The synchronisation pass that runs when the vault opens never ran at all: the loop is started from inside the unlock command, so the busy flag it yields to was raised by that command — the first sync was a minute late on every launch. And signing in from preferences while unlocked threw an unlock screen over an open vault whose keys were still in memory. The unlock card and the new confirmation live in their own controls because MainWindow cannot be laid out headless, so markup left inside it is markup no test can measure; both are now measured at the window's minimum size in the shapes that grow. What is still unverified is the composed window itself. |
||
|
|
04faef6597 |
Move files to and from a host over SFTP
M2's file transfer, built bottom-up: an SFTP session on the SSH layer, a transfer queue in a project of its own, and the two-pane browser the design asked for replacing the screen that said it did not exist. Remote listings carry names, sizes, modification times and a real drwxr-xr-x — nothing in this repository could render a POSIX mode before — and the queue moves one file at a time with progress, throughput and resume. The design import assumed this would be an SFTP subsystem channel on ISshConnection, beside the shell on a transport that is already up. SSH.NET does not offer that: SftpClient derives from BaseClient and owns its own transport, and there is no supported way to hand it an SshClient's session. So file transfer opens a second authenticated connection, and it is named for that rather than dressed up as a channel — OpenSftpAsync is on ISftpSessionFactory, not on a connection. The difference is visible to a user: the host records a second login, and a host whose password is typed each time asks for it again on this screen. It goes through the same host key gate, the same pin and the same two refusals a shell does, so a fingerprint approved for a terminal is approved here and one approved here reaches the other machines with the next sync. docs/design-import-gaps.md is corrected, and marked as the one row where what shipped differs from what it predicted. Nothing is written at its final name until it is complete. Every transfer goes to a .dodossh-part file beside its destination and is renamed into place at the end, so an interrupted transfer can never be mistaken for a finished one — which matters most for what this screen is actually for, which is copying a build artefact onto a server and then running it. A destination that already exists is refused outright rather than overwritten: the queue has no way to ask, and silently replacing a file somebody's process is serving is the worse of the two failures. The remote pane has DELETE and MKDIR so that refusal is not a dead end. A test against the container pins the assumption underneath all of this — that SFTP's rename does not clobber. Resume works within a run of the application and not across a restart, and the limit is deliberate rather than unfinished. Nothing records which source wrote a part file, and resuming one on the strength of its name matching is how a corrupt artefact gets delivered with nothing reporting a failure; a part file found at startup is started over. Making it survive a restart needs the preferences store this client still has not got. The offset a resume starts at is the part file's own length rather than the transfer's recorded progress: a cancellation can land between a write completing and the counter moving, and only one of those two is a fact about the bytes that are there. The queue and its connection outlive a lock, as shells do. LockAsync already argues that locking must not destroy work in flight — it is what somebody does when they walk away from the machine, which is exactly when a long transfer is most likely to be running — so TransfersViewModel is created once and the vault is attached on unlock and detached on lock. What locking takes is the host list, and it has to: those rows carry decrypted secrets. DodoSSH.Client.Transfer is a new project rather than more of Client.Ssh. The two answer different questions — one is about reaching a host, the other about moving bytes and what to do when moving them stops halfway — and this is the only client project that deliberately touches the local filesystem. Three defects the tests found, none of which review would have. SftpPath.Name answered an empty string for the root. NavigateRemoteAsync wrapped itself in the busy guard, so navigating from inside another command did nothing at all and the remote pane simply stayed empty after connecting, with no failure anywhere to explain it. And opening an SFTP session per test made two handshakes per test — this client learns a host key by being refused — which pushed the SSH assembly past sshd's MaxStartups and failed a different few unrelated tests each run; the session is shared through the fixture now, with the reason written where the next person will hit it. 1004 tests green across 18 projects, 24 of them new: the SFTP subsystem against the OpenSSH container, the queue against a real temporary directory and a fake host, and three more layout measurements because a screen this window has never laid out is a screen never checked. Not verified: the screen has not been looked at running. The layout harness measures it at the window's minimum in three shapes, which is the class of defect that has shipped here before, but reaching it in the application needs the compose stack, the migrations, the API and a browser sign-in. What is still absent — the status bar's transfer count, dragging between the panes, transferring a directory, and sftp over a bastion — is in docs/design-import-gaps.md. |
||
|
|
9a76eced14 |
Give hosts and terminals their own screen, and the rest of the vault another
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. |
||
|
|
211eba0666 |
Keep host key trust in the vault, and make it withdrawable
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
|
||
|
|
70b3290a77 |
Bind an SSH key to a host instead of picking one per connection
A host now names the key it authenticates with, or none, as a field in its encrypted payload — so the choice follows the host to every machine rather than being made again each time somebody connects. The per-connection "Use key" switch it replaces was a stopgap for not having this, and keeping both would have left two mechanisms answering one question. This is the first payload schema version bump, and it does not work the obvious way. A host is written at the *lowest* schema version that can represent it: one that binds a key is written at 2, one that does not is still written at 1, byte for byte as it was before the field existed. The version is what makes an older client refuse to edit an item, so stamping 2 unconditionally would mean upgrading a single machine and renaming a single host made that host uneditable on every machine that had not upgraded yet. Confining the cost to the hosts that actually use the field is the difference between a team noticing a bump and a team being blocked by one. HostSecretCodec states the rule so the next field added follows it, and a test pins the version-1 bytes against a literal rather than against the codec, because the claim is about history: every host already in every vault has to re-encode to what it encoded before, or the first sync after an upgrade would push the whole vault as changed. A binding is an item id, not a copy of the key — a second copy of a private key is one that goes stale — which means the reference can dangle when the key is deleted on another machine. Both places that meets are handled the same way, by refusing rather than falling back: - Connecting to a host whose key is gone is refused outright. A host somebody deliberately set up for key-only access must not quietly start offering a password. - Opening such a host in the editor keeps the binding, selected, labelled as missing. The quieter version of the same failure is someone editing the port and saving, silently converting the host to password authentication with nothing ever having said so. Two things this found by being falsified: - The merge was untested for the new field, and "just take the server's value" passed the entire suite — a local binding change would have been discarded with no conflict recorded. HostSecretMergeTests already had a test written for exactly this class of omission; it simply had not been extended. - Adding a nullable field exposed a defect in HostSecretMerge.Field: it short-circuited when the discarded value was null, so the formatter never ran for the one case where null is a value rather than an absence, and a field whose absence has a name could not report it. Now the formatter always runs, and "no key" appears in the conflict log where an empty string used to. Also fixes eight nullable warnings in SyncEndpointTests left by the server-side SSH key commit, which had omitted the null-forgiving operator the rest of that file uses. They were invisible until an unrelated change forced the project to recompile. The end-to-end slice now binds its host to its key, so a schema-version-2 payload goes through the real API, the real PostgreSQL and back out on a second machine. 745 tests green. Zero warnings, dotnet format clean. |
||
|
|
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. |
||
|
|
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 |
||
|
|
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. |
||
|
|
b9e7c258ae |
Point the design-time factory at the stack the repo ships
`dotnet ef database update --project src/DodoSSH.Infrastructure` — the command the README documents — failed on a clean machine. The design-time default named `dodossh_design` as user `postgres` with no password, which is a database this repository never creates, while the development compose stack creates `dodossh`/`dodossh`. The failure arrives as a SCRAM authentication error, so it reads like a broken container rather than a stale default. The default is now the compose stack, since that is the only local database the repo defines. DODOSSH_DESIGN_CONNECTION still overrides it, and a real deployment migrates through that or the migrator job. Also documents running the thing end to end, which the README never covered: the four commands in order, that migrations are a separate step because the API deliberately fails readiness rather than migrating, and the three M1 gaps visible in the first five minutes — so they are expected rather than diagnosed. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
b7325b78ca |
Record the platform flags that were only in conversation
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. |
||
|
|
a628762cd1 |
Add /me and enrollment with the identity-provider key binding (M1)
The last backend piece of M1. A client can now log in, discover it must enroll, publish its identity key, and get a usable personal vault. Enrollment is one indivisible act. One transaction writes the key, its wraps, the device, the key log entry, the vault and the vault key grant, because none of them is useful alone: a key with no vault leaves a user unable to store anything, and a vault with no grant is a container nobody can ever open -- including its owner, since only the client can wrap the key and it has already moved on. Two independent checks run, and neither substitutes for the other. The Ed25519 self-signature proves possession of the private key. The identity-provider binding proves whose key it is: the client hashed its statement, used the hash as an OIDC nonce, and the resulting ID token is the provider's signature over exactly those public keys. This server cannot mint that signature, so it cannot invent a key for a user who never enrolled -- which is the attack that would otherwise let an operator read every vault by publishing its own key as yours. The binding token is stored verbatim, not just summarised. Clients must repeat the check against the provider's JWKS fetched directly, and storing only our conclusion would ask them to trust the server about the one question the design exists to avoid trusting it about. Key log appends take a deployment-wide advisory lock. The falsification matters more than the passing test: with the lock removed, Enroll_ConcurrentEnrollmentsByDifferentUsers_LeaveAnUnbrokenChain fails with entry 11 linked to the wrong predecessor. Different users trip no unique index, so without serialising they all read the same head and the chain forks -- indistinguishable from the key substitution the log exists to make detectable, and permanent, because the log is append-only. Enrollment is idempotent. Vault ids and keys are client-chosen, so a client whose response was lost re-sends the identical body and gets the identical result. Without that, a lost response leaves a user enrolled against a vault they never learned the id of. Contract change, breaking the v0.1 freeze deliberately. EnrollmentRequest had DevicePublicKey but no wrap to go with it, which is unsatisfiable: only the holder of the secret bundle can seal it, so the server could never fill the gap. Added DeviceWrappedPrivateKey, and PersonalVault so enrollment can be atomic rather than leaving an unopenable vault behind two endpoints that do not exist yet. No client exists and no package is published, which is exactly when PublicAPI.Unshipped.txt expects this. Sync now requires the Enrolled policy, which until now was a stub whose name promised a check it never made. The sync denial tests use enrolled intruders instead of unenrolled ones -- an unenrolled caller is stopped before the vault check runs, which would have left those tests passing without exercising the thing they exist to prove. Also fixed: omitting kdfParameters from the JSON body was a 500. A record's non-nullable parameters are a compile-time promise, not a runtime one. 268 tests pass, zero warnings on a clean rebuild, format clean. |
||
|
|
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. |