Rebuild as a hexagonal Bitwarden vault-management toolkit - #5
Merged
Conversation
added 11 commits
August 16, 2026 14:06
The prototype on archive/v0-copilot built cleanly and did nothing useful: IBwRunner returned Task<int> exit codes rather than data, so there was no domain model and no way to act on an item programmatically. It also passed the master password as a command-line argument, visible in ps, and carried seven classes duplicated across two projects. Replaced with Domain <- Application <- Infrastructure <- Presentation, enforced by ArchUnitNET rather than convention. The duplicate ruleset is the substance. A shared password is deliberately not treated as evidence of a duplicate: on the vault this was built for, one password covered hundreds of unrelated accounts, so matching credentials only ever promote a group that a stronger signal has already established. Two regression tests pin the failure modes found while prototyping the analysis -- a single known brand among unrelated sites must not make them related, and one credential across distinct hosts is a homelab, not a duplicate. Merges are survivor-first and verified before any delete, so a failure cannot leave data in neither item.
build and publish, targeting the GitFlow branch set. The YAML is generated from Build.CI.GitHubActions.cs and must not be hand-edited.
The CLI is the wrong shape for reviewing 800 items, so the vault browser gets its own presentation project: unlock screen, folder tree, item list, detail pane. Process-per-call is also the wrong transport behind a GUI -- every read paid a fresh ~0.5s Node start-up. BwServeVaultClient talks to the local Vault Management API instead: one child process, HTTP for everything. Both adapters satisfy the same IVaultClient port, so the CLI keeps the process-per-call one (a one-shot command has no reason to open a port) and the desktop takes the server. That API is unauthenticated -- anything that can reach the port reads the whole vault. Mitigated structurally: loopback only, a random ephemeral port rather than the well-known 8087, and the process is killed on dispose so the window is exactly the app's lifetime. Unlock is a separate IVaultSession port because only the desktop takes a master password; the CLI inherits a session from the environment. The password reaches bw through --passwordenv or an HTTP body, never argv. Verified against bw 2026.7.0 rather than the docs, which caught that /status nests its payload under data.template while every other endpoint puts it directly in data. Avalonia 11.3 rather than 12.x: Diagnostics and much of the ecosystem still track 11, and DevTools is worth more here than being current.
The app froze on launch. AddBitwardenServe started the server from a DI factory with StartAsync().GetAwaiter().GetResult(); the first service is resolved on the UI thread, where Avalonia has installed a SynchronizationContext, so the awaits inside start-up tried to resume on the thread already blocked waiting for them. A sample confirmed it: the main thread sat in Monitor_Wait and never reached the event loop, which is what macOS reports as "not responding". The 30s timeout could not fire either -- the timing loop needed the same continuation. BwServeConnection now starts the server on first awaited use behind an async gate, and ShutdownRequested cancels itself, awaits, and then shuts down for real. No sync-over-async remains anywhere. A test resolves the graph against a non-existent executable and fails if resolution launches or waits on anything. Separately: killing the app left bw serve running, holding an unauthenticated port onto an unlocked vault for as long as the machine stayed up. ProcessExit alone did not land in time on a signal, so cleanup is also hooked through PosixSignalRegistration. Also fixed detail-pane sections binding Count to IsVisible -- an int where a bool is required, which Avalonia reports at runtime and which left those sections stuck. Avalonia 12 per preference. Only Watermark -> PlaceholderText needed changing. Avalonia.Diagnostics has no 12.x release so DevTools is dropped rather than mixing major versions.
Items are no longer login-only: cards, identities, secure notes and SSH keys are modelled, along with password history, the per-cipher key, reprompt and collection ids. The new payloads redact in ToString the way LoginDetails already did -- a card number, a CVV, an SSH private key and a historical password are all exactly as leakable as a password. Folders get file-explorer behaviour, which is more involved than it looks because Bitwarden has no hierarchy. "Homelab/Proxmox" is one folder whose name contains a slash, not a child of "Homelab", and "Homelab" need not exist. Every tree operation is therefore a bulk rename, and FolderPaths plans the whole set -- deepest-first so no two folders ever momentarily share a name -- refusing collisions and self-nesting before writing anything. Descendancy compares whole segments so "Homelab2" is never dragged along with "Homelab"; that one has a test. Icons come from Bitwarden's icon service with a coloured-initial fallback. Each lookup tells that service a domain is in this vault, so only the registrable domain is sent, results and misses are cached for a month, the cache filenames are hashed, and the whole thing is one flag away from off. Drag-and-drop uses Avalonia 12's DataFormat.CreateInProcessFormat, which carries the object itself rather than a serialized blob -- so vault data in a drag cannot reach another application.
GitHub's secret scanning and push protection are free for public repos only; this is a private repo on a free-tier org, and the API refuses outright: "Secret scanning is not available for this repository" (422). So the guard is client-side, the same substitution the sibling repos make for branch protection. The specific accident worth preventing: a `bw list items` dump is every password, TOTP seed and note in plaintext, and git keeps it in history even after a later delete. .gitignore covers the known filenames passively; the pre-commit hook scans staged content actively. The scanner is deliberately narrow. Every pattern is either an exact vendor token shape or a structure with no innocent explanation -- a Bitwarden EncString, a decrypted item carrying a password, a private key block, an assigned BW_SESSION. No entropy heuristics, because those generate the false positives that train people to pass --no-verify, and a habitually bypassed hook is worse than no hook. Verified against 13 cases -- eight that must block, five that must not -- and against all 99 tracked files with no false positives.
Two things were fine in a private repo and are not fine in a public one, both in documentation rather than code: A real self-hosted hostname was used as the worked example for what an icon lookup discloses. Publishing it would have disclosed exactly the thing the paragraph warns about. And the rationale for not treating a shared password as evidence of a duplicate cited a specific count from the vault this was built against. On a public repo under the author's own name that is a statement about their own security posture, and those credentials are not rotated yet. The engineering point stands without the number.
A draft is the editable result of a merge: the resolved value for every property, where each came from, and which item it gets written to -- an existing member, or a new one. It lives in Application rather than the view because it decides what gets overwritten, and that has to be testable without a UI. Two properties earn their keep. Default() reproduces the additive merge exactly, so approving from the queue and opening the editor and changing nothing produce the same item -- otherwise the queue's preview would be lying. And Overwrites reports only what a draft *replaces*, since additions are cheap and reversible while replacements are the part worth reading. Targeting a new item is refused outright when any member holds an attachment: the CLI cannot move one, so creating a third item and deleting the sources would destroy the file. A displaced password is pushed onto Bitwarden's own passwordHistory rather than dropped. Picking the wrong side of a credential conflict is the one way this tool can lose something irreplaceable, and this gives a second recovery path that does not expire with the 30-day trash window. One bug found by its own test: Overwrites masked passwords before comparing them, so two different secrets of the same length compared equal and a replaced password was reported as no change. Comparison is on real values now; masking applies only to what is reported.
The queue is the fast path. It splits groups by whether they actually need a human: mergeable groups whose members already agree on username and password have nothing but cosmetic decisions left -- which name, which folder, which URIs -- and get one click, or one click for all of them. Groups with a real credential conflict or a blocking warning open the editor instead. That split follows from the data rather than taste. Same-site and same-brand grouping both require identical credentials, so the password cannot differ in a mergeable group; on the vault this was built against it differs in none of them. The dangerous decision lives only in the conflict groups. The editor is members-rail, compare pane, result pane. A rail rather than a second pane because real groups run to five members and a strict two-pane layout has nowhere to put the rest -- for the common two-member case it reads as an ordinary side-by-side. Scalars take either side's value or one typed in; collections get per-element checkboxes, since "additive" only means anything for a collection. Identical rows are hidden by default because most properties agree and showing them buries the ones that don't. Secrets stay masked unless revealed: deciding which value wins rarely needs either read. The target radio offers any member or a new item, with the new-item option disabling itself when an attachment is present and saying why.
Dragging any row aborted the process: SIGABRT out of AppKit with "There are 0 items on the pasteboard, but 1 drag images." The payload rides DataFormat.CreateInProcessFormat, chosen so vault data in a drag can never reach another application. That works exactly as advertised -- the data never touches the system pasteboard -- but macOS still asks AppKit to attach a drag image, AppKit finds an empty pasteboard, and raises NSGenericException, which takes the process down with it. Adds one pasteboard-visible text item alongside the in-process payload, purely so the platform has something to hang the image on. It is a fixed marker rather than the item's name: that text is the only part of a drag another application can see, and an item name can itself be sensitive. Dropping a vault row into a text editor yields "BitwardenSharp item". Found by using the thing. The prior smoke test confirmed the app launches and the UI thread stays live, which says nothing about whether a feature works.
The repository builds warning-free; these slipped in with the specs.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Rebuilds this repository into a working vault-management toolkit. The prototype it replaces is
preserved on the
archive/v0-copilottag.What lands
A hexagonal solution —
Domain ← Application ← Infrastructure ← Presentation, enforced byArchUnitNET rather than convention. Fallout drives the build; the workflows are generated from
Build.CI.GitHubActions.cs.Duplicate detection across five categories, with only same-site and same-brand/family groups
treated as mergeable. Two rules earn their own regression tests:
hundreds of unrelated accounts, so matching credentials only ever promote a group some stronger
signal already established. An earlier rule accepted a group when any one domain belonged to a
known service family, which swept unrelated live accounts together.
delete the inventory of every machine but one.
A merge engine that writes, reads back to verify, and only then soft-deletes — so no data ever
exists in neither item. A displaced password is pushed onto Bitwarden's
passwordHistoryratherthan dropped, giving a recovery path that outlives the 30-day trash window.
Two transports behind one port: process-per-call
bwfor the CLI, and the local VaultManagement API from
bw servefor the desktop app, which outlives every call.A CLI (
bwsharp scan/merge, dry-run by default) and an Avalonia 12 desktop app — unlock,three-pane vault browser, folder management with drag-and-drop, website icons, a duplicate queue
and a three-pane merge editor.
A pre-commit secret guard, because a
bw list itemsdump is the whole vault in plaintext andgit keeps it in history after a later delete.
Handling secrets
Process arguments are world-readable via
ps, so no secret ever becomes one: the session keytravels in the child's environment, the base64 item payload for
bw editis piped to stdin, andthe master password goes through
--passwordenvor an HTTP body. Records holding credentialsoverride
ToStringto redact — the generated one would print a password into any log line thattouched an item.
Known issues, filed not fixed
None affect the CLI or the duplicate screens.
Verification
./build.shgreen87/6/12/4/47 groups, 113 deletions
Not yet exercised
No merge has been applied to a real vault. Every write path is covered by tests against a fake
client, and the CLI and queue both dry-run by default, but the first real write is still ahead.