Skip to content

Rebuild as a hexagonal Bitwarden vault-management toolkit - #5

Merged
ChrisonSimtian merged 11 commits into
mainfrom
develop
Aug 16, 2026
Merged

Rebuild as a hexagonal Bitwarden vault-management toolkit#5
ChrisonSimtian merged 11 commits into
mainfrom
develop

Conversation

@ChrisonSimtian

Copy link
Copy Markdown
Collaborator

Rebuilds this repository into a working vault-management toolkit. The prototype it replaces is
preserved on the archive/v0-copilot tag.

What lands

A hexagonal solutionDomain ← Application ← Infrastructure ← Presentation, enforced by
ArchUnitNET 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:

  • A shared password is not evidence of a duplicate. In a real vault one password can cover
    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.
  • One credential across several distinct hosts is a homelab, not a duplicate. Merging those would
    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 passwordHistory rather
than dropped, giving a recovery path that outlives the 30-day trash window.

Two transports behind one port: process-per-call bw for the CLI, and the local Vault
Management API from bw serve for 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 items dump is the whole vault in plaintext and
git 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 key
travels in the child's environment, the base64 item payload for bw edit is piped to stdin, and
the master password goes through --passwordenv or an HTTP body. Records holding credentials
override ToString to redact — the generated one would print a password into any log line that
touched an item.

Known issues, filed not fixed

None affect the CLI or the duplicate screens.

Verification

  • 87 tests, zero warnings, ./build.sh green
  • The C# scanner reproduces the earlier independent analysis of a real 818-item vault exactly:
    87/6/12/4/47 groups, 113 deletions
  • Desktop app exercised by hand: unlock, browse, folder drag-and-drop, duplicate queue, merge editor

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.

Christian Simon 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.
@ChrisonSimtian
ChrisonSimtian merged commit 27309ed into main Aug 16, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant