5593f337b66e53b47c1d3d62f3cae83c8253efce
11
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
9608d73747 |
Come back from a sync position the server will not accept
ci / build and test (push) Failing after 2s
"The server returned 400: The sync cursor is not valid for this vault. Resync from the beginning." told the user exactly what to do and gave them no way to do it. The cursor is the only thing a pull sends, so the refusal was permanent: the next pass read the same stored cursor and was told the same thing, once a minute, for ever. And because the pull runs first, the exception ended the pass before it reached the outbox — so the vault stopped receiving other machines' changes and stopped sending its own. A machine that met this went quietly read-only until somebody deleted its cache. The engine now does what the message asks. A pull refused with the invalid-cursor problem code — the code, never the prose, which is free to change — drops this vault's position, writes that down, and reads the log again from the beginning. The restarted request carries no cursor, which is the one position a server cannot reject, so the retry cannot loop; a refusal of that is rethrown rather than retried, and a restart is allowed once per pull. The position is saved before the replay starts, so a process that dies halfway through begins the next one from the beginning too rather than meeting the same refusal again. The mirror is deliberately kept. Replaying rewrites every row the server still has and applying a change is a blind overwrite, so the re-pull repairs the mirror on its way past; clearing it first would claim more than the evidence supports — the position was refused, not the contents — and would leave a machine that lost its connection mid-replay with less than it started with. That leaves one gap, named in the remarks rather than left to be discovered: once tombstone collection exists, a replay stops carrying deletions older than the retention window. None of the causes are the user's doing — a rotated cursor signing key, a vault served from a restored database, a cache copied between machines — so nothing asks them to decide anything. The report carries ResyncedFromStart and the status line says the position was not recognised and the vault was read again. It is kept out of NeedsAttention, because nothing is outstanding, but the background pass breaks its usual silence for it: a sync that pulled the whole vault on a day nobody changed anything otherwise reads as a fault. The fake server grew a switch that refuses cursors the way a rotated signing key does, including ones it minted itself. Three cases: the vault is re-read and the change on the far side of the refused position arrives; the edits waiting in the outbox are still pushed in that same pass, which is the half that made this worth recovering from rather than merely reporting; and a server that refuses the beginning itself is surfaced instead of replayed against. dotnet build is clean at zero warnings, dotnet format is clean, and the sync and app suites pass — 109 and 101. |
||
|
|
94e11f5e38 | update packages | ||
|
|
d17a60e7c3 |
Stop asking the server to delete things it has never seen
Add a host on a laptop with no network, change your mind, delete it: the outbox holds a tombstone for a row the server has never heard of, the push answers Invalid, the change is parked, and the user is left looking at a rejected change for an item they already deleted and a pending count that will never reach zero. It applies to all four item types, because they all go through the one generic repository — the known-host path is only the likeliest way to meet it, since trust is pinned by connecting and withdrawn from the host editor. DeleteAsync now drops the queued create instead, when the server cannot be holding the item. A null expected version means the row is a create — including a create that has since been edited, because coalescing keeps the original expected version — so there is no server row and no mirror row, and dropping the queued change makes the item genuinely gone. The attempt count is what makes that safe rather than merely convenient. Nothing sent cannot have landed. A parked row cannot have landed either, because parking is what the pusher does when the server has refused, so the refusal is the evidence — and a parked create that the user then deletes could not be got rid of at all before this: the tombstone replacing it was parked in its turn. What is left is a create that went out and whose answer was never seen. That one still gets a tombstone, because the server may be holding the item and a local drop would strand it there for ever. A refused tombstone is recoverable; an orphan nobody can see and nobody can delete is not. Eight tests, and the interesting half is the other direction. A repository that quietly dropped tombstones would pass a suite written only around the bug and would lose data on every machine but the one that pressed the button. Which is not hypothetical, because the mutation pass found exactly that hole in the first draft of these tests. Removing the expected-version guard left every test passing: after a sync there is no queued row at all, so deleting a synced item never reaches the shortcut and proves nothing about it. The way to hold an unpushed Upsert over an item the server holds is to edit it offline, and EditingASyncedItemOfflineAndThenDeletingIt_StillQueuesATombstone is the test that was missing. Without the guard it deletes the item here, leaves it on the server, and the next pull brings it back. Three mutations, all caught now: removing the shortcut (5 tests), removing the expected-version guard (1), removing the attempt-count guard (1). The Upsert check itself is conservative rather than load-bearing — a queued Delete with no expected version is not reachable from the interface, and completing one locally would discard a tombstone that might be needed, so it stays and is not independently covered. 106 tests green in Client.Sync, 8 of them new. Zero warnings, format clean. |
||
|
|
7016ce36f1 |
Key the local cache to the identity, not to the door it was opened through
Groundwork for a device key, and a spec change rather than a feature. ADR 0007 records the decision it clears the way for: a Windows Hello gesture guarding a protected blob, with the passphrase kept as a permanent fallback. The reason that decision needed this first is that a device key cannot open a session on its own. SessionOpener derived two things from the passphrase master key — the bundle, and the local cache key — and a device wrap is SealTo(device_x25519_pk), which yields the bundle and never computes a master key at all. A device unlock could therefore have opened the identity and still not read the cache it had itself written. So LocalCacheKey now derives from the bundle: dsh1/localcache/v1 → v2, specified in crypto.md §3.2. Every wrap that opens a vault ends up holding the bundle, so every door reaches the same cache. Extract-and-expand, not expand alone. Everything derived from the master key uses HKDF-Expand directly, which is sound because an Argon2id output is uniformly random over its whole length. The bundle's encoding is not — it opens with a fixed 14-byte label and carries a version, a generation and a timestamp before reaching any key material — so it needs the extract step to become a pseudorandom key first. Two consequences fell out, both improvements and neither the point: - A passphrase change no longer discards the local cache. The bundle is unchanged by a re-wrap, so the cache key is too. Under v1 changing a passphrase silently orphaned every cached row and the next launch re-pulled the whole vault. - Recovery-code unlock is fixed before it ships. It derives a different master key from a different secret and a different salt, so under v1 it would have had the same defect as the device path, and nobody would have noticed until it landed. The cache becomes unreadable exactly when the identity is rotated, which is the correct moment to discard it. Existing caches are discarded and re-pulled on upgrade — already the specified behaviour for a stale cache, and the reason the label is versioned rather than reused: a v1 cache must fail to open rather than decrypt to nonsense. One stated guarantee got weaker and now says so. crypto.md §10 claimed locking meant "nothing on disk can be read again without the passphrase." Where a device wrap exists that is no longer true, and it would have been untrue under either candidate design — the alternative was storing a copy of the cache key in the device blob, which is the same door with an extra key lying next to it. The wording now points at ADR 0007, because what guards the device key is a platform decision and not a property of this specification. A golden vector was quietly lying, which is the part worth reading twice. The "local-cache" entry pinned HKDF-SHA512-Expand over a fixed PRK — a construction the cache key no longer uses. Regenerating it would have produced a green suite describing a derivation this code does not perform. It is replaced by a vector over a bundle whose every byte is pinned: the label, version 1, generation 1, a fixed timestamp and two recognisable key scalars, all visible in the fixture so a second implementation can check itself against it. UserSecretBundle.TryDecode is internal for this, because Create draws fresh randomness and so can never produce a reproducible input. Mutation tested, and this one earns its keep: dropping the extract step now fails CommittedVectors_MatchCurrentImplementation. The vector it replaced could not have caught that, because it never touched the bundle at all. One test became false and says so. ARecordSealedUnderAnotherPassphrase is now ARecordSealedByAnotherIdentity: a different passphrase deliberately no longer changes the cache key, and TheLocalCacheKey_SurvivesAPassphraseChange pins that. What must still be unreadable is another user's cache. CacheHarness therefore generates an identity rather than deriving from a passphrase, and has no passphrase parameter left — the cache key is not a question about passphrases any more. SyncHarness's two simulated machines now derive the same cache key, which is what keying on the bundle means: they are the same user holding the same identity. They still have separate cache databases, so nothing is shared between them but the key that would open either. Both harnesses lost a MasterKey field that existed only to make a protector. 858 tests green. Zero warnings, dotnet format clean. Not done: the device key itself. Three pieces remain, and the middle one was a discovery rather than a plan — EnrollmentService.AddDevice runs only during enrollment, so every already-enrolled account, which is all of them, needs an endpoint to add a device wrap while unlocked. The client proves possession by producing the wrap, so that shape falls out of the crypto. After that: the protector seam with the wrap cached locally for offline unlock, then the Hello implementation and the unlock-screen UI, which is where the Windows TFM lands and where automated testing stops. |
||
|
|
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
|
||
|
|
d10a38d8e6 |
Pin every cipher's AAD resource type from one table, not one test each
Mutation testing found that pointing CredentialCipher at AadResourceType.Vault passed the entire suite. Every credential test compared the cipher against itself — round trips, cross-type refusals, two-machine sync — and all of those stay true when both halves of one cipher are wrong together, because Seal and TryOpen share the constant. A password sealed under the resource type for a vault encrypts cleanly, decrypts cleanly, syncs cleanly, and violates docs/crypto.md in a way nothing surfaces until another implementation refuses the item. By then the AAD is frozen into stored ciphertext and only clients can re-encrypt it. This is the third time that hole has appeared in this file, and the second time mutation testing rather than review is what found it. So the fix is structural rather than another hand-written test: one table of wire type to resource type, a theory that seals a sample through each cipher and opens it with the resource type the table names — never the one the cipher holds — and a guard asserting the table covers ItemKinds.SyncedTypes. A fourth item type can no longer be added without pinning its resource type: the coverage test fails, and the sample switch throws with an explanation. The two per-cipher tests it replaces said the same thing for hosts and keys, so nothing is lost and the credential row is no longer something someone has to remember. Verified by re-running the mutation matrix. All seven sabotages are now detected: the credential merge dropping its redaction, the key/credential exclusivity check disabled, the schema version ladder flattened so a key-bound host claims the credential version, a credential sending the server an empty fields record instead of none, CredentialKind claiming to be a host, CredentialCipher sealing under the wrong resource type, and the credential noun reading "host". Two of those were unproven before this run — one because the earlier sabotage did not compile, and one because it was genuinely undetected. Sync.Tests 88/88, Domain.Tests 117/117. Zero warnings, dotnet format clean. |
||
|
|
e24012b039 |
Sync credentials as a vault item type, and bind one to a host
Closes the largest remaining M1 gap in the data layer: a username and password can live in the vault, sync between machines, and be named by a host as how it authenticates. What is not here is the interface for creating one — see the end of this message. The third item type, and the first one that cost almost nothing to add. Server: a VaultCredential row, an EF configuration, a migration, and a CredentialKind. Client: a secret, a codec, a merge, a cipher, a kind, a repository facade and a session property. No new reconciliation logic, no change to the sync engine, no client cache migration. That was the whole point of the item-kind seam, and this is the evidence it holds. The narrowest type of the three on plaintext, and not for symmetry. A host has a deliberate concession — the relay needs an address it can resolve. A key has a fingerprint, public by nature, which this client still declines to send. A password has no part that is safe to expose: not its length, not a hash, not a hint. So CredentialKind refuses every plaintext field there is, hydrates none, and the table has no column to put one in. HostSecret.CredentialId is the password counterpart of SshKeyId, and the two are mutually exclusive. SSH itself would happily try a key and fall back to a password, but a host naming both leaves "how does this authenticate?" without a single answer — the interface, the connect path and the user would each be free to guess differently. TryValidate refuses it. One consequence was not anticipated: "a full host" stops being a coherent idea, which is what broke AFullHost_RoundTrips and is now written into that test. The schema version became a ladder rather than a maximum: credential-bound is 3, key-bound is 2, neither is still 1. Adding credentials therefore does not drag every key-bound host in every vault onto a version that clients understanding keys perfectly well would refuse to edit. A test pins exactly that, because it is the property the whole content-dependent-version rule exists to provide, and the obvious implementation would quietly lose it. Two tests had become false and said so: - Push_AnUnsupportedEntityType_IsInvalidNotAFailedBatch used Credential as its example of a type this server does not implement. It now asks the server's own registry what is still missing, so it cannot go stale again, and skips with a reason if that set ever empties. - ThePullFilterNamesEveryTypeThisBuildSynchronises pinned the exact list, which is what it is for. Also fixes ten nullable warnings — eight in SyncEndpointTests, two in a test file added earlier today. Neither set was introduced here; both were invisible until an unrelated change forced their project to recompile, which means the zero-warning claims made earlier in this work only ever covered what happened to be rebuilt. 777 tests green. Zero warnings, dotnet format clean. Not done, and deliberately: the credential interface. The vault column is 340 pixels wide and already holds two lists and two editors, kept from clipping its own buttons at the window's minimum height only by the one-editor-at-a-time rule added earlier today. A third list and a third editor would recreate that defect rather than avoid it, so the column needs a shape decision first. Credentials sync; they cannot yet be created in the interface. |
||
|
|
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. |
||
|
|
c4dbd85da0 |
Add the client's SSH key model, codec, merge and cipher
The client can now seal and open an SSH key item. Nothing consumes it yet — the repository, the sync engine's per-type handling and the UI come next — but this is the layer everything above it depends on, and it is the layer where the crypto has to be right. SshKeySecret holds the private key as an ordinary string, deliberately, and says so: a .NET string cannot be wiped, so the material lives until the GC reuses the memory. libsodium's guarded memory was considered and rejected because the passphrase protecting the key, the password on the next item and the JSON the codec just parsed are all strings on the same heap — protecting one field among them reads as security and buys nothing. What the design does give is that the key never reaches the disk in plaintext, never reaches the server at all, and is handed to SSH.NET through a MemoryStream so there is no temporary key file to leak. Validation refuses a public key by name. ssh-keygen writes two files whose names differ by four characters, and pasting the wrong one otherwise produces a vault item that looks fine and fails at connection time with an authentication error that says nothing about which file you chose. The merge redacts the private key and its passphrase from the conflict log. A host conflict shows both values so the loser can be put back; doing that for a private key would write the discarded key into a log that is designed to be read rather than used and is deliberately retained after acknowledgement. Two different private keys are not something anyone reconciles by reading them side by side. And the lesson worth recording, because it nearly shipped: the first version of AadResourceTypeTests proved nothing. It checked that a key payload does not open as a host and vice versa — true however both ciphers are misconfigured, because Seal and TryOpen share one constant, so changing it changes both and the round trip still works. Sealing every private key as if it were a vault passed all twelve tests. The tests now open a sealed payload independently through ItemKeys with the resource type named out of band, and that does fail under the same sabotage. A test that only compares an implementation against itself cannot catch a self-consistent mistake. The trap it defends: SyncEntityType.SshKey is 3, AadResourceType.SshKey is 6, because the crypto enum also carries None, User, Device and Vault ahead of the item types. A cast between them is a specification violation that encrypts cleanly and would only surface when another implementation refused the item. |
||
|
|
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. |