diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..514f237 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,20 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "husky": { + "version": "0.9.1", + "commands": [ + "husky" + ], + "rollForward": false + }, + "fallout.globaltool": { + "version": "10.4.0", + "commands": [ + "fallout" + ], + "rollForward": true + } + } +} diff --git a/.fallout/.gitignore b/.fallout/.gitignore new file mode 100644 index 0000000..36445e5 --- /dev/null +++ b/.fallout/.gitignore @@ -0,0 +1 @@ +/temp diff --git a/.fallout/build.schema.json b/.fallout/build.schema.json new file mode 100644 index 0000000..472ce39 --- /dev/null +++ b/.fallout/build.schema.json @@ -0,0 +1,113 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "definitions": { + "Host": { + "type": "string", + "enum": [ + "AppVeyor", + "AzurePipelines", + "Bamboo", + "Bitbucket", + "Bitrise", + "GitHubActions", + "GitLab", + "Jenkins", + "Rider", + "SpaceAutomation", + "TeamCity", + "Terminal", + "TravisCI", + "VisualStudio", + "VSCode" + ] + }, + "ExecutableTarget": { + "type": "string", + "enum": [ + "Compile", + "Test", + "TestLive" + ] + }, + "Verbosity": { + "type": "string", + "description": "", + "enum": [ + "Verbose", + "Normal", + "Minimal", + "Quiet" + ] + }, + "FalloutBuild": { + "properties": { + "Continue": { + "type": "boolean", + "description": "Indicates to continue a previously failed build attempt" + }, + "Help": { + "type": "boolean", + "description": "Shows the help text for this build assembly" + }, + "Host": { + "description": "Host for execution. Default is 'automatic'", + "$ref": "#/definitions/Host" + }, + "NoLogo": { + "type": "boolean", + "description": "Disables displaying the NUKE logo" + }, + "Partition": { + "type": "string", + "description": "Partition to use on CI" + }, + "Plan": { + "type": "boolean", + "description": "Shows the execution plan (HTML)" + }, + "Profile": { + "type": "array", + "description": "Defines the profiles to load", + "items": { + "type": "string" + } + }, + "Root": { + "type": "string", + "description": "Root directory during build execution" + }, + "Skip": { + "type": "array", + "description": "List of targets to be skipped. Empty list skips all dependencies", + "items": { + "$ref": "#/definitions/ExecutableTarget" + } + }, + "Target": { + "type": "array", + "description": "List of targets to be invoked. Default is '{default_target}'", + "items": { + "$ref": "#/definitions/ExecutableTarget" + } + }, + "Verbosity": { + "description": "Logging verbosity during build execution. Default is 'Normal'", + "$ref": "#/definitions/Verbosity" + }, + "BuildProjectFile": { + "type": [ + "null", + "string" + ], + "description": "Path to the build project (.csproj) relative to the repository root. Defaults to 'build/_build.csproj' when unset. Read by the Fallout global tool's in-tool runner." + } + } + } + }, + "allOf": [ + {}, + { + "$ref": "#/definitions/FalloutBuild" + } + ] +} diff --git a/.fallout/parameters.json b/.fallout/parameters.json new file mode 100644 index 0000000..1e78227 --- /dev/null +++ b/.fallout/parameters.json @@ -0,0 +1,3 @@ +{ + "Solution": "BitwardenSharp.slnx" +} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..e9823fd --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,54 @@ +# ------------------------------------------------------------------------------ +# +# +# This code was generated. +# +# - To turn off auto-generation set: +# +# [GitHubActions (AutoGenerate = false)] +# +# - To trigger manual generation invoke: +# +# fallout --generate-configuration GitHubActions_build --host GitHubActions +# +# +# ------------------------------------------------------------------------------ + +name: build + +on: + push: + branches: + - develop + - main + pull_request: + branches: + - develop + - main + - 'release/*' + - 'hotfix/*' + - 'support/*' + +jobs: + ubuntu-latest: + name: ubuntu-latest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: 'Cache: .fallout/temp, ~/.nuget/packages' + uses: actions/cache@v6 + with: + path: | + .fallout/temp + ~/.nuget/packages + key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }} + - name: 'Setup: .NET SDK' + uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + - name: 'Restore: dotnet tools' + run: dotnet tool restore + - name: 'Run: Test' + run: dotnet fallout Test diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..0d084d3 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,48 @@ +# ------------------------------------------------------------------------------ +# +# +# This code was generated. +# +# - To turn off auto-generation set: +# +# [GitHubActions (AutoGenerate = false)] +# +# - To trigger manual generation invoke: +# +# fallout --generate-configuration GitHubActions_publish --host GitHubActions +# +# +# ------------------------------------------------------------------------------ + +name: publish + +on: + push: + tags: + - 'v*' + +jobs: + ubuntu-latest: + name: ubuntu-latest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: 'Cache: .fallout/temp, ~/.nuget/packages' + uses: actions/cache@v6 + with: + path: | + .fallout/temp + ~/.nuget/packages + key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }} + - name: 'Setup: .NET SDK' + uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + - name: 'Restore: dotnet tools' + run: dotnet tool restore + - name: 'Run: Test' + run: dotnet fallout Test + env: + NuGetApiKey: ${{ secrets.NUGET_API_KEY }} diff --git a/.gitignore b/.gitignore index ce89292..20bf2e6 100644 --- a/.gitignore +++ b/.gitignore @@ -416,3 +416,22 @@ FodyWeavers.xsd *.msix *.msm *.msp + +# ── Vault data — never commit ──────────────────────────────────────────────── +# A `bw list items` dump is the entire vault in PLAINTEXT: every password, TOTP +# seed and note. One careless `git add -A` publishes the lot, and git history +# keeps it even after a later delete. The pre-commit hook in .husky/ is the +# active guard; these patterns are the passive one. +items.json +folders.json +*vault-dump*.json +*vault-export*.json +bw-export*.json +*.bitwarden.json +REVIEW.md +report.json +merge-log.json + +# Session key / master password must never reach disk in this repo +.bw-session +secrets.env diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..4452a1e --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,10 @@ +#!/usr/bin/env sh +# Husky.NET-managed pre-commit guard. Sourcing husky.sh is optional (honors HUSKY=0 +# and re-execs under `sh -e`); the guard also runs standalone on a fresh clone +# before `dotnet husky install` has regenerated _/. +husky_sh="$(dirname -- "$0")/_/husky.sh" +[ -f "$husky_sh" ] && . "$husky_sh" + +# GitHub's secret scanning and push protection are free for public repos only, and +# this is a private repo on a free-tier org. This is the substitute. +sh "$(dirname -- "$0")/scan-secrets.sh" diff --git a/.husky/scan-secrets.sh b/.husky/scan-secrets.sh new file mode 100755 index 0000000..6bcccbe --- /dev/null +++ b/.husky/scan-secrets.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env sh +# ───────────────────────────────────────────────────────────────────────────── +# Staged-content secret scanner. +# +# Why this exists: GitHub's own secret scanning and push protection are not +# available here — they are free for public repositories only, and this is a +# private repo on a free-tier org ("Secret scanning is not available for this +# repository", HTTP 422). This hook is the substitute, in the same spirit as the +# pre-push guard in the sibling repositories. +# +# What it is guarding against specifically: this repository's whole subject is a +# password vault. 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. That is the +# accident worth spending a hook on. +# +# Design rule: NO NOISE. A hook that cries wolf is a hook everyone bypasses, and +# a habitually bypassed hook is worse than none. Every pattern below is either an +# exact vendor token shape or a structure that has no innocent explanation. +# Entropy heuristics are deliberately absent — they are the usual source of the +# false positives that kill these things. +# +# Bypass once (and think about why): git commit --no-verify +# ───────────────────────────────────────────────────────────────────────────── +set -u + +RED='\033[0;31m'; YELLOW='\033[0;33m'; DIM='\033[2m'; OFF='\033[0m' +findings=0 + +report() { + findings=$((findings + 1)) + printf "${RED}✋ %s${OFF}\n %s\n" "$1" "$2" >&2 +} + +# Staged files, added/copied/modified only — renames and deletions carry no new content. +staged=$(git diff --cached --name-only --diff-filter=ACM) +[ -z "$staged" ] && exit 0 + +for file in $staged; do + # The scanner and its documentation necessarily contain the patterns themselves. + case "$file" in + .husky/scan-secrets.sh|.gitignore|docs/security*|*.md) continue ;; + esac + + # ── 1. Filenames that are vault dumps by convention ────────────────────── + case "$(basename "$file")" in + items.json|folders.json|report.json|merge-log.json|REVIEW.md) + report "Vault dump staged: $file" \ + "This is vault data, not source. Remove it: git restore --staged '$file'" + continue + ;; + esac + + # Binary files have no text to scan. + git diff --cached --numstat -- "$file" | grep -q '^-' && continue + + added=$(git diff --cached -U0 -- "$file" | grep '^+' | grep -v '^+++') + [ -z "$added" ] && continue + + # ── 2. A Bitwarden EncString ───────────────────────────────────────────── + # ".||" — Bitwarden's own ciphertext + # format. Nothing else looks like this; its presence means vault data. + if printf '%s' "$added" | grep -Eq '[0-9]\.[A-Za-z0-9+/=]{20,}\|[A-Za-z0-9+/=]{20,}\|[A-Za-z0-9+/=]{20,}'; then + report "Bitwarden EncString in $file" \ + "That is encrypted vault content. It does not belong in source." + fi + + # ── 3. The shape of a decrypted vault item ─────────────────────────────── + # bw stamps every object it emits with "object":"item"/"folder". Combined with + # a password or TOTP field, this is a decrypted dump rather than a fixture. + if printf '%s' "$added" | grep -Eq '"object"[[:space:]]*:[[:space:]]*"(item|folder|cipherDetails)"' \ + && printf '%s' "$added" | grep -Eq '"(password|totp|privateKey)"[[:space:]]*:[[:space:]]*"[^"]{4,}'; then + report "Decrypted vault item in $file" \ + "Looks like 'bw list items' output with real secrets. Use a redacted fixture." + fi + + # ── 4. Private keys ────────────────────────────────────────────────────── + if printf '%s' "$added" | grep -Eq -- '-----BEGIN [A-Z ]*PRIVATE KEY-----'; then + report "Private key block in $file" "Never commit a private key." + fi + + # ── 5. Exact vendor token shapes ───────────────────────────────────────── + # Each of these is a documented, unambiguous prefix+length. No guessing. + if printf '%s' "$added" | grep -Eq '(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{50,}'; then + report "GitHub token in $file" "Revoke it, then remove it from the change." + fi + if printf '%s' "$added" | grep -Eq 'AKIA[0-9A-Z]{16}'; then + report "AWS access key id in $file" "Revoke it immediately." + fi + if printf '%s' "$added" | grep -Eq 'xox[baprs]-[A-Za-z0-9-]{10,}'; then + report "Slack token in $file" "Revoke it immediately." + fi + if printf '%s' "$added" | grep -Eq '(sk|rk)_(live|test)_[A-Za-z0-9]{20,}'; then + report "Stripe key in $file" "Revoke it immediately." + fi + if printf '%s' "$added" | grep -Eq 'AIza[0-9A-Za-z_-]{35}'; then + report "Google API key in $file" "Revoke it immediately." + fi + # NuGet keys matter here: the sibling repos publish packages. + if printf '%s' "$added" | grep -Eq 'oy2[a-z0-9]{43}'; then + report "NuGet API key in $file" "Revoke it on nuget.org." + fi + + # ── 6. An assigned BW_SESSION ──────────────────────────────────────────── + # The session key decrypts the entire vault. Only flagged when it is being + # given a literal value — referring to the variable is normal and fine. + if printf '%s' "$added" | grep -Eq 'BW_SESSION[[:space:]]*=[[:space:]]*["'"'"']?[A-Za-z0-9+/]{40,}={0,2}'; then + report "Hard-coded BW_SESSION in $file" \ + "That key decrypts the whole vault. Read it from the environment instead." + fi + + # ── 7. A master password assigned in code ──────────────────────────────── + if printf '%s' "$added" | grep -Eiq '(master_?password|masterpw)[[:space:]]*[=:][[:space:]]*["'"'"'][^"'"'"']{6,}'; then + report "Hard-coded master password in $file" "Never. Prompt for it." + fi +done + +if [ "$findings" -gt 0 ]; then + printf "\n${YELLOW}%s finding(s). Commit blocked.${OFF}\n" "$findings" >&2 + printf "${DIM} Genuinely a false positive? Bypass once: git commit --no-verify${OFF}\n" >&2 + printf "${DIM} If a real secret already reached a commit, rotate it — deleting it later${OFF}\n" >&2 + printf "${DIM} does not remove it from git history.${OFF}\n" >&2 + exit 1 +fi + +exit 0 diff --git a/.husky/task-runner.json b/.husky/task-runner.json new file mode 100644 index 0000000..2e9f728 --- /dev/null +++ b/.husky/task-runner.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://alirezanet.github.io/Husky.Net/schema.json", + "tasks": [] +} diff --git a/BitwardenSharp.slnx b/BitwardenSharp.slnx new file mode 100644 index 0000000..df6d3ad --- /dev/null +++ b/BitwardenSharp.slnx @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2887ab7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,57 @@ +# Changelog + +All notable changes to this project are documented here. +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project +adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Duplicate detection across five categories, with only same-site and same-brand/family groups + treated as mergeable. +- Merge engine: survivor-first, verified before any deletion, soft deletes to trash. +- `bwsharp scan` and `bwsharp merge` on Spectre.Console; dry run by default. +- `bwsharp scan --from ` for read-only analysis of a saved `bw list items` dump. +- Architecture tests enforcing the hexagon and keeping process execution inside Infrastructure. +- Avalonia desktop host: unlock screen and a three-pane vault browser (folder tree, item list, + detail pane), with passwords masked until revealed. +- `AddBitwardenServe()` — a second `IVaultClient` adapter over the local Vault Management API + from `bw serve`, on a random loopback port owned as a child process. Used by the desktop host, + where one long-lived server beats a Node start-up per call. +- `IVaultSession` port for unlock/lock, separate from `IVaultClient` so only the desktop host + depends on taking a master password. + +### Changed +- Rewritten from the pre-1.0 prototype, which returned process exit codes rather than data and had + no domain model. That work is preserved on the `archive/v0-copilot` tag. + +### Added +- Full item model: cards, identities, secure notes and SSH keys alongside logins, plus password + history, per-cipher `key`, `reprompt` and collection ids. Card numbers, CVVs, SSH private keys + and historical passwords all redact in `ToString` like login passwords already did. +- Folder create/rename/delete and drag-and-drop of items and folders, planned through + `FolderPaths` so a rename carries its subtree and self-nesting is refused before any write. +- Website icons from Bitwarden's icon service, cached on disk, with a coloured-initial fallback. + Off by one flag; see the README on what a lookup discloses. + +### Added (continued) +- Duplicate queue and three-pane merge editor in the desktop app. The queue separates groups whose + decisions are cosmetic from those with a real credential conflict; the editor handles groups of + any size, per-property choices, per-element collection inclusion, and merging into a member or + a new item. + +### Fixed +- Desktop app froze on launch. Server start-up was awaited with `GetAwaiter().GetResult()` from a + DI factory, which runs on the UI thread; the awaits inside start-up then needed the thread that + was blocked waiting for them. Start-up is lazy and asynchronous now, shutdown cancels-awaits- + reshuts rather than blocking, and a test asserts that resolving a service starts no process. +- `bw serve` was left running after an abrupt exit, keeping an unauthenticated port onto an + unlocked vault open indefinitely. Cleanup now runs on POSIX signals as well as `ProcessExit`. +- Several detail-pane sections bound `Count` (an int) to `IsVisible` (a bool), which Avalonia + reports as a binding error at runtime. + +### Security +- Secrets are never passed as process arguments: the session key travels in the child environment + and the item payload for `bw edit` is piped to stdin. The prototype passed the master password + on the command line, where `ps` exposes it to every local user. +- Records holding credentials redact them in `ToString`. diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..6031e1a --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,32 @@ + + + + + net10.0 + latest + enable + enable + + + $(MSBuildProjectName.Replace("-","_")) + true + + Chrison Simtian + Homelab + https://github.com/Chrison-dev/BitwardenSharp + https://github.com/Chrison-dev/BitwardenSharp + git + MIT + README.md + true + + embedded + + + + + + + + diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..b56e8d3 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,9 @@ + + + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..21d77c7 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,40 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md index 2a4b2ab..ffb0192 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,218 @@ -# Bitwarden -Tools around bitwarden +# BitwardenSharp + +Vault-management tooling for [Bitwarden](https://bitwarden.com) that the official CLI does not +provide: finding duplicate logins, and merging them safely. + +`bw` is a fine transport and a poor toolkit. It has no merge, no bulk operations, no dry run, and +every edit is a read → mutate → base64 → write round trip against an item it replaces wholesale. +BitwardenSharp adds the missing layer on top of it. + +``` +dotnet tool install -g BitwardenSharp.Cli + +export BW_SESSION=$(bw unlock --raw) +bwsharp scan # read-only; find and classify duplicates +bwsharp merge EXACT-001 # dry run +bwsharp merge EXACT-001 --apply # write +``` + +## What it finds + +A scan classifies every group by how safely it can be collapsed: + +| Category | Meaning | Merged? | +|---|---|---| +| `ExactDuplicate` | Same registrable domain, same username, same password | yes | +| `RelatedDomain` | Same credentials across one brand's TLDs, or one service family | yes | +| `CredentialConflict` | Same site and username, different passwords — one is stale | review | +| `InfrastructureSharedCredential` | One login reused across distinct hosts | review | +| `SameName` | Identical item name, credentials differ | review | + +Only the first two are ever merged, and a group in either is still refused when it carries a +blocking warning — an attachment (which `bw` cannot move between items) or two differing TOTP +seeds. + +**A shared password is not evidence of a duplicate.** Password reuse is common enough that in a +real vault one password can cover hundreds of unrelated accounts, so matching credentials only +ever promote a group that some stronger signal — same domain, same brand, same family — has +already established. Grouping on credentials alone proposes deleting live accounts. + +## How a merge is safe + +Merging is `edit` + `delete`, and the ordering is the safety property: + +1. Re-read the survivor and every loser from the vault. A scan is a snapshot; acting on a stale + one could overwrite a change made since. +2. Fold the losers onto the survivor — union the URIs, adopt an unambiguous TOTP seed, add custom + fields the survivor lacks, append distinct notes, fill an empty folder. Purely additive: the + survivor's own name, username and password are never overwritten. +3. Write the survivor, then **read it back and verify**. +4. Only then delete the losers, softly, to Bitwarden's trash. + +There is no point at which data exists in neither item. A failure anywhere leaves the losers in +place and the operation simply re-runnable. Deletions stay restorable for 30 days. + +Dry run is the default; `--apply` is the only way to write. + +## Offline scanning + +`bwsharp scan --from items.json` runs the same analysis over a saved `bw list items` dump without +unlocking anything. Useful for auditing an export, and for reproducing a scan against a fixed +snapshot. The file-backed vault refuses every write. + +## Architecture + +An onion, with dependencies pointing inward only — enforced by tests in +`tests/Architecture.Tests`, not by convention. + +``` +src/Domain the vault model, URI/eTLD+1 reduction, duplicate value types. Depends on nothing. +src/Application duplicate detection, survivor selection, merge planning. Owns the ports. +src/Infrastructure two adapters onto `bw`, plus the wire contracts. +src/Presentation/Cli the `bwsharp` tool, on Spectre.Console. +src/Presentation/Desktop the GUI, on Avalonia. +``` + +### Two transports + +Both implement the same `IVaultClient` port, and each host picks what suits it: + +| | `AddBitwardenCli()` | `AddBitwardenServe()` | +|---|---|---| +| How | one `bw` process per call | one long-lived `bw serve`, HTTP for everything | +| Cost | ~0.5s Node start-up **per call** | ~1.5s once | +| Exposure | none | an **unauthenticated** local port for its lifetime | +| Used by | the CLI — one-shot, so no port is worth opening | the desktop app — outlives every call | + +The Vault Management API has no authentication of any kind: anything that can reach the port +reads the whole vault while it is unlocked. The mitigations are structural — loopback only, a +random ephemeral port rather than the well-known 8087, spawned as a child process and killed on +dispose, so the window is exactly the app's lifetime. + +One shape to know about if you extend the serve adapter: `/status` nests its payload under +`data.template`, while every other endpoint puts it directly in `data`. The envelope is not +uniform. + +### Why it wraps `bw` rather than replacing it + +There is no public Bitwarden API for personal vault items — the documented `api.bitwarden.com` +surface is organisation-scoped only (members, groups, collections, policies, events). The +alternatives were to shell out to the official client, or to reimplement Bitwarden's client-side +crypto against its internal endpoints. The latter means owning Argon2id/PBKDF2 derivation and +AES-CBC-HMAC EncString handling against an unsupported, changeable API, in front of a password +vault. Not a trade worth making. + +Note also that the official `Bitwarden.Sdk` NuGet package targets Secrets Manager, not personal +vault ciphers. + +### Handling secrets + +Two rules hold throughout, because process arguments are world-readable via `ps`: + +- Arguments go through `ProcessStartInfo.ArgumentList`, never a joined, hand-escaped string. +- **Secrets never become arguments.** The session key travels in the child's environment, and the + base64 item payload for `edit` — which contains the password in clear — is piped to stdin, + which `bw` accepts in place of the positional argument. + +`LoginDetails` and `CustomField` override the compiler-generated record `ToString` to redact, +so a stray log line or exception message cannot leak a credential. + +## Desktop app + +``` +dotnet run --project src/Presentation/Desktop +``` + +Avalonia 12. Unlock screen, then a three-pane browser: the folder tree (rebuilt from Bitwarden's +slash-separated flat names), the item list, and a detail pane. Passwords are masked until +revealed, and revealing is per-view — never persisted. + +Two rules the desktop host lives by, both learned the hard way: + +- **Never block the UI thread on a task.** The first service is resolved on that thread, where + Avalonia has installed a `SynchronizationContext`. Blocking there to await server start-up + deadlocked the app before it drew its window — the awaits needed the thread that was waiting + for them. `BwServeConnection` starts the server on first *awaited* use, and + `ShutdownRequested` cancels itself, awaits, then shuts down for real. There is no + `.GetAwaiter().GetResult()` anywhere, and a test enforces that resolution starts nothing. +- **The child must die with us.** A `bw serve` orphaned by a crash keeps an unauthenticated port + onto an unlocked vault open indefinitely, so cleanup is hooked on both `ProcessExit` and + `PosixSignalRegistration` for SIGTERM/SIGINT/SIGHUP. `ProcessExit` alone was observed not to + land in time. + +### Folders behave like a file explorer + +Create, rename, delete, and drag items or whole folders between nodes. Dropping on "No folder" +unfiles items; dropping a folder on empty space moves it to the root. + +**Bitwarden has no folder hierarchy.** "Homelab/Proxmox" is a single folder whose *name* contains +a slash — it is not a child of "Homelab", and "Homelab" need not exist at all. Clients render the +implied tree; the storage is flat. Every tree operation is therefore a bulk rename: moving +"Homelab" without rewriting "Homelab/Proxmox" would show the folder moving and its contents +staying behind. `FolderPaths` plans the whole set of renames — deepest-first, so no two folders +ever momentarily share a name — and refuses collisions and self-nesting before anything is +written. Descendancy compares whole segments, so "Homelab2" is never dragged along with +"Homelab". + +Deleting a folder unfiles its items rather than deleting them, and takes the subtree with it — +otherwise "Homelab/Proxmox" would be stranded as a root folder with a slash in its name. + +### Website icons + +Items show their site's icon, falling back to a coloured initial. Bitwarden stores no icon on an +item: clients derive one from the first URI and fetch it from a hosted service, which is why an +item with no URI never has one anywhere. + +**Each lookup discloses a domain from your vault to that service**, and doing it for a whole +vault hands over a list of the sites you hold accounts with — including private ones, where the +hostname alone is information. Three things limit it: only the registrable domain is sent, never +a path; results are cached on disk for a month, misses included, so a domain is asked about once; +and the cache filenames are hashed so the directory is not itself a readable list. Set +`AddBitwardenIcons(o => o.Enabled = false)` and nothing leaves the machine. + +### Duplicate queue and merge editor + +The **Duplicates** button opens a queue of every group the scanner found, each showing the merge +it proposes. It splits them by whether they need you: + +- **Routine** — mergeable, and the members already agree on username and password, so every + decision is cosmetic: which name, which folder, which URIs. One click each, or approve the lot. +- **Needs a decision** — a real credential conflict, or a blocking warning. These open the editor. + +That split is not arbitrary. On the vault this was built against, **the password differs in zero +of the mergeable groups** — it cannot differ, because same-site and same-brand grouping both +require identical credentials. The dangerous decision only exists in the handful of +`CredentialConflict` groups, so routing everything else through a three-pane editor would be +ceremony. + +The editor is three panes: the members on the left, one of them compared in the middle, and the +resolved result on the right. The left is a rail rather than a single pane because a real vault +has groups of three to five members and a strict two-pane layout has nowhere to put the rest; for +a two-member group it reads as a plain side-by-side. + +Scalars take a value from either side or one you type. Collections — URIs, custom fields — are +unioned with per-element checkboxes, because "additive" only means something for a collection: +you cannot have two usernames. Identical rows are hidden by default, since most properties agree +and showing them buries the ones that don't. + +A radio chooses what the result becomes: any member, or a brand-new item. **New item disables +itself when any member holds an attachment** — the CLI cannot move one, so creating a third item +and deleting the sources would destroy the file. + +### Next + +Retiring dead credentials, and an item template for API keys. + +## Building + +``` +./build.sh # compile + test +./build.sh TestLive # only the tests that drive a real bw against an unlocked vault +``` + +The build is [Fallout](https://fallout.build); `.github/workflows/*.yml` is generated from +`build/Build.CI.GitHubActions.cs` and must not be hand-edited. + +## Licence + +MIT. diff --git a/build.cmd b/build.cmd new file mode 100755 index 0000000..61a428a --- /dev/null +++ b/build.cmd @@ -0,0 +1,6 @@ +:; set -eo pipefail +:; SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd) +:; exec "$SCRIPT_DIR/build.sh" "$@" + +@echo off +powershell -ExecutionPolicy ByPass -NoProfile -File "%~dp0build.ps1" %* diff --git a/build.ps1 b/build.ps1 new file mode 100755 index 0000000..1e7f33e --- /dev/null +++ b/build.ps1 @@ -0,0 +1,9 @@ +#!/usr/bin/env pwsh +# Fallout build bootstrapper. +# ./build.ps1 Compile # build the solution +# ./build.ps1 Test # build + run unit tests (default) +[CmdletBinding()] +Param([Parameter(ValueFromRemainingArguments = $true)] [string[]] $BuildArguments) +$ErrorActionPreference = 'Stop' +dotnet run --project "$PSScriptRoot/build/_build.csproj" -- @BuildArguments +exit $LASTEXITCODE diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..2c9a9b9 --- /dev/null +++ b/build.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -eo pipefail +dotnet run --project "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/build/_build.csproj" -- "$@" diff --git a/build/Build.CI.GitHubActions.cs b/build/Build.CI.GitHubActions.cs new file mode 100644 index 0000000..5c7a92f --- /dev/null +++ b/build/Build.CI.GitHubActions.cs @@ -0,0 +1,73 @@ +using Fallout.Common; +using Fallout.Common.CI.GitHubActions; + +/// +/// The CI/CD definition — every workflow this repository has, declared as an attribute. +/// +/// +/// +/// `.github/workflows/*.yml` is GENERATED from what follows. Hand-editing the YAML is +/// silently undone by the next generation. Change an attribute, then regenerate: +/// dotnet fallout --generate-configuration GitHubActions_<name> --host GitHubActions +/// (or just build the _build project, which regenerates all of them). +/// +/// +/// The build is defined in C#, not in YAML. A workflow provisions a runner and routes a channel; +/// every step that does something invokes a Fallout target — so the same commands behave +/// identically on a laptop and on a runner. +/// +/// The branch model is GitFlow, matching the other Chrison-dev repositories. +/// + +// ── The gate ────────────────────────────────────────────────────────────────── +// +// Runs on every PR into a long-lived branch, and on every push to the two permanent ones. +// Feature branches build nothing until a PR is opened. +// +// Deliberately NO path exclusions: the job name is the required status check, so a docs-only +// PR filtered out here would wait forever on a check that never fires. +[GitHubActions( + "build", + GitHubActionsImage.UbuntuLatest, + FetchDepth = 0, + OnPushBranches = new[] { DevelopBranch, MainBranch }, + OnPullRequestBranches = new[] + { + DevelopBranch, MainBranch, ReleaseBranchPattern, HotfixBranchPattern, SupportBranchPattern, + }, + InvokedTargets = new[] { nameof(Test) })] + +// ── Publish ─────────────────────────────────────────────────────────────────── +// +// The three libraries and the `bwsharp` dotnet tool go to nuget.org on a release tag. +// Tag-triggered rather than push-triggered: a version is published because it was tagged, +// never because something landed on a branch. +[GitHubActions( + "publish", + GitHubActionsImage.UbuntuLatest, + FetchDepth = 0, + OnPushTags = new[] { ReleaseTagPattern }, + InvokedTargets = new[] { nameof(Test) }, + ImportSecrets = new[] { nameof(NuGetApiKey) })] +partial class Build +{ + /// Integration branch; everything lands here first. + const string DevelopBranch = "develop"; + + /// The trunk. Only release and hotfix merges reach it. + const string MainBranch = "main"; + + /// Short-lived stabilisation window cut from . + const string ReleaseBranchPattern = "release/*"; + + /// Short-lived urgent production fix cut from . + const string HotfixBranchPattern = "hotfix/*"; + + /// Long-lived maintenance line for a release has moved past. + const string SupportBranchPattern = "support/*"; + + /// Release tags are v1.2.3; publishing is driven by these, not by branches. + const string ReleaseTagPattern = "v*"; + + [Secret] readonly string NuGetApiKey; +} diff --git a/build/Build.cs b/build/Build.cs new file mode 100644 index 0000000..33d0500 --- /dev/null +++ b/build/Build.cs @@ -0,0 +1,44 @@ +using Fallout.Common; +using Fallout.Common.IO; +using Fallout.Common.Tools.DotNet; +using static Fallout.Common.Tools.DotNet.DotNetTasks; + +/// +/// Fallout build for BitwardenSharp — the targets. The CI/CD definition that invokes them lives in +/// Build.CI.GitHubActions.cs, from which every .github/workflows/*.yml is GENERATED; +/// never hand-edit those. +/// +/// Tests that touch a real vault through the `bw` CLI are tagged [Trait("Category","Live")] and +/// EXCLUDED from the default Test run — they need an unlocked vault and mutate real data. +/// Run them deliberately: ./build.sh TestLive +/// +partial class Build : FalloutBuild +{ + public static int Main() => Execute(x => x.Test); + + AbsolutePath SolutionFile => RootDirectory / "BitwardenSharp.slnx"; + + Target Compile => _ => _ + .Description("Build the solution") + .Executes(() => DotNetBuild(_ => _ + .SetProjectFile(SolutionFile) + .SetConfiguration("Release"))); + + Target Test => _ => _ + .Description("Run the unit and architecture tests (excludes live vault tests)") + .DependsOn(Compile) + .Executes(() => DotNetTest(_ => _ + .SetProjectFile(SolutionFile) + .SetConfiguration("Release") + .SetFilter("Category!=Live") + .EnableNoBuild())); + + Target TestLive => _ => _ + .Description("Run ONLY the tests that drive a real `bw` CLI against an unlocked vault") + .DependsOn(Compile) + .Executes(() => DotNetTest(_ => _ + .SetProjectFile(SolutionFile) + .SetConfiguration("Release") + .SetFilter("Category=Live") + .EnableNoBuild())); +} diff --git a/build/_build.csproj b/build/_build.csproj new file mode 100644 index 0000000..bf8f637 --- /dev/null +++ b/build/_build.csproj @@ -0,0 +1,20 @@ + + + + Exe + net10.0 + enable + enable + false + false + _build + $(MSBuildProjectDirectory)/.. + CS0649;CS0169;CA1050;CS8618 + + + + + + + + diff --git a/global.json b/global.json new file mode 100644 index 0000000..1e7fdfa --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestMinor" + } +} diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..4d736c1 --- /dev/null +++ b/nuget.config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/Application/Abstractions/IIconProvider.cs b/src/Application/Abstractions/IIconProvider.cs new file mode 100644 index 0000000..80c2f52 --- /dev/null +++ b/src/Application/Abstractions/IIconProvider.cs @@ -0,0 +1,31 @@ +namespace BitwardenSharp.Application.Abstractions; + +/// +/// Supplies website icons for vault items. +/// +/// +/// +/// Bitwarden does not store an icon on an item. Its clients derive one from the item's first URI +/// and fetch it from a hosted icon service, which is why an item with no URI never has an icon in +/// any Bitwarden client either. +/// +/// +/// This leaks information. Asking the icon service for git.internal.example/icon.png +/// tells that service the domain is in someone's vault, and doing it for every item hands over a +/// list of the sites you hold accounts with. Implementations must therefore be switchable off, +/// must request the registrable domain only, and should cache aggressively so a domain is asked +/// for once rather than on every render. +/// +/// +public interface IIconProvider +{ + /// Whether icons are being fetched at all. + bool IsEnabled { get; } + + /// + /// The icon bytes for a domain, or null when there is none, when fetching is disabled, or + /// when the lookup failed. Callers show a placeholder for null rather than treating it as an + /// error — a missing icon is the normal case for anything self-hosted. + /// + Task GetIconAsync(string domain, CancellationToken cancellationToken = default); +} diff --git a/src/Application/Abstractions/IVaultClient.cs b/src/Application/Abstractions/IVaultClient.cs new file mode 100644 index 0000000..1d67b6b --- /dev/null +++ b/src/Application/Abstractions/IVaultClient.cs @@ -0,0 +1,84 @@ +using BitwardenSharp.Domain.Vault; + +namespace BitwardenSharp.Application.Abstractions; + +/// +/// The port onto a Bitwarden vault. Application code depends on this and never on a transport. +/// +/// +/// There is no public Bitwarden API for personal vault items — the documented api.bitwarden.com +/// surface is organisation-scoped only. Every implementation therefore goes through the official +/// bw client in some form. Reimplementing Bitwarden's client-side crypto against the +/// internal endpoints was considered and rejected: it means owning Argon2id/PBKDF2 derivation and +/// AES-CBC-HMAC EncString handling against an unsupported, changeable API, in front of a password +/// vault. +/// +public interface IVaultClient +{ + /// Pulls the latest vault state from the server. + Task SyncAsync(CancellationToken cancellationToken = default); + + /// Whether the vault is currently unlocked and usable. + Task GetStatusAsync(CancellationToken cancellationToken = default); + + Task> GetItemsAsync(CancellationToken cancellationToken = default); + + Task> GetFoldersAsync(CancellationToken cancellationToken = default); + + Task GetItemAsync(string id, CancellationToken cancellationToken = default); + + /// + /// Replaces an item wholesale and returns what the vault stored. The Bitwarden CLI has no + /// partial update: the object sent is the object kept, so callers must pass a complete item. + /// + Task UpdateItemAsync(VaultItem item, CancellationToken cancellationToken = default); + + /// + /// Deletes an item. Soft by default, which moves it to the trash and keeps it restorable for + /// 30 days — the only undo a merge has. + /// + Task DeleteItemAsync(string id, bool permanent = false, CancellationToken cancellationToken = default); + + /// + /// Creates an item and returns it as stored, with the id the vault assigned. + /// + /// + /// Used when a merge is resolved into a brand-new item rather than into one of its sources. + /// Note that attachments cannot be carried onto a created item — the CLI has no way to move + /// one — so a group holding an attachment cannot be merged this way at all. + /// + Task CreateItemAsync(VaultItem item, CancellationToken cancellationToken = default); + + /// Creates a folder. The name is the full path, e.g. "Homelab/Proxmox". + Task CreateFolderAsync(string name, CancellationToken cancellationToken = default); + + /// + /// Renames a folder to the given full path. + /// + /// + /// This renames one folder only. Because Bitwarden stores folders flat, a tree operation is + /// several of these — plan it with rather than calling + /// this directly, or descendants get left behind under the old name. + /// + Task RenameFolderAsync( + string id, string name, CancellationToken cancellationToken = default); + + /// + /// Deletes a folder. Items inside it are not deleted; Bitwarden unfiles them. + /// + Task DeleteFolderAsync(string id, CancellationToken cancellationToken = default); +} + +/// Lock state and identity of the vault behind an . +public sealed record VaultStatus +{ + public required string Status { get; init; } + + public string? UserEmail { get; init; } + + public string? ServerUrl { get; init; } + + public DateTimeOffset? LastSync { get; init; } + + public bool IsUnlocked => string.Equals(Status, "unlocked", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/Application/Abstractions/IVaultSession.cs b/src/Application/Abstractions/IVaultSession.cs new file mode 100644 index 0000000..4b86f49 --- /dev/null +++ b/src/Application/Abstractions/IVaultSession.cs @@ -0,0 +1,40 @@ +namespace BitwardenSharp.Application.Abstractions; + +/// The outcome of an unlock attempt. +public sealed record UnlockResult +{ + public required bool Succeeded { get; init; } + + /// Why it failed, safe to show a user. Null on success. + public string? Error { get; init; } + + public static UnlockResult Success() => new() { Succeeded = true }; + + public static UnlockResult Failure(string error) => new() { Succeeded = false, Error = error }; +} + +/// +/// Controls the lock state of the vault. +/// +/// +/// Separate from on purpose. Reading and merging need an unlocked +/// vault but have no business unlocking one, and the CLI presentation never unlocks at all — it +/// inherits a session from the environment. Only the desktop host takes a master password, so +/// only it depends on this. +/// +public interface IVaultSession +{ + Task GetStatusAsync(CancellationToken cancellationToken = default); + + /// + /// Unlocks the vault and retains the resulting session key for subsequent vault calls. + /// + /// + /// The password is used once and not retained. Implementations must not place it on a + /// command line — see the note on the bw adapter. + /// + Task UnlockAsync(string masterPassword, CancellationToken cancellationToken = default); + + /// Discards the session key and locks the vault. + Task LockAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Application/ApplicationServiceExtensions.cs b/src/Application/ApplicationServiceExtensions.cs new file mode 100644 index 0000000..92f82df --- /dev/null +++ b/src/Application/ApplicationServiceExtensions.cs @@ -0,0 +1,18 @@ +using BitwardenSharp.Application.Duplicates; +using BitwardenSharp.Application.Folders; +using BitwardenSharp.Application.Merging; +using Microsoft.Extensions.DependencyInjection; + +namespace BitwardenSharp.Application; + +/// Registers the vault operations. Requires an IVaultClient from Infrastructure. +public static class ApplicationServiceExtensions +{ + public static IServiceCollection AddBitwardenSharpApplication(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/Application/BitwardenSharp.Application.csproj b/src/Application/BitwardenSharp.Application.csproj new file mode 100644 index 0000000..883cfcc --- /dev/null +++ b/src/Application/BitwardenSharp.Application.csproj @@ -0,0 +1,13 @@ + + + Vault operations for BitwardenSharp — duplicate detection, survivor selection and merge planning. Owns the IVaultClient port; holds no transport of its own. + bitwarden;vault;deduplication;password-manager + + + + + + + + + diff --git a/src/Application/Duplicates/DuplicateScanResult.cs b/src/Application/Duplicates/DuplicateScanResult.cs new file mode 100644 index 0000000..2a74af8 --- /dev/null +++ b/src/Application/Duplicates/DuplicateScanResult.cs @@ -0,0 +1,23 @@ +using BitwardenSharp.Domain.Duplicates; + +namespace BitwardenSharp.Application.Duplicates; + +/// Everything one scan found. +public sealed record DuplicateScanResult +{ + public required int TotalItems { get; init; } + + public required int LoginCount { get; init; } + + public required IReadOnlyList Groups { get; init; } + + public IEnumerable Mergeable => Groups.Where(g => g.CanMerge); + + public IEnumerable NeedingReview => Groups.Where(g => !g.CanMerge); + + /// How many items would be deleted if every mergeable group were applied. + public int MergeableDeletions => Mergeable.Sum(g => g.Losers.Count()); + + public IReadOnlyDictionary CountByCategory => + Groups.GroupBy(g => g.Category).ToDictionary(g => g.Key, g => g.Count()); +} diff --git a/src/Application/Duplicates/DuplicateScanner.cs b/src/Application/Duplicates/DuplicateScanner.cs new file mode 100644 index 0000000..a7637c4 --- /dev/null +++ b/src/Application/Duplicates/DuplicateScanner.cs @@ -0,0 +1,201 @@ +using BitwardenSharp.Domain.Duplicates; +using BitwardenSharp.Domain.Uris; +using BitwardenSharp.Domain.Vault; + +namespace BitwardenSharp.Application.Duplicates; + +/// +/// Finds sets of login items that describe the same account, and classifies each set by how +/// safely it can be collapsed. +/// +/// +/// +/// The ordering of the passes matters. Each pass claims the items it groups, so a later, weaker +/// signal cannot re-group items an earlier, stronger one already explained. +/// +/// +/// A shared password is not evidence of a duplicate. Password reuse is common enough that +/// on a real vault a single password can cover hundreds of unrelated accounts, so "same +/// credentials" only ever promotes a group that some other signal — same registrable target, same +/// brand, same service family — has already established. An earlier version of this rule accepted +/// a group when *any one* of its domains belonged to a known family, which swept unrelated sites +/// together and proposed deleting live accounts. See the RelatedCredentials pass. +/// +/// +public sealed class DuplicateScanner +{ + /// Groups the login items in , strongest signal first. + public DuplicateScanResult Scan(IReadOnlyList items) + { + ArgumentNullException.ThrowIfNull(items); + + var logins = items + .Where(i => i.Type == ItemType.Login && i.Login is not null) + .OrderBy(i => i.Id, StringComparer.Ordinal) + .ToList(); + + var targets = logins.ToDictionary( + i => i.Id, + i => i.Uris.Select(u => UriTarget.Parse(u.Uri)).OfType().Distinct().ToList()); + + var claimed = new HashSet(StringComparer.Ordinal); + var seen = new HashSet(StringComparer.Ordinal); + var groups = new List(); + var counters = new Dictionary(); + + void Emit(DuplicateCategory category, string key, IReadOnlyList members, bool claim) + { + var signature = Signature(members); + if (members.Count < 2 || !seen.Add(signature)) return; + + counters[category] = counters.GetValueOrDefault(category) + 1; + groups.Add(Build($"{Prefix(category)}-{counters[category]:D3}", category, key, members, targets)); + if (claim) foreach (var m in members) claimed.Add(m.Id); + } + + // ── Pass 1: same target, same username, same password ──────────────────────────────── + // The strongest signal there is. Everything here is one account the vault recorded twice. + var byTargetUser = new Dictionary<(string Target, string User), List>(); + foreach (var item in logins) + { + var user = item.Login!.NormalisedUsername; + if (user is null || item.Login.PasswordFingerprint is null) continue; + foreach (var target in targets[item.Id]) + byTargetUser.GetOrAdd((target.Value, user)).Add(item); + } + + var conflicts = new List<((string Target, string User) Key, List Members)>(); + foreach (var (key, members) in byTargetUser.OrderBy(kv => kv.Key)) + { + var distinct = members.DistinctBy(m => m.Id).ToList(); + if (distinct.Count < 2) continue; + + var byPassword = distinct.GroupBy(m => m.Login!.PasswordFingerprint!).ToList(); + foreach (var sharing in byPassword.Where(g => g.Count() > 1)) + Emit(DuplicateCategory.ExactDuplicate, $"{key.Target} · {key.User}", [.. sharing], claim: true); + + // Same door, same name, different keys — one of them is stale. Held for pass 3 so + // that any exact duplicates inside it are recognised as such first. + if (byPassword.Count > 1) conflicts.Add((key, distinct)); + } + + // ── Pass 2: same credentials across related targets ────────────────────────────────── + var byCredential = new Dictionary<(string User, string Password), List>(); + foreach (var item in logins) + { + var user = item.Login!.NormalisedUsername; + var password = item.Login.PasswordFingerprint; + if (user is null || password is null) continue; + byCredential.GetOrAdd((user, password)).Add(item); + } + + foreach (var (key, members) in byCredential.OrderBy(kv => kv.Key)) + { + if (members.Count < 2) continue; + + var distinctTargets = members.SelectMany(m => targets[m.Id]).Distinct().ToList(); + if (distinctTargets.Count < 2) continue; + + var infrastructure = distinctTargets + .Where(t => t.Kind is UriTargetKind.IpAddress or UriTargetKind.Host) + .ToList(); + var domains = distinctTargets.Where(t => t.Kind == UriTargetKind.Domain).ToList(); + + if (infrastructure.Count > 1) + { + // Distinct machines that happen to share a login. Merging would delete the + // inventory of every host but one. + Emit(DuplicateCategory.InfrastructureSharedCredential, + $"{key.User} · {infrastructure.Count} hosts", members, claim: false); + continue; + } + + // Related only if EVERY domain agrees — one shared brand, or one shared family with + // no unrecognised brand among them. Anything else is reuse across unrelated sites. + var brands = domains.Select(d => d.Brand).Distinct().ToList(); + var families = domains.Select(ServiceFamily.ForTarget).ToList(); + + var sameBrand = brands.Count == 1; + var sameFamily = domains.Count > 0 + && families.All(f => f is not null) + && families.Distinct().Count() == 1; + + if (sameBrand || sameFamily) + Emit(DuplicateCategory.RelatedDomain, + $"{key.User} · {string.Join(", ", distinctTargets.Select(t => t.Value).Order())}", + members, claim: true); + } + + // ── Pass 3: same target and username, different passwords ──────────────────────────── + foreach (var (key, members) in conflicts) + Emit(DuplicateCategory.CredentialConflict, + $"{key.Target} · {key.User}", members, claim: false); + + // ── Pass 4: identical name, nothing else in common ─────────────────────────────────── + foreach (var byName in logins + .Where(i => !string.IsNullOrWhiteSpace(i.Name)) + .GroupBy(i => i.Name.Trim(), StringComparer.OrdinalIgnoreCase) + .OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase)) + { + var members = byName.ToList(); + if (members.Count < 2 || members.Any(m => claimed.Contains(m.Id))) continue; + Emit(DuplicateCategory.SameName, byName.Key, members, claim: false); + } + + return new DuplicateScanResult + { + TotalItems = items.Count, + LoginCount = logins.Count, + Groups = groups, + }; + } + + private static string Signature(IEnumerable members) => + string.Join('|', members.Select(m => m.Id).Order(StringComparer.Ordinal)); + + private static string Prefix(DuplicateCategory category) => category switch + { + DuplicateCategory.ExactDuplicate => "EXACT", + DuplicateCategory.RelatedDomain => "RELATED", + DuplicateCategory.CredentialConflict => "CONFLICT", + DuplicateCategory.InfrastructureSharedCredential => "INFRA", + DuplicateCategory.SameName => "NAME", + _ => "GROUP", + }; + + private static DuplicateGroup Build( + string id, + DuplicateCategory category, + string key, + IReadOnlyList members, + IReadOnlyDictionary> targets) + { + // Richest survives: everything the others hold can be copied onto it, and an attachment + // cannot be moved between items at all. Newest revision breaks a tie. + var survivor = members + .OrderByDescending(m => m.Richness) + .ThenByDescending(m => m.RevisionDate ?? DateTimeOffset.MinValue) + .ThenBy(m => m.Id, StringComparer.Ordinal) + .First(); + + return new DuplicateGroup + { + Id = id, + Category = category, + Key = key, + Survivor = survivor, + Members = members, + Warnings = MergeWarnings.For(members, survivor), + }; + } +} + +internal static class DictionaryExtensions +{ + public static List GetOrAdd( + this Dictionary> source, TKey key) where TKey : notnull + { + if (source.TryGetValue(key, out var existing)) return existing; + return source[key] = []; + } +} diff --git a/src/Application/Duplicates/MergeWarnings.cs b/src/Application/Duplicates/MergeWarnings.cs new file mode 100644 index 0000000..9f4c525 --- /dev/null +++ b/src/Application/Duplicates/MergeWarnings.cs @@ -0,0 +1,78 @@ +using BitwardenSharp.Domain.Duplicates; +using BitwardenSharp.Domain.Vault; + +namespace BitwardenSharp.Application.Duplicates; + +/// Works out what a human needs to know before approving a group. +internal static class MergeWarnings +{ + public static IReadOnlyList For(IReadOnlyList members, VaultItem survivor) + { + var warnings = new List(); + + // Blocking. `bw` exposes no way to move an attachment between items, so merging would + // delete the file along with its item. Refuse rather than lose it. + var withAttachments = members.Where(m => m.Attachments.Count > 0).ToList(); + if (withAttachments.Count > 0) + { + var total = withAttachments.Sum(m => m.Attachments.Count); + warnings.Add(new MergeWarning( + "attachments", + $"{total} attachment(s) across {withAttachments.Count} item(s); the CLI cannot move " + + "attachments between items, so these must be handled by hand") + { IsBlocking = true }); + } + + // Blocking. Two different second factors for one account means one of them is wrong, and + // guessing costs the account. + var seeds = members + .Select(m => m.Login?.Totp) + .Where(t => !string.IsNullOrWhiteSpace(t)) + .Distinct(StringComparer.Ordinal) + .ToList(); + if (seeds.Count > 1) + { + warnings.Add(new MergeWarning( + "totp-conflict", + $"{seeds.Count} differing TOTP seeds in this group; resolve which is current before merging") + { IsBlocking = true }); + } + else if (seeds.Count == 1 && string.IsNullOrWhiteSpace(survivor.Login?.Totp)) + { + warnings.Add(new MergeWarning( + "totp-transfer", + "the TOTP seed is on an item being deleted and will be carried onto the survivor")); + } + + var notes = members + .Select(m => m.Notes?.Trim()) + .Where(n => !string.IsNullOrEmpty(n)) + .Distinct(StringComparer.Ordinal) + .ToList(); + if (notes.Count > 1) + warnings.Add(new MergeWarning("notes", $"{notes.Count} differing notes will be concatenated")); + + var clashing = members + .SelectMany(m => m.Fields) + .Where(f => !string.IsNullOrWhiteSpace(f.Name)) + .GroupBy(f => f.Name!, StringComparer.OrdinalIgnoreCase) + .Where(g => g.Select(f => f.Value).Distinct(StringComparer.Ordinal).Count() > 1) + .Select(g => g.Key) + .ToList(); + if (clashing.Count > 0) + warnings.Add(new MergeWarning( + "field-conflict", + $"custom field(s) with the same name but different values: {string.Join(", ", clashing)}; " + + "the survivor's value is kept")); + + var folders = members + .Select(m => m.FolderId) + .Where(f => !string.IsNullOrWhiteSpace(f)) + .Distinct(StringComparer.Ordinal) + .ToList(); + if (folders.Count > 1) + warnings.Add(new MergeWarning("folder-span", $"members span {folders.Count} different folders")); + + return warnings; + } +} diff --git a/src/Application/Folders/FolderService.cs b/src/Application/Folders/FolderService.cs new file mode 100644 index 0000000..7d504ab --- /dev/null +++ b/src/Application/Folders/FolderService.cs @@ -0,0 +1,137 @@ +using BitwardenSharp.Application.Abstractions; +using BitwardenSharp.Domain.Vault; +using Microsoft.Extensions.Logging; + +namespace BitwardenSharp.Application.Folders; + +/// The result of a folder operation, in terms the UI can show directly. +public sealed record FolderOperationResult +{ + public required bool Succeeded { get; init; } + public string? Error { get; init; } + + /// How many folders were renamed. A subtree move touches more than one. + public int FoldersChanged { get; init; } + + /// How many items were moved between folders. + public int ItemsChanged { get; init; } + + public static FolderOperationResult Failure(string error) => new() { Succeeded = false, Error = error }; +} + +/// +/// File-explorer operations over Bitwarden's flat folder list. +/// +/// +/// Every method here plans the whole operation with before writing +/// anything, so an invalid gesture — a name collision, a folder dropped into its own subtree — +/// is refused with the vault untouched rather than half-applied. +/// +public sealed class FolderService(IVaultClient vault, ILogger? logger = null) +{ + public async Task CreateAsync( + string? parentPath, + string leafName, + CancellationToken cancellationToken = default) + { + var folders = await vault.GetFoldersAsync(cancellationToken); + var plan = FolderPaths.PlanCreate(folders, parentPath, leafName); + if (!plan.IsValid) return FolderOperationResult.Failure(plan.Error!.Message); + + var name = plan.Renames[0].NewName; + await vault.CreateFolderAsync(name, cancellationToken); + logger?.LogInformation("Created folder {Name}", name); + + return new FolderOperationResult { Succeeded = true, FoldersChanged = 1 }; + } + + /// Renames a folder's own segment, carrying its whole subtree. + public async Task RenameAsync( + string folderId, + string newLeafName, + CancellationToken cancellationToken = default) + { + var folders = await vault.GetFoldersAsync(cancellationToken); + return await ApplyAsync(FolderPaths.PlanRename(folders, folderId, newLeafName), cancellationToken); + } + + /// Moves a folder under a new parent, or to the root when the path is null. + public async Task MoveAsync( + string folderId, + string? newParentPath, + CancellationToken cancellationToken = default) + { + var folders = await vault.GetFoldersAsync(cancellationToken); + return await ApplyAsync(FolderPaths.PlanMove(folders, folderId, newParentPath), cancellationToken); + } + + /// + /// Deletes a folder. + /// + /// + /// Bitwarden unfiles the items inside rather than deleting them, which is the behaviour we + /// want. Descendant folders are separate records and are not removed automatically — + /// deleting "Homelab" would strand "Homelab/Proxmox" as a root-level folder with a slash in + /// its name. deletes the subtree instead, deepest first. + /// + public async Task DeleteAsync( + string folderId, + bool includeDescendants = true, + CancellationToken cancellationToken = default) + { + var folders = await vault.GetFoldersAsync(cancellationToken); + var target = folders.FirstOrDefault(f => f.Id == folderId); + if (target is null) return FolderOperationResult.Failure("That folder no longer exists."); + + var doomed = new List { target }; + if (includeDescendants) + doomed.AddRange(folders.Where(f => FolderPaths.IsDescendantOf(f.Name, target.Name))); + + // Deepest first, so the tree never passes through a state with a parentless child. + foreach (var folder in doomed.OrderByDescending(f => FolderPaths.Segments(f.Name).Count)) + { + await vault.DeleteFolderAsync(folder.Id, cancellationToken); + logger?.LogInformation("Deleted folder {Name}", folder.Name); + } + + return new FolderOperationResult { Succeeded = true, FoldersChanged = doomed.Count }; + } + + /// Moves items into a folder, or out of any folder when the id is null. + public async Task MoveItemsAsync( + IReadOnlyList itemIds, + string? targetFolderId, + CancellationToken cancellationToken = default) + { + var moved = 0; + foreach (var id in itemIds) + { + var item = await vault.GetItemAsync(id, cancellationToken); + if (item.FolderId == targetFolderId) continue; + + await vault.UpdateItemAsync(item with { FolderId = targetFolderId }, cancellationToken); + moved++; + } + + logger?.LogInformation("Moved {Count} item(s) to folder {FolderId}", moved, targetFolderId ?? ""); + return new FolderOperationResult { Succeeded = true, ItemsChanged = moved }; + } + + private async Task ApplyAsync( + FolderPlan plan, + CancellationToken cancellationToken) + { + if (!plan.IsValid) return FolderOperationResult.Failure(plan.Error!.Message); + if (plan.Renames.Count == 0) return new FolderOperationResult { Succeeded = true }; + + // The plan orders descendants deepest-first; applying in that order means no two folders + // ever momentarily share a name. + foreach (var rename in plan.Renames) + { + await vault.RenameFolderAsync(rename.FolderId, rename.NewName, cancellationToken); + logger?.LogInformation("Renamed folder {Old} -> {New}", rename.OldName, rename.NewName); + } + + return new FolderOperationResult { Succeeded = true, FoldersChanged = plan.Renames.Count }; + } +} diff --git a/src/Application/Merging/MergeBuilder.cs b/src/Application/Merging/MergeBuilder.cs new file mode 100644 index 0000000..9bab86e --- /dev/null +++ b/src/Application/Merging/MergeBuilder.cs @@ -0,0 +1,171 @@ +using BitwardenSharp.Domain.Vault; + +namespace BitwardenSharp.Application.Merging; + +/// The merged item, and a human-readable account of what changed to produce it. +public sealed record MergeResult(VaultItem Merged, IReadOnlyList Changes); + +/// +/// Folds the losers of a duplicate group onto its survivor. Purely additive: the survivor's own +/// name, username, password and folder are never overwritten, so the worst case of a wrong +/// grouping is an item carrying a URI it did not need — not a changed credential. +/// +public static class MergeBuilder +{ + internal const string NoteSeparator = "\n\n--- merged ---\n"; + + /// + /// Builds the item a resolved describes. + /// + /// + /// + /// Unlike the additive overload, this one may replace the target's own values — that is the + /// point of the editor. The one replacement worth special handling is the password: when a + /// draft displaces one, the displaced value is pushed onto + /// rather than dropped. + /// + /// + /// That matters because picking the wrong side of a credential conflict is the single way this + /// tool can lose something irreplaceable. The losing item goes to the trash and is restorable + /// for 30 days; the displaced password stays on the surviving item indefinitely. Two + /// independent recovery paths for one irreversible decision. + /// + /// + public static MergeResult Build(MergeDraft draft) + { + ArgumentNullException.ThrowIfNull(draft); + + var changes = new List(); + var target = draft.TargetItem; + + // A new item inherits the type from the group and nothing else; there is no prior state. + var basis = target ?? new VaultItem + { + Id = string.Empty, + Type = draft.Group.Survivor.Type, + Name = draft.Name.Value, + }; + + var history = basis.PasswordHistory.ToList(); + if (target is not null + && !string.IsNullOrEmpty(target.Login?.Password) + && !string.Equals(target.Login.Password, draft.Password.Value, StringComparison.Ordinal)) + { + history.Insert(0, new PasswordHistoryEntry + { + Password = target.Login.Password, + LastUsedDate = target.Login.PasswordRevisionDate ?? target.RevisionDate, + }); + changes.Add("password replaced (previous value kept in password history)"); + } + + foreach (var (field, _, _) in draft.Overwrites.Where(o => o.Field != "Password")) + changes.Add($"{field} replaced"); + + var addedUris = draft.Uris.Count - basis.Uris.Count; + if (addedUris > 0) changes.Add($"+{addedUris} uri(s)"); + + var addedFields = draft.Fields.Count - basis.Fields.Count; + if (addedFields > 0) changes.Add($"+{addedFields} custom field(s)"); + + var login = basis.Login ?? new LoginDetails(); + var merged = basis with + { + Name = draft.Name.Value, + FolderId = draft.FolderId.Value, + Notes = string.IsNullOrWhiteSpace(draft.Notes.Value) ? null : draft.Notes.Value, + Favorite = draft.Favorite.Value, + Fields = draft.Fields, + PasswordHistory = history, + Login = basis.Type == ItemType.Login || draft.Group.Survivor.Type == ItemType.Login + ? login with + { + Username = draft.Username.Value, + Password = draft.Password.Value, + Totp = draft.Totp.Value, + Uris = draft.Uris, + } + : basis.Login, + }; + + return new MergeResult(merged, changes); + } + + public static MergeResult Build(VaultItem survivor, IReadOnlyList losers) + { + ArgumentNullException.ThrowIfNull(survivor); + ArgumentNullException.ThrowIfNull(losers); + + var changes = new List(); + var login = survivor.Login ?? new LoginDetails(); + + // ── URIs: union, keeping each entry's own match rule ────────────────────────────────── + var uris = login.Uris.ToList(); + var seenUris = new HashSet( + uris.Select(u => u.Uri.Trim()), StringComparer.OrdinalIgnoreCase); + foreach (var uri in losers.SelectMany(l => l.Uris)) + { + if (string.IsNullOrWhiteSpace(uri.Uri) || !seenUris.Add(uri.Uri.Trim())) continue; + uris.Add(uri); + changes.Add($"+uri {uri.Uri}"); + } + + // ── TOTP: adopt only when unambiguous ───────────────────────────────────────────────── + var totp = login.Totp; + if (string.IsNullOrWhiteSpace(totp)) + { + var seeds = losers + .Select(l => l.Login?.Totp) + .Where(t => !string.IsNullOrWhiteSpace(t)) + .Distinct(StringComparer.Ordinal) + .ToList(); + + // Two differing seeds is a blocking warning upstream; refusing again here keeps the + // builder correct on its own, without depending on the caller having checked. + if (seeds.Count == 1) + { + totp = seeds[0]; + changes.Add("+totp (adopted from a deleted item)"); + } + } + + // ── Custom fields: add only names the survivor lacks ────────────────────────────────── + var fields = survivor.Fields.ToList(); + var seenFields = new HashSet( + fields.Select(f => f.Name ?? string.Empty), StringComparer.OrdinalIgnoreCase); + foreach (var field in losers.SelectMany(l => l.Fields)) + { + if (!seenFields.Add(field.Name ?? string.Empty)) continue; + fields.Add(field); + changes.Add($"+field {field.Name}"); + } + + // ── Notes: append anything not already present ──────────────────────────────────────── + var notes = survivor.Notes?.Trim() ?? string.Empty; + foreach (var loser in losers) + { + var note = loser.Notes?.Trim(); + if (string.IsNullOrEmpty(note) || notes.Contains(note, StringComparison.Ordinal)) continue; + notes = notes.Length == 0 ? note : notes + NoteSeparator + note; + changes.Add($"+notes from \"{loser.Name}\""); + } + + // ── Folder: only ever fills a gap ───────────────────────────────────────────────────── + var folderId = survivor.FolderId; + if (string.IsNullOrWhiteSpace(folderId)) + { + folderId = losers.Select(l => l.FolderId).FirstOrDefault(f => !string.IsNullOrWhiteSpace(f)); + if (!string.IsNullOrWhiteSpace(folderId)) changes.Add("+folder (adopted from a deleted item)"); + } + + var merged = survivor with + { + FolderId = folderId, + Notes = notes.Length == 0 ? null : notes, + Fields = fields, + Login = login with { Uris = uris, Totp = totp }, + }; + + return new MergeResult(merged, changes); + } +} diff --git a/src/Application/Merging/MergeDraft.cs b/src/Application/Merging/MergeDraft.cs new file mode 100644 index 0000000..0be3121 --- /dev/null +++ b/src/Application/Merging/MergeDraft.cs @@ -0,0 +1,196 @@ +using BitwardenSharp.Domain.Duplicates; +using BitwardenSharp.Domain.Vault; + +namespace BitwardenSharp.Application.Merging; + +/// Where a resolved merge is written. +public sealed record MergeTarget +{ + /// The id of the member being kept, or null to create a brand-new item. + public string? ItemId { get; init; } + + public bool IsNewItem => ItemId is null; + + public static MergeTarget Existing(string itemId) => new() { ItemId = itemId }; + + public static readonly MergeTarget NewItem = new(); +} + +/// Where a resolved value came from, so the UI can show provenance. +public enum ValueOrigin +{ + /// Every member agreed; there was nothing to decide. + Unanimous, + + /// Taken from one member. + Member, + + /// Typed by hand — a value that exists in no member. + Edited, + + /// Built from several members, e.g. unioned URIs or concatenated notes. + Combined, +} + +/// A resolved scalar, with where it came from. +public sealed record Resolved(T Value, ValueOrigin Origin, string? SourceItemId = null) +{ + public static Resolved Unanimous(T value) => new(value, ValueOrigin.Unanimous); + + public static Resolved From(T value, string itemId) => new(value, ValueOrigin.Member, itemId); + + public static Resolved Edited(T value) => new(value, ValueOrigin.Edited); + + public static Resolved Combined(T value) => new(value, ValueOrigin.Combined); +} + +/// +/// The editable result of a merge: what the surviving item will contain, and where it is written. +/// +/// +/// +/// This is the model behind the three-pane editor, and it lives in Application rather than the +/// view because it is the thing that decides what gets overwritten. It must be testable without a +/// UI — this is the operation that can destroy data. +/// +/// +/// Unlike 's additive default, a draft may replace the survivor's own +/// values, including its password. See for how that is surfaced, and +/// for how a displaced password is preserved. +/// +/// +public sealed record MergeDraft +{ + public required DuplicateGroup Group { get; init; } + + public required MergeTarget Target { get; init; } + + public required Resolved Name { get; init; } + public Resolved Username { get; init; } = Resolved.Unanimous(null); + public Resolved Password { get; init; } = Resolved.Unanimous(null); + public Resolved Totp { get; init; } = Resolved.Unanimous(null); + public Resolved FolderId { get; init; } = Resolved.Unanimous(null); + public Resolved Notes { get; init; } = Resolved.Unanimous(null); + public Resolved Favorite { get; init; } = Resolved.Unanimous(false); + + public IReadOnlyList Uris { get; init; } = []; + public IReadOnlyList Fields { get; init; } = []; + + /// The member being kept, or null when the target is a new item. + public VaultItem? TargetItem => + Target.IsNewItem ? null : Group.Members.FirstOrDefault(m => m.Id == Target.ItemId); + + /// Members that will be deleted once the merge is applied. + public IEnumerable Doomed => + Group.Members.Where(m => Target.IsNewItem || m.Id != Target.ItemId); + + /// + /// Whether the merge may be resolved into a brand-new item. + /// + /// + /// It may not when any member holds an attachment: the CLI cannot move an attachment between + /// items, so creating a third item and deleting the sources would destroy the file. Merging + /// into the member that holds it is still fine. + /// + public bool CanTargetNewItem => Group.Members.All(m => m.Attachments.Count == 0); + + public string? NewItemBlockedReason => CanTargetNewItem + ? null + : "One of these items has an attachment, and the Bitwarden CLI cannot move attachments " + + "between items. Merge into the item that holds it instead."; + + /// + /// Values on the target that this draft would replace, as (field, before, after). + /// + /// + /// Empty for a new item — nothing exists to overwrite. This is what the confirmation step + /// shows: additions are cheap and reversible, replacements are the part worth reading. + /// + public IReadOnlyList<(string Field, string? Before, string? After)> Overwrites + { + get + { + var target = TargetItem; + if (target is null) return []; + + var changes = new List<(string, string?, string?)>(); + + // Named 'label' rather than 'field': inside a property accessor, C# 14 treats + // 'field' as a contextual keyword for the backing field. + // + // The comparison is always on the real values and the masking is applied only to what + // is reported. Masking first would compare bullet strings, and two different secrets + // of the same length would then look identical — a replaced password reported as no + // change at all. + void Compare(string label, string? before, string? after, bool secret = false) + { + if (string.Equals(before ?? string.Empty, after ?? string.Empty, StringComparison.Ordinal)) + return; + changes.Add(secret + ? (label, Mask(before), Mask(after)) + : (label, before, after)); + } + + Compare("Name", target.Name, Name.Value); + Compare("Username", target.Login?.Username, Username.Value); + Compare("Password", target.Login?.Password, Password.Value, secret: true); + Compare("TOTP", target.Login?.Totp, Totp.Value, secret: true); + Compare("Notes", target.Notes, Notes.Value); + + return changes; + } + } + + /// Whether this draft changes the target's password, which is the risky case. + public bool ReplacesPassword => + TargetItem is { Login: not null } target + && !string.IsNullOrEmpty(target.Login.Password) + && !string.Equals(target.Login.Password, Password.Value, StringComparison.Ordinal); + + private static string? Mask(string? secret) => + string.IsNullOrEmpty(secret) ? null : new string('•', Math.Min(secret.Length, 12)); + + /// + /// The draft the wizard opens with: today's additive merge, expressed as explicit decisions. + /// + /// + /// Deliberately identical in outcome to + /// so that "approve the default" in the fast path and "open the editor and change nothing" + /// produce the same item. Scalars come from the survivor; collections are unioned. + /// + public static MergeDraft Default(DuplicateGroup group) + { + ArgumentNullException.ThrowIfNull(group); + + var survivor = group.Survivor; + var others = group.Losers.ToList(); + + var (merged, _) = MergeBuilder.Build(survivor, others); + + return new MergeDraft + { + Group = group, + Target = MergeTarget.Existing(survivor.Id), + Name = Agreed(group, m => m.Name) ?? Resolved.From(merged.Name, survivor.Id), + Username = Agreed(group, m => m.Login?.Username) + ?? Resolved.From(merged.Login?.Username, survivor.Id), + Password = Agreed(group, m => m.Login?.Password) + ?? Resolved.From(merged.Login?.Password, survivor.Id), + Totp = Agreed(group, m => m.Login?.Totp) + ?? Resolved.Combined(merged.Login?.Totp), + FolderId = Agreed(group, m => m.FolderId) + ?? Resolved.From(merged.FolderId, survivor.Id), + Notes = Agreed(group, m => m.Notes) ?? Resolved.Combined(merged.Notes), + Favorite = Agreed(group, m => m.Favorite) ?? Resolved.From(merged.Favorite, survivor.Id), + Uris = merged.Uris, + Fields = merged.Fields, + }; + } + + /// A resolved value when every member already agrees, otherwise null. + private static Resolved? Agreed(DuplicateGroup group, Func select) + { + var values = group.Members.Select(select).Distinct().ToList(); + return values.Count == 1 ? Resolved.Unanimous(values[0]) : null; + } +} diff --git a/src/Application/Merging/MergeExecutor.cs b/src/Application/Merging/MergeExecutor.cs new file mode 100644 index 0000000..07c5b17 --- /dev/null +++ b/src/Application/Merging/MergeExecutor.cs @@ -0,0 +1,243 @@ +using BitwardenSharp.Application.Abstractions; +using BitwardenSharp.Domain.Duplicates; +using BitwardenSharp.Domain.Vault; +using Microsoft.Extensions.Logging; + +namespace BitwardenSharp.Application.Merging; + +/// What happened to one group. +public enum MergeStatus +{ + /// Survivor updated and every loser deleted. + Merged, + + /// Refused before any write: the group is not mergeable. + Skipped, + + /// + /// The survivor did not read back with the merged content, so no loser was deleted. + /// Nothing was lost. + /// + VerificationFailed, + + /// The vault rejected a call. Any loser not yet deleted is untouched. + Failed, +} + +public sealed record MergeOutcome +{ + public required string GroupId { get; init; } + + public required MergeStatus Status { get; init; } + + public IReadOnlyList Changes { get; init; } = []; + + public IReadOnlyList DeletedItemIds { get; init; } = []; + + public string? Message { get; init; } +} + +/// +/// Applies merges against a vault. +/// +/// +/// +/// The write order is the safety property. For each group the survivor is updated first, then +/// read back and verified, and only then are the losers deleted. There is therefore no +/// moment at which a URI, seed or note exists in neither item: if anything fails, it fails with +/// the losers still present and the operation is simply re-runnable. +/// +/// +/// Items are re-read from the vault immediately before merging rather than reused from the scan. +/// A scan is a snapshot, and acting on a stale one could overwrite a change made in the meantime. +/// +/// +public sealed class MergeExecutor(IVaultClient vault, ILogger? logger = null) +{ + /// + /// Applies a resolved draft — the editor's path, where the target may be any member of the + /// group or a brand-new item, and values may be replaced rather than only added. + /// + /// + /// The ordering guarantee is the same in all three cases: the surviving item is written and + /// read back before any source is deleted. Creating a new item adds one failure mode — the + /// create succeeds and a delete then fails, leaving three items rather than one. That is + /// deliberately the direction the bias runs: a leftover duplicate is an annoyance, a lost + /// item is not. + /// + public async Task ApplyAsync( + MergeDraft draft, + bool dryRun = true, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(draft); + + if (draft.Target.IsNewItem && !draft.CanTargetNewItem) + return new MergeOutcome + { + GroupId = draft.Group.Id, + Status = MergeStatus.Skipped, + Message = draft.NewItemBlockedReason, + }; + + try + { + var (merged, changes) = MergeBuilder.Build(draft); + var doomed = draft.Doomed.ToList(); + + if (dryRun) + return new MergeOutcome + { + GroupId = draft.Group.Id, Status = MergeStatus.Merged, Changes = changes, + }; + + string survivingId; + if (draft.Target.IsNewItem) + { + var created = await vault.CreateItemAsync(merged, cancellationToken); + survivingId = created.Id; + changes = [.. changes, "created as a new item"]; + } + else + { + // Re-read: the scan is a snapshot and the item may have changed since. + await vault.GetItemAsync(merged.Id, cancellationToken); + await vault.UpdateItemAsync(merged, cancellationToken); + survivingId = merged.Id; + } + + var readBack = await vault.GetItemAsync(survivingId, cancellationToken); + if (!Verifies(readBack, merged)) + { + logger?.LogError( + "Group {GroupId}: survivor {ItemId} did not verify; nothing was deleted", + draft.Group.Id, survivingId); + return new MergeOutcome + { + GroupId = draft.Group.Id, + Status = MergeStatus.VerificationFailed, + Changes = changes, + Message = draft.Target.IsNewItem + ? "the new item did not read back as written; the originals were left alone" + : "the survivor did not read back as written; nothing was deleted", + }; + } + + var deleted = new List(); + foreach (var item in doomed) + { + await vault.DeleteItemAsync(item.Id, permanent: false, cancellationToken); + deleted.Add(item.Id); + logger?.LogInformation("Group {GroupId}: deleted {ItemId} to trash", draft.Group.Id, item.Id); + } + + return new MergeOutcome + { + GroupId = draft.Group.Id, + Status = MergeStatus.Merged, + Changes = changes, + DeletedItemIds = deleted, + }; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger?.LogError(ex, "Group {GroupId}: merge failed", draft.Group.Id); + return new MergeOutcome + { + GroupId = draft.Group.Id, Status = MergeStatus.Failed, Message = ex.Message, + }; + } + } + + /// + /// Whether what the vault stored matches what we asked it to store, on the fields a merge can + /// change. Checked before any deletion, so a silent write failure costs nothing. + /// + private static bool Verifies(VaultItem stored, VaultItem intended) + { + var storedUris = stored.Uris.Select(u => u.Uri.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase); + return intended.Uris.Select(u => u.Uri.Trim()).All(storedUris.Contains) + && string.Equals(stored.Name, intended.Name, StringComparison.Ordinal) + && string.Equals(stored.Login?.Password, intended.Login?.Password, StringComparison.Ordinal); + } + + /// + /// Applies one group. performs every read and computes the merge + /// but issues no write. + /// + public async Task ApplyAsync( + DuplicateGroup group, + bool dryRun = true, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(group); + + if (!group.CanMerge) + { + var blocking = group.Warnings.Where(w => w.IsBlocking).Select(w => w.Message); + return new MergeOutcome + { + GroupId = group.Id, + Status = MergeStatus.Skipped, + Message = group.Category.Disposition() == MergeDisposition.ReviewOnly + ? $"{group.Category} is review-only and is never merged automatically" + : string.Join("; ", blocking), + }; + } + + try + { + // Re-read: the scan is a snapshot and may be stale. + var survivor = await vault.GetItemAsync(group.Survivor.Id, cancellationToken); + var losers = new List(); + foreach (var loser in group.Losers) + losers.Add(await vault.GetItemAsync(loser.Id, cancellationToken)); + + var (merged, changes) = MergeBuilder.Build(survivor, losers); + + if (dryRun) + return new MergeOutcome { GroupId = group.Id, Status = MergeStatus.Merged, Changes = changes }; + + await vault.UpdateItemAsync(merged, cancellationToken); + + // Verify before deleting anything. + var readBack = await vault.GetItemAsync(merged.Id, cancellationToken); + var stored = readBack.Uris.Select(u => u.Uri.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase); + var expected = merged.Uris.Select(u => u.Uri.Trim()); + if (!expected.All(stored.Contains)) + { + logger?.LogError( + "Group {GroupId}: survivor {ItemId} did not verify after update; losers left in place", + group.Id, merged.Id); + return new MergeOutcome + { + GroupId = group.Id, + Status = MergeStatus.VerificationFailed, + Changes = changes, + Message = "survivor did not read back with the merged URIs; nothing was deleted", + }; + } + + var deleted = new List(); + foreach (var loser in losers) + { + await vault.DeleteItemAsync(loser.Id, permanent: false, cancellationToken); + deleted.Add(loser.Id); + logger?.LogInformation("Group {GroupId}: deleted {ItemId} to trash", group.Id, loser.Id); + } + + return new MergeOutcome + { + GroupId = group.Id, + Status = MergeStatus.Merged, + Changes = changes, + DeletedItemIds = deleted, + }; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger?.LogError(ex, "Group {GroupId}: merge failed", group.Id); + return new MergeOutcome { GroupId = group.Id, Status = MergeStatus.Failed, Message = ex.Message }; + } + } +} diff --git a/src/Domain/BitwardenSharp.Domain.csproj b/src/Domain/BitwardenSharp.Domain.csproj new file mode 100644 index 0000000..5eac3af --- /dev/null +++ b/src/Domain/BitwardenSharp.Domain.csproj @@ -0,0 +1,6 @@ + + + Vault model for BitwardenSharp — items, logins, URIs, folders and the duplicate-analysis value types. Depends on nothing. + bitwarden;vault;password-manager + + diff --git a/src/Domain/Duplicates/DuplicateCategory.cs b/src/Domain/Duplicates/DuplicateCategory.cs new file mode 100644 index 0000000..71728cf --- /dev/null +++ b/src/Domain/Duplicates/DuplicateCategory.cs @@ -0,0 +1,61 @@ +namespace BitwardenSharp.Domain.Duplicates; + +/// Why a set of items was grouped, and therefore what may safely be done with it. +public enum DuplicateCategory +{ + /// + /// Same registrable target, same username, same password. One account recorded more than + /// once — almost always a browser importing each subdomain it saw as its own entry. + /// + ExactDuplicate, + + /// + /// Same username and password across different but genuinely related domains — the same + /// brand under two TLDs, or two front doors onto one . + /// Merging keeps every URI so autofill still fires on all of them. + /// + RelatedDomain, + + /// + /// Same target and username but the passwords differ. One is stale; which one cannot be + /// determined from the vault alone. Never merged automatically. + /// + CredentialConflict, + + /// + /// One credential reused across several distinct hosts or IPs. These are separate machines + /// that happen to share a login, not duplicates — merging them would delete the inventory. + /// Reported so the sprawl is visible, never merged. + /// + InfrastructureSharedCredential, + + /// + /// Identical item name but the credentials differ. Too weak to act on alone; surfaced for a + /// human to look at. + /// + SameName, +} + +/// What the tool is willing to do with a group without being told twice. +public enum MergeDisposition +{ + /// Safe to merge once the operator has approved the group. + Mergeable, + + /// Reported only. Requires a human decision that the vault data cannot supply. + ReviewOnly, +} + +public static class DuplicateCategoryExtensions +{ + /// + /// Whether a category may ever be merged. Kept beside the enum rather than decided at the + /// call site so no future code path can quietly treat a conflict as mergeable. + /// + public static MergeDisposition Disposition(this DuplicateCategory category) => category switch + { + DuplicateCategory.ExactDuplicate => MergeDisposition.Mergeable, + DuplicateCategory.RelatedDomain => MergeDisposition.Mergeable, + _ => MergeDisposition.ReviewOnly, + }; +} diff --git a/src/Domain/Duplicates/DuplicateGroup.cs b/src/Domain/Duplicates/DuplicateGroup.cs new file mode 100644 index 0000000..bca3556 --- /dev/null +++ b/src/Domain/Duplicates/DuplicateGroup.cs @@ -0,0 +1,48 @@ +using BitwardenSharp.Domain.Vault; + +namespace BitwardenSharp.Domain.Duplicates; + +/// Something about a group that a human should read before approving it. +public sealed record MergeWarning(string Code, string Message) +{ + /// + /// A warning that makes the group unmergeable outright, rather than merely worth reading. + /// + public bool IsBlocking { get; init; } +} + +/// +/// A set of items believed to describe one account, with the merge already worked out: which +/// item survives, which are deleted, and what has to be carried across first. +/// +public sealed record DuplicateGroup +{ + /// Stable within one scan, e.g. EXACT-007. Used to approve groups by name. + public required string Id { get; init; } + + public required DuplicateCategory Category { get; init; } + + /// What the members had in common — the grouping key, for display. + public required string Key { get; init; } + + /// The item that will be kept and updated. Always a member of . + public required VaultItem Survivor { get; init; } + + public required IReadOnlyList Members { get; init; } + + public IReadOnlyList Warnings { get; init; } = []; + + /// The items that would be deleted, in the order they would be deleted. + public IEnumerable Losers => Members.Where(m => m.Id != Survivor.Id); + + /// + /// Whether this group can be applied. A mergeable category still refuses when a blocking + /// warning is present — an attachment on a loser, for instance, which the CLI cannot move. + /// + public bool CanMerge => + Category.Disposition() == MergeDisposition.Mergeable + && !Warnings.Any(w => w.IsBlocking); + + public override string ToString() => + $"{Id} [{Category}] {Key} — keep {Survivor.Name}, drop {Losers.Count()}"; +} diff --git a/src/Domain/Uris/PublicSuffix.cs b/src/Domain/Uris/PublicSuffix.cs new file mode 100644 index 0000000..b030eb8 --- /dev/null +++ b/src/Domain/Uris/PublicSuffix.cs @@ -0,0 +1,42 @@ +namespace BitwardenSharp.Domain.Uris; + +/// +/// The multi-label public suffixes needed to find the registrable domain (eTLD+1). +/// +/// +/// +/// This is a curated subset, not the full Public Suffix List. The full list is ~10k entries that +/// change monthly and would need a fetched, versioned data file; the failure mode of a missing +/// entry here is narrow and safe — foo.co.zz would reduce to co.zz instead of +/// foo.co.zz, which can only make two items look MORE alike, and every merge is reviewed +/// before it is applied. +/// +/// +/// Weighted towards where the vault actually lives: New Zealand, the UK, Germany and Australia. +/// Add entries as they come up rather than importing the world. +/// +/// +public static class PublicSuffix +{ + private static readonly HashSet MultiLabel = new(StringComparer.OrdinalIgnoreCase) + { + // New Zealand + "co.nz", "net.nz", "org.nz", "govt.nz", "ac.nz", "school.nz", "geek.nz", "kiwi.nz", + // United Kingdom + "co.uk", "org.uk", "ac.uk", "gov.uk", "me.uk", "net.uk", "plc.uk", "ltd.uk", + // Australia + "com.au", "net.au", "org.au", "edu.au", "gov.au", "asn.au", "id.au", + // Japan + "co.jp", "or.jp", "ne.jp", "ac.jp", "go.jp", + // Rest of the world, as encountered + "com.br", "com.cn", "net.cn", "org.cn", "gov.cn", "edu.cn", + "co.za", "org.za", "co.in", "net.in", "org.in", "com.mx", "com.ar", "com.co", + "co.kr", "or.kr", "com.sg", "com.hk", "com.tw", "co.il", "com.tr", "co.id", + "com.my", "co.th", "com.ph", "com.vn", "com.pk", "com.eg", "com.sa", "com.ua", + "com.pl", "com.ru", "com.es", "co.ke", "com.ng", "com.pe", "com.ve", "com.uy", + "co.at", "co.hu", "com.de", "com.ee", "com.hr", "com.gr", "com.cy", "com.mt", + }; + + /// True when the final two labels of a host form a known multi-label suffix. + public static bool IsMultiLabelSuffix(string lastTwoLabels) => MultiLabel.Contains(lastTwoLabels); +} diff --git a/src/Domain/Uris/ServiceFamily.cs b/src/Domain/Uris/ServiceFamily.cs new file mode 100644 index 0000000..0cbdf3e --- /dev/null +++ b/src/Domain/Uris/ServiceFamily.cs @@ -0,0 +1,48 @@ +namespace BitwardenSharp.Domain.Uris; + +/// +/// Groups brands that are one account behind different front doors — gmail.com and youtube.com +/// are one Google login, live.com and outlook.com are one Microsoft login. +/// +/// +/// Membership here is a claim that a single set of credentials genuinely signs into all of them. +/// It is deliberately conservative: a wrong entry causes two real, separate accounts to be +/// proposed for merge, and the loser is deleted. When in doubt, leave a brand out — the cost of +/// omission is one duplicate that survives, the cost of a false entry is a lost account. +/// +public static class ServiceFamily +{ + private static readonly Dictionary BrandToFamily = BuildIndex(new() + { + ["google"] = ["google", "gmail", "googlemail", "youtube", "googleapis", "blogger", "firebase"], + ["microsoft"] = ["microsoft", "live", "outlook", "hotmail", "office", "office365", "msn", + "azure", "xbox", "skype", "sharepoint", "microsoftonline", "onedrive"], + ["amazon"] = ["amazon", "audible", "primevideo", "kindle"], + ["apple"] = ["apple", "icloud", "itunes"], + ["meta"] = ["facebook", "instagram", "whatsapp", "messenger", "meta", "oculus"], + ["atlassian"] = ["atlassian", "jira", "confluence", "bitbucket", "trello"], + ["valve"] = ["steampowered", "steamcommunity", "valvesoftware", "steam"], + ["ubisoft"] = ["ubisoft", "ubi", "uplay"], + ["ea"] = ["origin", "eaplay"], + ["adobe"] = ["adobe", "behance"], + ["sony"] = ["sony", "playstation", "sonyentertainmentnetwork"], + ["proton"] = ["proton", "protonvpn", "protonmail"], + ["x"] = ["twitter"], + }); + + private static Dictionary BuildIndex(Dictionary families) + { + var index = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (family, brands) in families) + foreach (var brand in brands) + index[brand] = family; + return index; + } + + /// The family a brand belongs to, or null when it is not part of a known one. + public static string? ForBrand(string? brand) => + brand is not null && BrandToFamily.TryGetValue(brand, out var family) ? family : null; + + /// The family a target belongs to, or null for IPs, hosts, apps and unknown brands. + public static string? ForTarget(UriTarget target) => ForBrand(target.Brand); +} diff --git a/src/Domain/Uris/UriTarget.cs b/src/Domain/Uris/UriTarget.cs new file mode 100644 index 0000000..927fa9e --- /dev/null +++ b/src/Domain/Uris/UriTarget.cs @@ -0,0 +1,90 @@ +using System.Net; + +namespace BitwardenSharp.Domain.Uris; + +/// What a stored URI actually points at. +public enum UriTargetKind +{ + /// A registrable internet domain, e.g. digikey.co.nz. + Domain, + + /// A literal IP address. Almost always a homelab box. + IpAddress, + + /// A dotless hostname — localhost, synology, an mDNS name. + Host, + + /// A native app identifier from an androidapp:// or iosapp:// URI. + App, +} + +/// +/// A stored URI reduced to the thing worth comparing: its registrable domain, IP, bare host or +/// app id. Two logins are candidates for the same account only if their targets agree. +/// +public sealed record UriTarget(UriTargetKind Kind, string Value) +{ + private static readonly string[] Schemes = + [ + "http://", "https://", "ftp://", "ssh://", "sftp://", "androidapp://", "android://", + "iosapp://", "otpauth://", "chrome://", "moz-extension://", "file://", + ]; + + /// + /// Reduces a raw stored URI to its comparable target, or null when there is nothing to compare. + /// + public static UriTarget? Parse(string? uri) + { + if (string.IsNullOrWhiteSpace(uri)) return null; + + var value = uri.Trim(); + + // Native app identifiers are their own namespace: "com.google.android.gm" is not a domain + // and must never be folded together with google.com by the domain rules below. + foreach (var appScheme in (string[])["androidapp://", "android://", "iosapp://"]) + { + if (value.StartsWith(appScheme, StringComparison.OrdinalIgnoreCase)) + { + var app = value[appScheme.Length..].Trim('/'); + return app.Length == 0 ? null : new UriTarget(UriTargetKind.App, app.ToLowerInvariant()); + } + } + + foreach (var scheme in Schemes) + { + if (value.StartsWith(scheme, StringComparison.OrdinalIgnoreCase)) + { + value = value[scheme.Length..]; + break; + } + } + + // Strip path, query and fragment. + value = value.Split('/')[0].Split('?')[0].Split('#')[0]; + + // Strip any userinfo, then the port. + var at = value.LastIndexOf('@'); + if (at >= 0) value = value[(at + 1)..]; + value = value.Split(':')[0].Trim().TrimEnd('.').ToLowerInvariant(); + + if (value.Length == 0) return null; + + if (IPAddress.TryParse(value, out _)) return new UriTarget(UriTargetKind.IpAddress, value); + + if (!value.Contains('.')) return new UriTarget(UriTargetKind.Host, value); + + var labels = value.Split('.'); + if (labels.Length >= 3 && PublicSuffix.IsMultiLabelSuffix($"{labels[^2]}.{labels[^1]}")) + return new UriTarget(UriTargetKind.Domain, string.Join('.', labels[^3..])); + + return new UriTarget(UriTargetKind.Domain, string.Join('.', labels[^2..])); + } + + /// + /// The leading label of a registrable domain — digikey.co.nz gives digikey. + /// Null for anything that is not a domain, so IPs never compare as brands. + /// + public string? Brand => Kind == UriTargetKind.Domain ? Value.Split('.')[0] : null; + + public override string ToString() => $"{Value} ({Kind})"; +} diff --git a/src/Domain/Vault/CustomField.cs b/src/Domain/Vault/CustomField.cs new file mode 100644 index 0000000..0a1f0c6 --- /dev/null +++ b/src/Domain/Vault/CustomField.cs @@ -0,0 +1,19 @@ +namespace BitwardenSharp.Domain.Vault; + +/// A user-defined field on an item. +public sealed record CustomField +{ + public string? Name { get; init; } + + public string? Value { get; init; } + + public FieldType Type { get; init; } + + public string? LinkedId { get; init; } + + /// + /// Redacts : a field may be , and this is + /// exactly where API keys and recovery codes end up. + /// + public override string ToString() => $"CustomField {{ Name = {Name}, Type = {Type} }}"; +} diff --git a/src/Domain/Vault/FieldType.cs b/src/Domain/Vault/FieldType.cs new file mode 100644 index 0000000..983ba0a --- /dev/null +++ b/src/Domain/Vault/FieldType.cs @@ -0,0 +1,10 @@ +namespace BitwardenSharp.Domain.Vault; + +/// Custom-field kind, using the CLI's own numeric values. +public enum FieldType +{ + Text = 0, + Hidden = 1, + Boolean = 2, + Linked = 3, +} diff --git a/src/Domain/Vault/FolderPaths.cs b/src/Domain/Vault/FolderPaths.cs new file mode 100644 index 0000000..6a2939d --- /dev/null +++ b/src/Domain/Vault/FolderPaths.cs @@ -0,0 +1,166 @@ +namespace BitwardenSharp.Domain.Vault; + +/// One folder that has to be renamed to carry out a tree operation. +public sealed record FolderRename(string FolderId, string OldName, string NewName); + +/// Why a folder operation was refused, in words a user can act on. +public sealed record FolderOperationError(string Message); + +/// A planned tree operation: the renames to apply, or the reason it cannot be done. +public sealed record FolderPlan +{ + public IReadOnlyList Renames { get; init; } = []; + + public FolderOperationError? Error { get; init; } + + public bool IsValid => Error is null; + + public static FolderPlan Invalid(string message) => new() { Error = new FolderOperationError(message) }; + + public static FolderPlan Of(IReadOnlyList renames) => new() { Renames = renames }; +} + +/// +/// Tree operations over Bitwarden's flat folder list. +/// +/// +/// +/// Bitwarden has no folder hierarchy. "Homelab/Proxmox" is a single folder whose name +/// contains a slash; it is not a child of "Homelab", and "Homelab" need not even exist. Clients +/// render the implied tree, but the storage is flat. +/// +/// +/// The consequence is that every tree operation is a bulk rename. Renaming "Homelab" to "Lab" +/// leaves "Homelab/Proxmox" untouched unless it is renamed too — the UI would show the folder +/// moving and its contents staying behind. Everything here therefore plans the full set of +/// renames, including descendants, before anything is written. +/// +/// +public static class FolderPaths +{ + public const char Separator = '/'; + + /// Trims each segment and drops empties, so " A / / B " becomes "A/B". + public static string Normalise(string name) => + string.Join(Separator, Segments(name)); + + public static IReadOnlyList Segments(string? name) => + (name ?? string.Empty) + .Split(Separator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + /// The parent path of "A/B/C" is "A/B"; a root folder has none. + public static string? Parent(string name) + { + var segments = Segments(name); + return segments.Count <= 1 ? null : string.Join(Separator, segments.Take(segments.Count - 1)); + } + + /// The last segment: "A/B/C" gives "C". + public static string Leaf(string name) => Segments(name).LastOrDefault() ?? string.Empty; + + /// Whether sits underneath . + /// + /// Compares whole segments, so "Homelab2" is not treated as a child of "Homelab" the way a + /// naive StartsWith would have it. + /// + public static bool IsDescendantOf(string candidate, string ancestor) + { + var a = Segments(ancestor); + var c = Segments(candidate); + return c.Count > a.Count + && a.Select((segment, i) => string.Equals(segment, c[i], StringComparison.OrdinalIgnoreCase)) + .All(match => match); + } + + private static string Join(string? parent, string leaf) => + string.IsNullOrEmpty(parent) ? leaf : $"{parent}{Separator}{leaf}"; + + /// Rebases a path from one ancestor onto another, keeping the part below it. + private static string Rebase(string path, string oldRoot, string newRoot) => + Join(newRoot, string.Join(Separator, Segments(path).Skip(Segments(oldRoot).Count))); + + /// + /// Plans renaming a folder's own last segment, carrying every descendant with it. + /// + public static FolderPlan PlanRename( + IReadOnlyList folders, + string folderId, + string newLeafName) + { + var target = folders.FirstOrDefault(f => f.Id == folderId); + if (target is null) return FolderPlan.Invalid("That folder no longer exists."); + + var leaf = Normalise(newLeafName); + if (leaf.Length == 0) return FolderPlan.Invalid("A folder needs a name."); + if (leaf.Contains(Separator)) + return FolderPlan.Invalid( + $"A name cannot contain '{Separator}'. Move the folder instead to change where it sits."); + + var newPath = Join(Parent(target.Name), leaf); + return PlanRebase(folders, target, newPath); + } + + /// + /// Plans moving a folder under a new parent, or to the root when + /// is null. + /// + public static FolderPlan PlanMove( + IReadOnlyList folders, + string folderId, + string? newParentPath) + { + var target = folders.FirstOrDefault(f => f.Id == folderId); + if (target is null) return FolderPlan.Invalid("That folder no longer exists."); + + var parent = newParentPath is null ? null : Normalise(newParentPath); + + // Moving a folder inside itself would orphan the whole subtree. + if (parent is not null + && (string.Equals(parent, target.Name, StringComparison.OrdinalIgnoreCase) + || IsDescendantOf(parent, target.Name))) + return FolderPlan.Invalid("A folder cannot be moved inside itself."); + + var newPath = Join(parent, Leaf(target.Name)); + return PlanRebase(folders, target, newPath); + } + + private static FolderPlan PlanRebase( + IReadOnlyList folders, + VaultFolder target, + string newPath) + { + if (string.Equals(newPath, target.Name, StringComparison.Ordinal)) return FolderPlan.Of([]); + + var clash = folders.FirstOrDefault(f => + f.Id != target.Id && string.Equals(f.Name, newPath, StringComparison.OrdinalIgnoreCase)); + if (clash is not null) return FolderPlan.Invalid($"A folder called \"{newPath}\" already exists."); + + var renames = new List { new(target.Id, target.Name, newPath) }; + + // Descendants are independent folders whose names merely start with the old path. They + // are renamed deepest-first so no intermediate state has two folders sharing a name. + renames.AddRange(folders + .Where(f => IsDescendantOf(f.Name, target.Name)) + .OrderByDescending(f => Segments(f.Name).Count) + .Select(f => new FolderRename(f.Id, f.Name, Rebase(f.Name, target.Name, newPath)))); + + return FolderPlan.Of(renames); + } + + /// Validates a name for a new folder at . + public static FolderPlan PlanCreate( + IReadOnlyList folders, + string? parentPath, + string leafName) + { + var leaf = Normalise(leafName); + if (leaf.Length == 0) return FolderPlan.Invalid("A folder needs a name."); + + var path = Join(parentPath is null ? null : Normalise(parentPath), leaf); + if (folders.Any(f => string.Equals(f.Name, path, StringComparison.OrdinalIgnoreCase))) + return FolderPlan.Invalid($"A folder called \"{path}\" already exists."); + + // Carries the intended full name as a rename with no id for the caller to create. + return FolderPlan.Of([new FolderRename(string.Empty, string.Empty, path)]); + } +} diff --git a/src/Domain/Vault/ItemAttachment.cs b/src/Domain/Vault/ItemAttachment.cs new file mode 100644 index 0000000..a611cda --- /dev/null +++ b/src/Domain/Vault/ItemAttachment.cs @@ -0,0 +1,15 @@ +namespace BitwardenSharp.Domain.Vault; + +/// +/// A file attached to an item. Present for detection only: the Bitwarden CLI offers no way to +/// move an attachment between items, so any merge involving one has to be refused rather than +/// silently dropping the file. +/// +public sealed record ItemAttachment +{ + public required string Id { get; init; } + + public string? FileName { get; init; } + + public long? Size { get; init; } +} diff --git a/src/Domain/Vault/ItemDetails.cs b/src/Domain/Vault/ItemDetails.cs new file mode 100644 index 0000000..8061e96 --- /dev/null +++ b/src/Domain/Vault/ItemDetails.cs @@ -0,0 +1,120 @@ +namespace BitwardenSharp.Domain.Vault; + +/// Payment card details. Every field here is sensitive. +public sealed record CardDetails +{ + public string? CardholderName { get; init; } + public string? Brand { get; init; } + public string? Number { get; init; } + public string? ExpMonth { get; init; } + public string? ExpYear { get; init; } + + /// CVV/CVC. + public string? Code { get; init; } + + /// Last four digits, for display without revealing the card. + public string? LastFour => + Number is { Length: >= 4 } number ? number[^4..] : null; + + public string? Expiry => ExpMonth is null && ExpYear is null + ? null + : $"{ExpMonth?.PadLeft(2, '0') ?? "??"}/{ExpYear ?? "????"}"; + + /// Redacts the number and CVV; the generated record ToString would print both. + public override string ToString() => + $"CardDetails {{ Brand = {Brand}, Number = {(Number is null ? "" : $"••••{LastFour}")}, " + + $"Code = {(Code is null ? "" : "")} }}"; +} + +/// +/// Personal identity details. Bitwarden omits null members from its JSON, so most of these are +/// absent on any given item — the full set is modelled so a round-trip never drops one. +/// +public sealed record IdentityDetails +{ + public string? Title { get; init; } + public string? FirstName { get; init; } + public string? MiddleName { get; init; } + public string? LastName { get; init; } + public string? Address1 { get; init; } + public string? Address2 { get; init; } + public string? Address3 { get; init; } + public string? City { get; init; } + public string? State { get; init; } + public string? PostalCode { get; init; } + public string? Country { get; init; } + public string? Company { get; init; } + public string? Email { get; init; } + public string? Phone { get; init; } + public string? Username { get; init; } + + /// Social security or national insurance number. + public string? Ssn { get; init; } + + public string? PassportNumber { get; init; } + public string? LicenseNumber { get; init; } + + public string? FullName => + string.Join(' ', new[] { Title, FirstName, MiddleName, LastName } + .Where(p => !string.IsNullOrWhiteSpace(p))) is { Length: > 0 } name + ? name + : null; + + public string? Address => + string.Join(", ", new[] { Address1, Address2, Address3, City, State, PostalCode, Country } + .Where(p => !string.IsNullOrWhiteSpace(p))) is { Length: > 0 } address + ? address + : null; + + /// Redacts the government identifiers. + public override string ToString() => + $"IdentityDetails {{ Name = {FullName}, Ssn = {(Ssn is null ? "" : "")}, " + + $"PassportNumber = {(PassportNumber is null ? "" : "")} }}"; +} + +/// Secure-note payload. Bitwarden only defines one kind; the text lives in the item's notes. +public sealed record SecureNoteDetails +{ + public SecureNoteType Type { get; init; } = SecureNoteType.Generic; +} + +public enum SecureNoteType +{ + Generic = 0, +} + +/// An SSH key pair held in the vault. +public sealed record SshKeyDetails +{ + public string? PrivateKey { get; init; } + public string? PublicKey { get; init; } + public string? KeyFingerprint { get; init; } + + /// The algorithm, read off the public key ("ssh-ed25519 AAAA…" gives "ssh-ed25519"). + public string? Algorithm => PublicKey?.Split(' ', 2).FirstOrDefault(); + + /// Redacts the private key. + public override string ToString() => + $"SshKeyDetails {{ Algorithm = {Algorithm}, Fingerprint = {KeyFingerprint}, " + + $"PrivateKey = {(PrivateKey is null ? "" : "")} }}"; +} + +/// A password this item used previously, kept by Bitwarden when one is changed. +public sealed record PasswordHistoryEntry +{ + public string? Password { get; init; } + public DateTimeOffset? LastUsedDate { get; init; } + + /// Redacts the password — the whole point of this record is that it holds an old one. + public override string ToString() => + $"PasswordHistoryEntry {{ LastUsedDate = {LastUsedDate}, Password = }}"; +} + +/// +/// Whether Bitwarden asks for the master password again before revealing this item. +/// +public enum RepromptType +{ + None = 0, + MasterPassword = 1, +} diff --git a/src/Domain/Vault/ItemType.cs b/src/Domain/Vault/ItemType.cs new file mode 100644 index 0000000..30159de --- /dev/null +++ b/src/Domain/Vault/ItemType.cs @@ -0,0 +1,11 @@ +namespace BitwardenSharp.Domain.Vault; + +/// Bitwarden's item discriminator, using the CLI's own numeric values. +public enum ItemType +{ + Login = 1, + SecureNote = 2, + Card = 3, + Identity = 4, + SshKey = 5, +} diff --git a/src/Domain/Vault/LoginDetails.cs b/src/Domain/Vault/LoginDetails.cs new file mode 100644 index 0000000..2f0f2a6 --- /dev/null +++ b/src/Domain/Vault/LoginDetails.cs @@ -0,0 +1,55 @@ +using System.Security.Cryptography; +using System.Text; + +namespace BitwardenSharp.Domain.Vault; + +/// The credential half of a item. +public sealed record LoginDetails +{ + public string? Username { get; init; } + + public string? Password { get; init; } + + /// TOTP seed or otpauth:// URI. Treated as a secret. + public string? Totp { get; init; } + + public IReadOnlyList Uris { get; init; } = []; + + public DateTimeOffset? PasswordRevisionDate { get; init; } + + /// + /// Username lowered and trimmed, for comparison. Bitwarden preserves whatever case the user + /// typed, but "Chrison" and "chrison" on the same site are the same account. + /// + public string? NormalisedUsername => + string.IsNullOrWhiteSpace(Username) ? null : Username.Trim().ToLowerInvariant(); + + /// + /// A stable, non-reversible fingerprint of the password, for equality comparison and for + /// display in reports. Two items share a password if and only if these match. + /// + /// + /// SHA-256 truncated to 12 hex characters. This exists so duplicate analysis can say "same + /// password" in a report a human reads, without that report becoming a plaintext password + /// dump. It is a comparison aid, never a credential — anything storing or transmitting the + /// actual secret must use directly and treat it accordingly. + /// + public string? PasswordFingerprint + { + get + { + if (string.IsNullOrEmpty(Password)) return null; + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(Password)); + return Convert.ToHexStringLower(hash)[..12]; + } + } + + /// + /// Redacts every secret. The compiler-generated record ToString would print + /// and in full, so any log line, exception message + /// or debugger string that touched a login would leak it. + /// + public override string ToString() => + $"LoginDetails {{ Username = {Username}, Password = {(Password is null ? "" : "")}, " + + $"Totp = {(Totp is null ? "" : "")}, Uris = {Uris.Count} }}"; +} diff --git a/src/Domain/Vault/LoginUri.cs b/src/Domain/Vault/LoginUri.cs new file mode 100644 index 0000000..fa6efce --- /dev/null +++ b/src/Domain/Vault/LoginUri.cs @@ -0,0 +1,10 @@ +namespace BitwardenSharp.Domain.Vault; + +/// One URI on a login, with the match rule Bitwarden should use for it. +public sealed record LoginUri +{ + public required string Uri { get; init; } + + /// Null means "use the vault-wide default match strategy". + public UriMatchType? Match { get; init; } +} diff --git a/src/Domain/Vault/UriMatchType.cs b/src/Domain/Vault/UriMatchType.cs new file mode 100644 index 0000000..7105017 --- /dev/null +++ b/src/Domain/Vault/UriMatchType.cs @@ -0,0 +1,15 @@ +namespace BitwardenSharp.Domain.Vault; + +/// +/// How Bitwarden decides a URI matches the current page. Null on an item means "inherit the +/// vault default", which is why is nullable rather than defaulted. +/// +public enum UriMatchType +{ + Domain = 0, + Host = 1, + StartsWith = 2, + Exact = 3, + RegularExpression = 4, + Never = 5, +} diff --git a/src/Domain/Vault/VaultFolder.cs b/src/Domain/Vault/VaultFolder.cs new file mode 100644 index 0000000..cd03921 --- /dev/null +++ b/src/Domain/Vault/VaultFolder.cs @@ -0,0 +1,16 @@ +namespace BitwardenSharp.Domain.Vault; + +/// +/// A vault folder. Bitwarden has no real hierarchy — nesting is a naming convention, where +/// "Homelab/Proxmox" is a single folder whose name happens to contain a slash. +/// +public sealed record VaultFolder +{ + public required string Id { get; init; } + + public required string Name { get; init; } + + /// The path segments implied by the name, e.g. ["Homelab", "Proxmox"]. + public IReadOnlyList Segments => + Name.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); +} diff --git a/src/Domain/Vault/VaultItem.cs b/src/Domain/Vault/VaultItem.cs new file mode 100644 index 0000000..c7bce75 --- /dev/null +++ b/src/Domain/Vault/VaultItem.cs @@ -0,0 +1,86 @@ +namespace BitwardenSharp.Domain.Vault; + +/// +/// A single vault entry, mirroring the shape the Bitwarden CLI emits from +/// bw list items / bw get item. +/// +/// +/// This is a faithful transport model, not an idealised one: field names and nullability follow +/// what bw actually produces so a round-trip (get → mutate → edit) preserves everything +/// the CLI gave us. Dropping an unrecognised field here would silently delete it from the vault +/// on the next write. +/// +public sealed record VaultItem +{ + public required string Id { get; init; } + + public required ItemType Type { get; init; } + + public required string Name { get; init; } + + public string? FolderId { get; init; } + + public string? OrganizationId { get; init; } + + public string? Notes { get; init; } + + public bool Favorite { get; init; } + + public LoginDetails? Login { get; init; } + + public CardDetails? Card { get; init; } + + public IdentityDetails? Identity { get; init; } + + public SecureNoteDetails? SecureNote { get; init; } + + public SshKeyDetails? SshKey { get; init; } + + public IReadOnlyList Fields { get; init; } = []; + + public IReadOnlyList Attachments { get; init; } = []; + + /// Previous passwords, newest first. Bitwarden keeps these when one is changed. + public IReadOnlyList PasswordHistory { get; init; } = []; + + public IReadOnlyList CollectionIds { get; init; } = []; + + public RepromptType Reprompt { get; init; } + + /// + /// The item's own encryption key, present on items Bitwarden has migrated to per-cipher keys + /// (109 of 818 on the vault this was built against). Opaque here and carried untouched — the + /// CLI handles the crypto, but dropping this field on a write would corrupt the item. + /// + public string? Key { get; init; } + + public DateTimeOffset? RevisionDate { get; init; } + + public DateTimeOffset? CreationDate { get; init; } + + /// Every URI on this item, or empty when it has none. + public IReadOnlyList Uris => Login?.Uris ?? []; + + /// Whether the master password must be re-entered before this item is revealed. + public bool RequiresReprompt => Reprompt == RepromptType.MasterPassword; + + /// + /// How much irreplaceable data this item carries. Used to choose which member of a duplicate + /// group survives a merge — the richest one wins, because everything the others hold can be + /// copied onto it, but an attachment cannot be moved between items by the CLI at all. + /// + public int Richness => + Attachments.Count * 10 + + (string.IsNullOrWhiteSpace(Login?.Totp) ? 0 : 5) + + Fields.Count * 3 + + (string.IsNullOrWhiteSpace(Notes) ? 0 : 2) + + (string.IsNullOrWhiteSpace(FolderId) ? 0 : 2) + + (Favorite ? 1 : 0) + + Uris.Count; + + /// + /// Deliberately excludes every payload — see , + /// and , all of which redact in their own right. + /// + public override string ToString() => $"VaultItem {{ Id = {Id}, Name = {Name}, Type = {Type} }}"; +} diff --git a/src/Infrastructure/BitwardenSharp.Infrastructure.csproj b/src/Infrastructure/BitwardenSharp.Infrastructure.csproj new file mode 100644 index 0000000..3d68e78 --- /dev/null +++ b/src/Infrastructure/BitwardenSharp.Infrastructure.csproj @@ -0,0 +1,13 @@ + + + Adapters for BitwardenSharp — drives the official `bw` CLI to satisfy the Application's IVaultClient port. + bitwarden;vault;cli;password-manager + + + + + + + + + diff --git a/src/Infrastructure/Bw/BwCliOptions.cs b/src/Infrastructure/Bw/BwCliOptions.cs new file mode 100644 index 0000000..7ead2dc --- /dev/null +++ b/src/Infrastructure/Bw/BwCliOptions.cs @@ -0,0 +1,19 @@ +namespace BitwardenSharp.Infrastructure.Bw; + +/// How to reach the bw client. +public sealed class BwCliOptions +{ + /// Path to the executable. Resolved from PATH when left as the bare name. + public string ExecutablePath { get; set; } = "bw"; + + /// + /// The vault session key. + /// + /// + /// Held in memory for the life of the process and passed to child processes through their + /// environment. It is never written to disk and never becomes a command-line argument. + /// Defaults to BW_SESSION from the environment, which is how bw unlock --raw + /// is normally plumbed through. + /// + public string? Session { get; set; } = Environment.GetEnvironmentVariable("BW_SESSION"); +} diff --git a/src/Infrastructure/Bw/BwCliVaultClient.cs b/src/Infrastructure/Bw/BwCliVaultClient.cs new file mode 100644 index 0000000..fcb8f1b --- /dev/null +++ b/src/Infrastructure/Bw/BwCliVaultClient.cs @@ -0,0 +1,148 @@ +using System.Text; +using System.Text.Json; +using BitwardenSharp.Application.Abstractions; +using BitwardenSharp.Domain.Vault; +using BitwardenSharp.Infrastructure.Bw.Contracts; +using Microsoft.Extensions.Logging; + +namespace BitwardenSharp.Infrastructure.Bw; + +/// +/// An backed by the official bw command-line client. +/// +/// +/// Every call spawns a process, which costs roughly half a second of Node start-up. That is +/// irrelevant for the bulk read (one bw list items returns the whole vault) and acceptable +/// for merges, which are a handful of calls each and are gated on human approval anyway. +/// +public sealed class BwCliVaultClient( + BwProcessRunner runner, + ILogger? logger = null) : IVaultClient +{ + private static readonly JsonSerializerOptions Json = new() + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + }; + + public async Task SyncAsync(CancellationToken cancellationToken = default) => + await runner.RunAsync(["sync"], cancellationToken: cancellationToken); + + public async Task GetStatusAsync(CancellationToken cancellationToken = default) + { + var json = await runner.RunAsync(["status"], cancellationToken: cancellationToken); + var status = JsonSerializer.Deserialize(json, Json) + ?? throw new InvalidOperationException("bw status returned nothing"); + return new VaultStatus + { + Status = status.Status, + UserEmail = status.UserEmail, + ServerUrl = status.ServerUrl, + LastSync = status.LastSync, + }; + } + + public async Task> GetItemsAsync(CancellationToken cancellationToken = default) + { + var json = await runner.RunAsync(["list", "items"], cancellationToken: cancellationToken); + var items = JsonSerializer.Deserialize>(json, Json) ?? []; + logger?.LogDebug("Read {Count} items from the vault", items.Count); + return items.Select(BwItemMapper.ToDomain).ToList(); + } + + public async Task> GetFoldersAsync(CancellationToken cancellationToken = default) + { + var json = await runner.RunAsync(["list", "folders"], cancellationToken: cancellationToken); + var folders = JsonSerializer.Deserialize>(json, Json) ?? []; + return folders.Select(BwItemMapper.ToDomain).ToList(); + } + + public async Task GetItemAsync(string id, CancellationToken cancellationToken = default) => + BwItemMapper.ToDomain(await GetWireItemAsync(id, cancellationToken)); + + public async Task UpdateItemAsync(VaultItem item, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(item); + + // Re-read the wire object and apply onto it: `bw edit` replaces the item wholesale, so + // anything absent from the payload is deleted from the vault. See BwItemMapper.ApplyTo. + var wire = BwItemMapper.ApplyTo(item, await GetWireItemAsync(item.Id, cancellationToken)); + var payload = Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(wire, Json)); + + // Piped, not passed. The payload contains the password in clear, and process arguments + // are readable by any local user through `ps`. + var json = await runner.RunAsync( + ["edit", "item", item.Id], + standardInput: payload, + cancellationToken: cancellationToken); + + var updated = JsonSerializer.Deserialize(json, Json) + ?? throw new InvalidOperationException($"bw edit item {item.Id} returned nothing"); + return BwItemMapper.ToDomain(updated); + } + + public async Task DeleteItemAsync( + string id, + bool permanent = false, + CancellationToken cancellationToken = default) + { + string[] args = permanent ? ["delete", "item", id, "--permanent"] : ["delete", "item", id]; + await runner.RunAsync(args, cancellationToken: cancellationToken); + logger?.LogInformation( + "Deleted item {ItemId} ({Disposition})", id, permanent ? "permanently" : "to trash"); + } + + public async Task CreateItemAsync( + VaultItem item, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(item); + + var wire = BwItemMapper.ApplyTo(item, BwItemMapper.NewWireItem(item)); + var payload = Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(wire, Json)); + + // Piped, not passed: the payload carries the password in clear. + var json = await runner.RunAsync( + ["create", "item"], standardInput: payload, cancellationToken: cancellationToken); + + return BwItemMapper.ToDomain( + JsonSerializer.Deserialize(json, Json) + ?? throw new InvalidOperationException("bw create item returned nothing")); + } + + public async Task CreateFolderAsync( + string name, CancellationToken cancellationToken = default) + { + var payload = Convert.ToBase64String( + JsonSerializer.SerializeToUtf8Bytes(new BwFolder { Name = name }, Json)); + var json = await runner.RunAsync( + ["create", "folder"], standardInput: payload, cancellationToken: cancellationToken); + return BwItemMapper.ToDomain( + JsonSerializer.Deserialize(json, Json) + ?? throw new InvalidOperationException("bw create folder returned nothing")); + } + + public async Task RenameFolderAsync( + string id, string name, CancellationToken cancellationToken = default) + { + var payload = Convert.ToBase64String( + JsonSerializer.SerializeToUtf8Bytes(new BwFolder { Id = id, Name = name }, Json)); + var json = await runner.RunAsync( + ["edit", "folder", id], standardInput: payload, cancellationToken: cancellationToken); + return BwItemMapper.ToDomain( + JsonSerializer.Deserialize(json, Json) + ?? throw new InvalidOperationException($"bw edit folder {id} returned nothing")); + } + + public async Task DeleteFolderAsync(string id, CancellationToken cancellationToken = default) + { + await runner.RunAsync(["delete", "folder", id], cancellationToken: cancellationToken); + logger?.LogInformation("Deleted folder {FolderId}", id); + } + + private async Task GetWireItemAsync(string id, CancellationToken cancellationToken) + { + var json = await runner.RunAsync(["get", "item", id], cancellationToken: cancellationToken); + return JsonSerializer.Deserialize(json, Json) + ?? throw new InvalidOperationException($"bw get item {id} returned nothing"); + } +} diff --git a/src/Infrastructure/Bw/BwItemMapper.cs b/src/Infrastructure/Bw/BwItemMapper.cs new file mode 100644 index 0000000..177864f --- /dev/null +++ b/src/Infrastructure/Bw/BwItemMapper.cs @@ -0,0 +1,160 @@ +using BitwardenSharp.Domain.Vault; +using BitwardenSharp.Infrastructure.Bw.Contracts; + +namespace BitwardenSharp.Infrastructure.Bw; + +/// Translates between the bw wire shape and the domain model. +internal static class BwItemMapper +{ + public static VaultItem ToDomain(BwItem wire) => new() + { + Id = wire.Id, + Type = (ItemType)wire.Type, + Name = wire.Name, + FolderId = wire.FolderId, + OrganizationId = wire.OrganizationId, + Notes = wire.Notes, + Favorite = wire.Favorite, + RevisionDate = wire.RevisionDate, + CreationDate = wire.CreationDate, + Reprompt = (RepromptType)(wire.Reprompt ?? 0), + CollectionIds = wire.CollectionIds ?? [], + Key = wire.Key, + PasswordHistory = wire.PasswordHistory? + .Select(h => new PasswordHistoryEntry { Password = h.Password, LastUsedDate = h.LastUsedDate }) + .ToList() ?? [], + Card = wire.Card is null ? null : new CardDetails + { + CardholderName = wire.Card.CardholderName, + Brand = wire.Card.Brand, + Number = wire.Card.Number, + ExpMonth = wire.Card.ExpMonth, + ExpYear = wire.Card.ExpYear, + Code = wire.Card.Code, + }, + Identity = wire.Identity is null ? null : new IdentityDetails + { + Title = wire.Identity.Title, + FirstName = wire.Identity.FirstName, + MiddleName = wire.Identity.MiddleName, + LastName = wire.Identity.LastName, + Address1 = wire.Identity.Address1, + Address2 = wire.Identity.Address2, + Address3 = wire.Identity.Address3, + City = wire.Identity.City, + State = wire.Identity.State, + PostalCode = wire.Identity.PostalCode, + Country = wire.Identity.Country, + Company = wire.Identity.Company, + Email = wire.Identity.Email, + Phone = wire.Identity.Phone, + Ssn = wire.Identity.Ssn, + Username = wire.Identity.Username, + PassportNumber = wire.Identity.PassportNumber, + LicenseNumber = wire.Identity.LicenseNumber, + }, + SecureNote = wire.SecureNote is null + ? null + : new SecureNoteDetails { Type = (SecureNoteType)wire.SecureNote.Type }, + SshKey = wire.SshKey is null ? null : new SshKeyDetails + { + PrivateKey = wire.SshKey.PrivateKey, + PublicKey = wire.SshKey.PublicKey, + KeyFingerprint = wire.SshKey.KeyFingerprint, + }, + Login = wire.Login is null ? null : new LoginDetails + { + Username = wire.Login.Username, + Password = wire.Login.Password, + Totp = wire.Login.Totp, + PasswordRevisionDate = wire.Login.PasswordRevisionDate, + Uris = wire.Login.Uris? + .Where(u => !string.IsNullOrWhiteSpace(u.Uri)) + .Select(u => new LoginUri { Uri = u.Uri!, Match = (UriMatchType?)u.Match }) + .ToList() ?? [], + }, + Fields = wire.Fields? + .Select(f => new CustomField + { + Name = f.Name, + Value = f.Value, + Type = (FieldType)f.Type, + LinkedId = f.LinkedId, + }) + .ToList() ?? [], + Attachments = wire.Attachments? + .Select(a => new ItemAttachment + { + Id = a.Id, + FileName = a.FileName, + Size = long.TryParse(a.Size, out var size) ? size : null, + }) + .ToList() ?? [], + }; + + /// + /// A blank wire object for an item that does not exist yet. + /// + /// + /// Only the discriminators the server needs in order to accept the create; everything else is + /// filled by . No id, and no per-cipher key — both are the vault's to + /// assign, and sending a borrowed one would attach the new item to another item's key. + /// + public static BwItem NewWireItem(VaultItem item) => new() + { + Type = (int)item.Type, + Name = item.Name, + Login = item.Type == ItemType.Login ? new BwLogin() : null, + SecureNote = item.Type == ItemType.SecureNote ? new BwSecureNote() : null, + Card = item.Type == ItemType.Card ? new BwCard() : null, + Identity = item.Type == ItemType.Identity ? new BwIdentity() : null, + }; + + /// + /// Writes the domain item's mutable state onto the wire object it came from and returns it. + /// + /// + /// Mutating the original rather than building a fresh is what preserves + /// collectionIds, reprompt, fido2Credentials and anything a newer CLI has + /// added that landed in extension data. A bw edit replaces the stored item outright, so + /// a field absent from the payload is a field deleted from the vault. + /// + public static BwItem ApplyTo(VaultItem item, BwItem wire) + { + wire.Name = item.Name; + wire.FolderId = item.FolderId; + wire.Notes = item.Notes; + wire.Favorite = item.Favorite; + + wire.Fields = item.Fields.Count == 0 + ? null + : item.Fields.Select(f => new BwField + { + Name = f.Name, + Value = f.Value, + Type = (int)f.Type, + LinkedId = f.LinkedId, + }).ToList(); + + if (item.Login is not null) + { + wire.Login ??= new BwLogin(); + wire.Login.Username = item.Login.Username; + wire.Login.Password = item.Login.Password; + wire.Login.Totp = item.Login.Totp; + wire.Login.Uris = item.Login.Uris.Count == 0 + ? null + : item.Login.Uris.Select(u => new BwUri { Uri = u.Uri, Match = (int?)u.Match }).ToList(); + } + + return wire; + } + + public static VaultFolder ToDomain(BwFolder wire) => new() + { + // The pseudo-folder "No Folder" comes back with a null id; represent it as empty so it + // is a value rather than a hole. + Id = wire.Id ?? string.Empty, + Name = wire.Name, + }; +} diff --git a/src/Infrastructure/Bw/BwProcessRunner.cs b/src/Infrastructure/Bw/BwProcessRunner.cs new file mode 100644 index 0000000..3e84d11 --- /dev/null +++ b/src/Infrastructure/Bw/BwProcessRunner.cs @@ -0,0 +1,131 @@ +using System.Diagnostics; +using System.Text; +using Microsoft.Extensions.Logging; + +namespace BitwardenSharp.Infrastructure.Bw; + +/// The result of one bw invocation. +public sealed record BwResult(int ExitCode, string StandardOutput, string StandardError) +{ + public bool Succeeded => ExitCode == 0; +} + +/// Raised when bw exits non-zero. +public sealed class BwCommandException(string command, BwResult result) + : Exception($"`bw {command}` failed with exit code {result.ExitCode}: {Summarise(result.StandardError)}") +{ + public BwResult Result { get; } = result; + + private static string Summarise(string stderr) + { + var text = stderr.Trim(); + return text.Length <= 400 ? text : text[..400] + "…"; + } +} + +/// +/// Runs the official bw client. +/// +/// +/// +/// Two rules hold everywhere in this class, and both exist because the arguments of a running +/// process are world-readable via ps on every platform this ships to: +/// +/// +/// +/// Arguments go through , never a joined string. The +/// runtime quotes each element correctly per platform, so nothing has to be escaped by hand. +/// +/// +/// Secrets never become arguments. The session key travels in the environment, and the +/// base64 item payload for edit/create — which contains the password in clear — +/// is piped to stdin, which bw accepts in place of the positional argument. +/// +/// +/// +public sealed class BwProcessRunner(BwCliOptions options, ILogger? logger = null) +{ + /// Runs bw and returns its output, throwing if it exits non-zero. + public async Task RunAsync( + IEnumerable arguments, + string? standardInput = null, + IReadOnlyDictionary? environment = null, + CancellationToken cancellationToken = default) + { + var result = await TryRunAsync(arguments, standardInput, environment, cancellationToken); + if (!result.Succeeded) throw new BwCommandException(DescribeSafely(arguments), result); + return result.StandardOutput; + } + + /// Runs bw and returns the result without throwing on a non-zero exit. + public async Task TryRunAsync( + IEnumerable arguments, + string? standardInput = null, + IReadOnlyDictionary? environment = null, + CancellationToken cancellationToken = default) + { + var args = arguments.ToList(); + + var startInfo = new ProcessStartInfo + { + FileName = options.ExecutablePath, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = standardInput is not null, + UseShellExecute = false, + CreateNoWindow = true, + }; + + foreach (var arg in args) startInfo.ArgumentList.Add(arg); + + // The session key is a decryption key for the whole vault. It goes in the environment of + // the child alone — not the command line, and not this process's own environment. + if (!string.IsNullOrEmpty(options.Session)) startInfo.Environment["BW_SESSION"] = options.Session; + + // bw asks for input on a TTY when it wants a password. Nothing here should ever reach + // that state, and if it does we want a clean failure rather than a hung process. + startInfo.Environment["BW_NOINTERACTION"] = "true"; + + // Caller-supplied values -- currently the master password for `unlock --passwordenv`. + // Set on the child only; this process's own environment is never touched. + if (environment is not null) + foreach (var (name, value) in environment) startInfo.Environment[name] = value; + + logger?.LogDebug("Running bw {Arguments}", DescribeSafely(args)); + + using var process = new Process { StartInfo = startInfo }; + if (!process.Start()) + throw new InvalidOperationException($"could not start '{options.ExecutablePath}'"); + + var stdout = process.StandardOutput.ReadToEndAsync(cancellationToken); + var stderr = process.StandardError.ReadToEndAsync(cancellationToken); + + if (standardInput is not null) + { + await process.StandardInput.WriteAsync(standardInput.AsMemory(), cancellationToken); + process.StandardInput.Close(); + } + + await process.WaitForExitAsync(cancellationToken); + return new BwResult(process.ExitCode, (await stdout).Trim(), (await stderr).Trim()); + } + + /// + /// Renders an argument list for logging with anything past a known-safe verb elided, so a + /// debug log can never become a record of item ids or payloads. + /// + private static string DescribeSafely(IEnumerable arguments) + { + var args = arguments.ToList(); + var safe = new StringBuilder(); + for (var i = 0; i < args.Count; i++) + { + // The first two tokens are the verb and object ("get item", "list items"); anything + // after them can identify a specific secret. + safe.Append(i < 2 ? args[i] : "<…>"); + if (i < args.Count - 1) safe.Append(' '); + if (i >= 2) break; + } + return safe.ToString(); + } +} diff --git a/src/Infrastructure/Bw/BwVaultSession.cs b/src/Infrastructure/Bw/BwVaultSession.cs new file mode 100644 index 0000000..bdda440 --- /dev/null +++ b/src/Infrastructure/Bw/BwVaultSession.cs @@ -0,0 +1,78 @@ +using System.Text.Json; +using BitwardenSharp.Application.Abstractions; +using BitwardenSharp.Infrastructure.Bw.Contracts; +using Microsoft.Extensions.Logging; + +namespace BitwardenSharp.Infrastructure.Bw; + +/// +/// Unlocks the vault through the bw client and holds the resulting session key. +/// +/// +/// +/// bw unlock offers three ways to supply a password. Two are unacceptable here: +/// the positional argument puts it in argv, where ps shows it to every local user, +/// and --passwordfile writes it to disk. This uses --passwordenv, which names an +/// environment variable set on the child process only — never on ours, and never written down. +/// +/// +/// The variable name is randomised per attempt so nothing can be primed to read a fixed one, and +/// the value is dropped as soon as the process exits. +/// +/// +public sealed class BwVaultSession( + BwProcessRunner runner, + BwCliOptions options, + ILogger? logger = null) : IVaultSession +{ + private static readonly JsonSerializerOptions Json = new() { PropertyNameCaseInsensitive = true }; + + public async Task GetStatusAsync(CancellationToken cancellationToken = default) + { + var json = await runner.RunAsync(["status"], cancellationToken: cancellationToken); + var status = JsonSerializer.Deserialize(json, Json) + ?? throw new InvalidOperationException("bw status returned nothing"); + return new VaultStatus + { + Status = status.Status, + UserEmail = status.UserEmail, + ServerUrl = status.ServerUrl, + LastSync = status.LastSync, + }; + } + + public async Task UnlockAsync( + string masterPassword, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(masterPassword)) return UnlockResult.Failure("Enter your master password."); + + var variable = $"BWSHARP_MP_{Guid.NewGuid():N}"; + var result = await runner.TryRunAsync( + ["unlock", "--raw", "--passwordenv", variable], + environment: new Dictionary { [variable] = masterPassword }, + cancellationToken: cancellationToken); + + if (!result.Succeeded) + { + logger?.LogWarning("Unlock rejected (exit {ExitCode})", result.ExitCode); + // bw's own message is safe to surface: it says the password is wrong, not what it is. + var error = result.StandardError.Trim(); + return UnlockResult.Failure(error.Length == 0 ? "Could not unlock the vault." : error); + } + + var session = result.StandardOutput.Trim(); + if (session.Length == 0) return UnlockResult.Failure("bw returned an empty session key."); + + options.Session = session; + logger?.LogInformation("Vault unlocked"); + return UnlockResult.Success(); + } + + public async Task LockAsync(CancellationToken cancellationToken = default) + { + await runner.TryRunAsync(["lock"], cancellationToken: cancellationToken); + options.Session = null; + logger?.LogInformation("Vault locked"); + } +} diff --git a/src/Infrastructure/Bw/Contracts/BwContracts.cs b/src/Infrastructure/Bw/Contracts/BwContracts.cs new file mode 100644 index 0000000..2947b9c --- /dev/null +++ b/src/Infrastructure/Bw/Contracts/BwContracts.cs @@ -0,0 +1,156 @@ +using System.Text.Json.Serialization; + +namespace BitwardenSharp.Infrastructure.Bw.Contracts; + +/// +/// The wire shape of a vault item as the bw CLI emits and accepts it. +/// +/// +/// Kept separate from the domain model on purpose. An item written back to the vault replaces the +/// stored one wholesale, so this type has to round-trip every property bw gave us, +/// including ones the domain has no opinion about. catches anything +/// added by a future CLI version, so an unrecognised field survives a merge instead of being +/// silently deleted from the vault. +/// +public sealed class BwItem +{ + [JsonPropertyName("id")] public string Id { get; set; } = string.Empty; + [JsonPropertyName("organizationId")] public string? OrganizationId { get; set; } + [JsonPropertyName("folderId")] public string? FolderId { get; set; } + [JsonPropertyName("type")] public int Type { get; set; } + [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; + [JsonPropertyName("notes")] public string? Notes { get; set; } + [JsonPropertyName("favorite")] public bool Favorite { get; set; } + [JsonPropertyName("login")] public BwLogin? Login { get; set; } + [JsonPropertyName("card")] public BwCard? Card { get; set; } + [JsonPropertyName("identity")] public BwIdentity? Identity { get; set; } + [JsonPropertyName("secureNote")] public BwSecureNote? SecureNote { get; set; } + [JsonPropertyName("sshKey")] public BwSshKey? SshKey { get; set; } + [JsonPropertyName("passwordHistory")] public List? PasswordHistory { get; set; } + + /// Per-cipher key. Opaque, and fatal to drop on a write. + [JsonPropertyName("key")] public string? Key { get; set; } + [JsonPropertyName("fields")] public List? Fields { get; set; } + [JsonPropertyName("attachments")] public List? Attachments { get; set; } + [JsonPropertyName("collectionIds")] public List? CollectionIds { get; set; } + [JsonPropertyName("revisionDate")] public DateTimeOffset? RevisionDate { get; set; } + [JsonPropertyName("creationDate")] public DateTimeOffset? CreationDate { get; set; } + [JsonPropertyName("deletedDate")] public DateTimeOffset? DeletedDate { get; set; } + [JsonPropertyName("reprompt")] public int? Reprompt { get; set; } + + /// Anything this version of the model does not name, preserved verbatim. + [JsonExtensionData] public Dictionary? ExtensionData { get; set; } +} + +public sealed class BwLogin +{ + [JsonPropertyName("username")] public string? Username { get; set; } + [JsonPropertyName("password")] public string? Password { get; set; } + [JsonPropertyName("totp")] public string? Totp { get; set; } + [JsonPropertyName("uris")] public List? Uris { get; set; } + [JsonPropertyName("passwordRevisionDate")] public DateTimeOffset? PasswordRevisionDate { get; set; } + [JsonPropertyName("fido2Credentials")] public List? Fido2Credentials { get; set; } + + [JsonExtensionData] public Dictionary? ExtensionData { get; set; } +} + +public sealed class BwUri +{ + [JsonPropertyName("uri")] public string? Uri { get; set; } + [JsonPropertyName("match")] public int? Match { get; set; } + + [JsonExtensionData] public Dictionary? ExtensionData { get; set; } +} + +public sealed class BwField +{ + [JsonPropertyName("name")] public string? Name { get; set; } + [JsonPropertyName("value")] public string? Value { get; set; } + [JsonPropertyName("type")] public int Type { get; set; } + [JsonPropertyName("linkedId")] public string? LinkedId { get; set; } + + [JsonExtensionData] public Dictionary? ExtensionData { get; set; } +} + +public sealed class BwAttachment +{ + [JsonPropertyName("id")] public string Id { get; set; } = string.Empty; + [JsonPropertyName("fileName")] public string? FileName { get; set; } + [JsonPropertyName("size")] public string? Size { get; set; } + [JsonPropertyName("sizeName")] public string? SizeName { get; set; } + [JsonPropertyName("url")] public string? Url { get; set; } + + [JsonExtensionData] public Dictionary? ExtensionData { get; set; } +} + +public sealed class BwCard +{ + [JsonPropertyName("cardholderName")] public string? CardholderName { get; set; } + [JsonPropertyName("brand")] public string? Brand { get; set; } + [JsonPropertyName("number")] public string? Number { get; set; } + [JsonPropertyName("expMonth")] public string? ExpMonth { get; set; } + [JsonPropertyName("expYear")] public string? ExpYear { get; set; } + [JsonPropertyName("code")] public string? Code { get; set; } + + [JsonExtensionData] public Dictionary? ExtensionData { get; set; } +} + +public sealed class BwIdentity +{ + [JsonPropertyName("title")] public string? Title { get; set; } + [JsonPropertyName("firstName")] public string? FirstName { get; set; } + [JsonPropertyName("middleName")] public string? MiddleName { get; set; } + [JsonPropertyName("lastName")] public string? LastName { get; set; } + [JsonPropertyName("address1")] public string? Address1 { get; set; } + [JsonPropertyName("address2")] public string? Address2 { get; set; } + [JsonPropertyName("address3")] public string? Address3 { get; set; } + [JsonPropertyName("city")] public string? City { get; set; } + [JsonPropertyName("state")] public string? State { get; set; } + [JsonPropertyName("postalCode")] public string? PostalCode { get; set; } + [JsonPropertyName("country")] public string? Country { get; set; } + [JsonPropertyName("company")] public string? Company { get; set; } + [JsonPropertyName("email")] public string? Email { get; set; } + [JsonPropertyName("phone")] public string? Phone { get; set; } + [JsonPropertyName("ssn")] public string? Ssn { get; set; } + [JsonPropertyName("username")] public string? Username { get; set; } + [JsonPropertyName("passportNumber")] public string? PassportNumber { get; set; } + [JsonPropertyName("licenseNumber")] public string? LicenseNumber { get; set; } + + [JsonExtensionData] public Dictionary? ExtensionData { get; set; } +} + +public sealed class BwSecureNote +{ + [JsonPropertyName("type")] public int Type { get; set; } + + [JsonExtensionData] public Dictionary? ExtensionData { get; set; } +} + +public sealed class BwSshKey +{ + [JsonPropertyName("privateKey")] public string? PrivateKey { get; set; } + [JsonPropertyName("publicKey")] public string? PublicKey { get; set; } + [JsonPropertyName("keyFingerprint")] public string? KeyFingerprint { get; set; } + + [JsonExtensionData] public Dictionary? ExtensionData { get; set; } +} + +public sealed class BwPasswordHistory +{ + [JsonPropertyName("password")] public string? Password { get; set; } + [JsonPropertyName("lastUsedDate")] public DateTimeOffset? LastUsedDate { get; set; } +} + +public sealed class BwFolder +{ + [JsonPropertyName("id")] public string? Id { get; set; } + [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; +} + +public sealed class BwStatus +{ + [JsonPropertyName("status")] public string Status { get; set; } = "unknown"; + [JsonPropertyName("userEmail")] public string? UserEmail { get; set; } + [JsonPropertyName("serverUrl")] public string? ServerUrl { get; set; } + [JsonPropertyName("lastSync")] public DateTimeOffset? LastSync { get; set; } +} diff --git a/src/Infrastructure/Bw/JsonFileVaultClient.cs b/src/Infrastructure/Bw/JsonFileVaultClient.cs new file mode 100644 index 0000000..01e6dff --- /dev/null +++ b/src/Infrastructure/Bw/JsonFileVaultClient.cs @@ -0,0 +1,71 @@ +using System.Text.Json; +using BitwardenSharp.Application.Abstractions; +using BitwardenSharp.Domain.Vault; +using BitwardenSharp.Infrastructure.Bw.Contracts; + +namespace BitwardenSharp.Infrastructure.Bw; + +/// +/// A read-only over a saved bw list items dump. +/// +/// +/// +/// Useful for auditing an export without unlocking a vault, for reproducing a scan against a +/// fixed snapshot, and for driving the scanner from a test fixture. +/// +/// +/// Every mutating member throws. A file-backed vault that silently accepted writes would be a +/// trap: a merge would report success having changed nothing. +/// +/// +public sealed class JsonFileVaultClient(string itemsPath, string? foldersPath = null) : IVaultClient +{ + private static readonly JsonSerializerOptions Json = new() { PropertyNameCaseInsensitive = true }; + + public Task SyncAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task GetStatusAsync(CancellationToken cancellationToken = default) => + Task.FromResult(new VaultStatus + { + Status = "unlocked", + ServerUrl = $"file://{itemsPath}", + }); + + public async Task> GetItemsAsync(CancellationToken cancellationToken = default) + { + await using var stream = File.OpenRead(itemsPath); + var items = await JsonSerializer.DeserializeAsync>(stream, Json, cancellationToken) ?? []; + return items.Select(BwItemMapper.ToDomain).ToList(); + } + + public async Task> GetFoldersAsync(CancellationToken cancellationToken = default) + { + if (foldersPath is null || !File.Exists(foldersPath)) return []; + await using var stream = File.OpenRead(foldersPath); + var folders = await JsonSerializer.DeserializeAsync>(stream, Json, cancellationToken) ?? []; + return folders.Select(BwItemMapper.ToDomain).ToList(); + } + + public async Task GetItemAsync(string id, CancellationToken cancellationToken = default) => + (await GetItemsAsync(cancellationToken)).FirstOrDefault(i => i.Id == id) + ?? throw new KeyNotFoundException($"no item {id} in {itemsPath}"); + + public Task UpdateItemAsync(VaultItem item, CancellationToken cancellationToken = default) => + throw new NotSupportedException("this vault is a read-only file snapshot; merges need a live vault"); + + public Task DeleteItemAsync(string id, bool permanent = false, CancellationToken cancellationToken = default) => + throw new NotSupportedException("this vault is a read-only file snapshot; merges need a live vault"); + + public Task CreateItemAsync(VaultItem item, CancellationToken cancellationToken = default) => + throw new NotSupportedException("this vault is a read-only file snapshot"); + + public Task CreateFolderAsync(string name, CancellationToken cancellationToken = default) => + throw new NotSupportedException("this vault is a read-only file snapshot"); + + public Task RenameFolderAsync( + string id, string name, CancellationToken cancellationToken = default) => + throw new NotSupportedException("this vault is a read-only file snapshot"); + + public Task DeleteFolderAsync(string id, CancellationToken cancellationToken = default) => + throw new NotSupportedException("this vault is a read-only file snapshot"); +} diff --git a/src/Infrastructure/Icons/BitwardenIconProvider.cs b/src/Infrastructure/Icons/BitwardenIconProvider.cs new file mode 100644 index 0000000..794e353 --- /dev/null +++ b/src/Infrastructure/Icons/BitwardenIconProvider.cs @@ -0,0 +1,163 @@ +using System.Security.Cryptography; +using System.Text; +using BitwardenSharp.Application.Abstractions; +using Microsoft.Extensions.Logging; + +namespace BitwardenSharp.Infrastructure.Icons; + +/// How website icons are fetched, if at all. +public sealed class IconOptions +{ + /// + /// Whether to fetch icons at all. Off means every item shows its placeholder and no request + /// ever leaves the machine. + /// + public bool Enabled { get; set; } = true; + + /// + /// The icon service. Bitwarden runs one per region; EU accounts should use the EU host so the + /// lookups stay in the same jurisdiction as the vault. + /// + public Uri ServiceUrl { get; set; } = new("https://icons.bitwarden.eu/"); + + /// Where fetched icons are cached. Null uses the platform's local app-data folder. + public string? CacheDirectory { get; set; } + + /// How long a cached icon (or a cached miss) is trusted. + public TimeSpan CacheLifetime { get; set; } = TimeSpan.FromDays(30); +} + +/// +/// Fetches website icons from Bitwarden's icon service, cached on disk. +/// +/// +/// +/// Every lookup tells the icon service that a domain is in this vault. Fetching icons for +/// a whole vault hands over a list of the sites its owner holds accounts with — including private +/// self-hosted ones, whose mere existence is information. Bitwarden's own +/// clients behave this way and expose it as a setting; so does this, via +/// . +/// +/// +/// Three things limit the exposure. Only the registrable domain is ever sent, never a full URI +/// with its path. Results are cached on disk for a month, so a domain is asked about once rather +/// than on every render. And misses are cached too — a self-hosted host that has no icon is not +/// re-requested every time the app opens. +/// +/// +public sealed class BitwardenIconProvider : IIconProvider, IDisposable +{ + private readonly IconOptions _options; + private readonly HttpClient _http; + private readonly ILogger? _logger; + private readonly string _cacheDirectory; + private readonly SemaphoreSlim _concurrency = new(4, 4); + + /// Domains already resolved this session, so repeats never touch the disk either. + private readonly Dictionary _memory = new(StringComparer.OrdinalIgnoreCase); + + public BitwardenIconProvider(IconOptions options, ILogger? logger = null) + { + _options = options; + _logger = logger; + _http = new HttpClient { BaseAddress = options.ServiceUrl, Timeout = TimeSpan.FromSeconds(10) }; + + _cacheDirectory = options.CacheDirectory ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "BitwardenSharp", "icons"); + Directory.CreateDirectory(_cacheDirectory); + } + + public bool IsEnabled => _options.Enabled; + + public async Task GetIconAsync(string domain, CancellationToken cancellationToken = default) + { + if (!_options.Enabled || string.IsNullOrWhiteSpace(domain)) return null; + + var key = domain.Trim().ToLowerInvariant(); + + lock (_memory) + { + if (_memory.TryGetValue(key, out var remembered)) return remembered; + } + + var cached = ReadCache(key); + if (cached is not null) + { + var icon = cached.Length == 0 ? null : cached; // zero bytes is a cached miss + lock (_memory) _memory[key] = icon; + return icon; + } + + await _concurrency.WaitAsync(cancellationToken); + try + { + var icon = await FetchAsync(key, cancellationToken); + WriteCache(key, icon ?? []); + lock (_memory) _memory[key] = icon; + return icon; + } + finally + { + _concurrency.Release(); + } + } + + private async Task FetchAsync(string domain, CancellationToken cancellationToken) + { + try + { + using var response = await _http.GetAsync($"{Uri.EscapeDataString(domain)}/icon.png", cancellationToken); + if (!response.IsSuccessStatusCode) + { + _logger?.LogDebug("No icon for {Domain} ({Status})", domain, (int)response.StatusCode); + return null; + } + + var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken); + return bytes.Length == 0 ? null : bytes; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // A missing icon is cosmetic. Never let it surface as an error to the user. + _logger?.LogDebug(ex, "Icon lookup failed for {Domain}", domain); + return null; + } + } + + /// + /// Cache file name. The domain is hashed rather than used directly so the cache directory is + /// not itself a plainly readable list of the sites in the vault. + /// + private string CachePath(string domain) => + Path.Combine( + _cacheDirectory, + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(domain)))[..24] + ".png"); + + private byte[]? ReadCache(string domain) + { + try + { + var path = CachePath(domain); + if (!File.Exists(path)) return null; + if (DateTime.UtcNow - File.GetLastWriteTimeUtc(path) > _options.CacheLifetime) return null; + return File.ReadAllBytes(path); + } + catch + { + return null; + } + } + + private void WriteCache(string domain, byte[] bytes) + { + try { File.WriteAllBytes(CachePath(domain), bytes); } + catch (Exception ex) { _logger?.LogDebug(ex, "Could not cache icon for {Domain}", domain); } + } + + public void Dispose() + { + _http.Dispose(); + _concurrency.Dispose(); + } +} diff --git a/src/Infrastructure/InfrastructureServiceExtensions.cs b/src/Infrastructure/InfrastructureServiceExtensions.cs new file mode 100644 index 0000000..c3b0610 --- /dev/null +++ b/src/Infrastructure/InfrastructureServiceExtensions.cs @@ -0,0 +1,74 @@ +using BitwardenSharp.Application.Abstractions; +using BitwardenSharp.Infrastructure.Bw; +using BitwardenSharp.Infrastructure.Icons; +using BitwardenSharp.Infrastructure.Serve; +using Microsoft.Extensions.DependencyInjection; + +namespace BitwardenSharp.Infrastructure; + +/// Registers the `bw`-backed adapter for the Application's IVaultClient port. +public static class InfrastructureServiceExtensions +{ + public static IServiceCollection AddBitwardenCli( + this IServiceCollection services, + Action? configure = null) + { + var options = new BwCliOptions(); + configure?.Invoke(options); + + services.AddSingleton(options); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } + + /// + /// Registers the bw serve adapter: one child process exposing the local Vault + /// Management API, with HTTP for every call. + /// + /// + /// Prefer this in any host that outlives a single command — the process-per-call adapter + /// pays a fresh Node start-up every time. The server is started lazily on first use and + /// stopped when the provider is disposed. Note the API is unauthenticated; see + /// . + /// + public static IServiceCollection AddBitwardenServe( + this IServiceCollection services, + Action? configure = null) + { + var options = new BwServeOptions(); + configure?.Invoke(options); + + services.AddSingleton(options); + services.AddSingleton(); + + // Registration must not start anything: the first service is resolved on the UI thread, + // and blocking there to await start-up deadlocks the app. BwServeConnection starts the + // server on first awaited use instead. + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(p => p.GetRequiredService()); + services.AddSingleton(p => p.GetRequiredService()); + return services; + } + + /// + /// Registers website icons from Bitwarden's icon service. + /// + /// + /// Opt-in by call site rather than by default: every lookup discloses a domain from the vault + /// to the service. See . + /// + public static IServiceCollection AddBitwardenIcons( + this IServiceCollection services, + Action? configure = null) + { + var options = new IconOptions(); + configure?.Invoke(options); + + services.AddSingleton(options); + services.AddSingleton(); + return services; + } +} diff --git a/src/Infrastructure/Serve/BwServeConnection.cs b/src/Infrastructure/Serve/BwServeConnection.cs new file mode 100644 index 0000000..79e7f97 --- /dev/null +++ b/src/Infrastructure/Serve/BwServeConnection.cs @@ -0,0 +1,66 @@ +using Microsoft.Extensions.Logging; + +namespace BitwardenSharp.Infrastructure.Serve; + +/// +/// Starts bw serve on first use and hands out the pointed at it. +/// +/// +/// +/// The server has to be running before its port — and therefore the client's base address — is +/// known, so acquiring the client is inherently asynchronous. This type exists to keep that +/// asynchrony honest. +/// +/// +/// Nothing here may block on a task. An earlier version started the server from a DI +/// factory with StartAsync().GetAwaiter().GetResult(). Resolving the first service happens +/// on the UI thread, where Avalonia installs a ; the awaits +/// inside startup then tried to resume on the thread that was blocked waiting for them, and the +/// app deadlocked before its window appeared. The gate below is a +/// awaited asynchronously for exactly that reason — never lock, never .Result. +/// +/// +public sealed class BwServeConnection( + BwServeProcess server, + ILogger? logger = null) : IAsyncDisposable +{ + private readonly SemaphoreSlim _gate = new(1, 1); + private HttpClient? _http; + + /// + /// The client for the local API, starting the server if this is the first call. Concurrent + /// callers wait on the same startup rather than racing to spawn two servers. + /// + public async Task GetClientAsync(CancellationToken cancellationToken = default) + { + if (_http is not null) return _http; + + await _gate.WaitAsync(cancellationToken); + try + { + if (_http is null) + { + await server.StartAsync(cancellationToken); + _http = new HttpClient + { + BaseAddress = server.BaseAddress, + Timeout = TimeSpan.FromMinutes(2), + }; + logger?.LogDebug("Vault Management API ready at {BaseAddress}", server.BaseAddress); + } + return _http; + } + finally + { + _gate.Release(); + } + } + + public async ValueTask DisposeAsync() + { + _http?.Dispose(); + _http = null; + await server.DisposeAsync(); + _gate.Dispose(); + } +} diff --git a/src/Infrastructure/Serve/BwServeProcess.cs b/src/Infrastructure/Serve/BwServeProcess.cs new file mode 100644 index 0000000..a17efb3 --- /dev/null +++ b/src/Infrastructure/Serve/BwServeProcess.cs @@ -0,0 +1,201 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; + +namespace BitwardenSharp.Infrastructure.Serve; + +/// How to run the local Vault Management API. +public sealed class BwServeOptions +{ + public string ExecutablePath { get; set; } = "bw"; + + /// + /// Loopback only. bw serve accepts all to bind every interface; do not use it — + /// the API has no authentication whatsoever. + /// + public string Hostname { get; set; } = "localhost"; + + /// Zero picks a free ephemeral port, which is the sane default. + public int Port { get; set; } + + public TimeSpan StartupTimeout { get; set; } = TimeSpan.FromSeconds(30); +} + +/// +/// Owns a child bw serve process exposing the Vault Management API. +/// +/// +/// +/// This is the right transport for a long-running host: one Node process serves every call over +/// HTTP, instead of paying a fresh ~0.5s interpreter start-up per bw invocation. Behind a +/// GUI listing 800 items that difference is the whole user experience. +/// +/// +/// The API is unauthenticated. Anything that can reach the port can read the entire vault +/// while it is unlocked — there is no token, no header, no handshake. The mitigations here are +/// therefore structural: bind loopback only, take a random ephemeral port rather than the +/// well-known 8087, own the process as a child, and kill it on dispose so the window is exactly +/// the lifetime of this object. Any local process running as this user can still reach it during +/// that window; that is inherent to the feature, not something this class can fix. +/// +/// +public sealed class BwServeProcess(BwServeOptions options, ILogger? logger = null) + : IAsyncDisposable +{ + private Process? _process; + private EventHandler? _processExitHook; + private readonly List _signalHooks = []; + + public Uri BaseAddress { get; private set; } = null!; + + public async Task StartAsync(CancellationToken cancellationToken = default) + { + if (_process is not null) return; + + var port = options.Port == 0 ? FreePort() : options.Port; + + var startInfo = new ProcessStartInfo + { + FileName = options.ExecutablePath, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + startInfo.ArgumentList.Add("serve"); + startInfo.ArgumentList.Add("--hostname"); + startInfo.ArgumentList.Add(options.Hostname); + startInfo.ArgumentList.Add("--port"); + startInfo.ArgumentList.Add(port.ToString()); + + _process = Process.Start(startInfo) + ?? throw new InvalidOperationException($"could not start '{options.ExecutablePath} serve'"); + + BaseAddress = new Uri($"http://{options.Hostname}:{port}/"); + logger?.LogInformation("Started bw serve on {BaseAddress} (pid {Pid})", BaseAddress, _process.Id); + + RegisterCleanupHooks(); + + await WaitUntilAnsweringAsync(cancellationToken); + } + + /// + /// Polls /status until the server answers. bw serve prints its banner before the + /// listener is actually accepting, so waiting on stdout would race. + /// + private async Task WaitUntilAnsweringAsync(CancellationToken cancellationToken) + { + using var probe = new HttpClient { BaseAddress = BaseAddress, Timeout = TimeSpan.FromSeconds(2) }; + var deadline = DateTimeOffset.UtcNow + options.StartupTimeout; + + while (DateTimeOffset.UtcNow < deadline) + { + if (_process!.HasExited) + { + var stderr = await _process.StandardError.ReadToEndAsync(cancellationToken); + throw new InvalidOperationException( + $"bw serve exited immediately (code {_process.ExitCode}): {stderr.Trim()}"); + } + + try + { + using var response = await probe.GetAsync("status", cancellationToken); + if (response.IsSuccessStatusCode) return; + } + catch (HttpRequestException) { /* not listening yet */ } + catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) { } + + await Task.Delay(100, cancellationToken); + } + + throw new TimeoutException($"bw serve did not answer within {options.StartupTimeout.TotalSeconds:0}s"); + } + + /// Asks the OS for an unused port by binding one and immediately releasing it. + private static int FreePort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + /// + /// Makes sure the child dies with us. + /// + /// + /// A child is not reaped with its parent on Unix, so an app that exits without disposing — a + /// crash, a kill, a stopped debug session — would otherwise leave bw serve running + /// indefinitely with an unauthenticated port onto an unlocked vault. + /// + /// alone is not enough: on a signal the runtime gives + /// handlers a short budget and the kill was observed not to land. + /// runs first and synchronously, so the child is gone before the runtime begins tearing down. + /// SIGKILL remains uncoverable by anything. + /// + private void RegisterCleanupHooks() + { + _processExitHook = (_, _) => KillNow(); + AppDomain.CurrentDomain.ProcessExit += _processExitHook; + + foreach (var signal in (PosixSignal[])[PosixSignal.SIGTERM, PosixSignal.SIGINT, PosixSignal.SIGHUP]) + { + try + { + _signalHooks.Add(PosixSignalRegistration.Create(signal, _ => KillNow())); + } + catch (PlatformNotSupportedException) + { + // Not every signal exists on every platform; ProcessExit still covers the rest. + } + } + } + + /// Synchronous kill, for the exit hooks where there is no time to await. + private void KillNow() + { + try + { + if (_process is { HasExited: false }) _process.Kill(entireProcessTree: true); + } + catch + { + // Exiting anyway; there is nothing useful to do with a failure here. + } + } + + public async ValueTask DisposeAsync() + { + if (_processExitHook is not null) + { + AppDomain.CurrentDomain.ProcessExit -= _processExitHook; + _processExitHook = null; + } + + foreach (var hook in _signalHooks) hook.Dispose(); + _signalHooks.Clear(); + + if (_process is null) return; + + try + { + if (!_process.HasExited) + { + _process.Kill(entireProcessTree: true); + await _process.WaitForExitAsync(); + } + } + catch (Exception ex) + { + logger?.LogWarning(ex, "Could not stop bw serve cleanly"); + } + finally + { + _process.Dispose(); + _process = null; + } + } +} diff --git a/src/Infrastructure/Serve/BwServeVaultClient.cs b/src/Infrastructure/Serve/BwServeVaultClient.cs new file mode 100644 index 0000000..c3ee3a2 --- /dev/null +++ b/src/Infrastructure/Serve/BwServeVaultClient.cs @@ -0,0 +1,252 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using BitwardenSharp.Application.Abstractions; +using BitwardenSharp.Domain.Vault; +using BitwardenSharp.Infrastructure.Bw; +using BitwardenSharp.Infrastructure.Bw.Contracts; +using Microsoft.Extensions.Logging; + +namespace BitwardenSharp.Infrastructure.Serve; + +/// Every Vault Management API response is wrapped in this envelope. +internal sealed class ServeEnvelope +{ + [JsonPropertyName("success")] public bool Success { get; set; } + [JsonPropertyName("data")] public T? Data { get; set; } + [JsonPropertyName("message")] public string? Message { get; set; } +} + +/// A list response nests its payload one level deeper. +internal sealed class ServeList +{ + [JsonPropertyName("object")] public string? Object { get; set; } + [JsonPropertyName("data")] public List Data { get; set; } = []; +} + +/// +/// /status alone nests its payload one level further, under a "template" object, rather +/// than placing it directly in data like every other endpoint. Verified against +/// bw 2026.7.0 — do not assume the envelope is uniform. +/// +internal sealed class ServeTemplate +{ + [JsonPropertyName("object")] public string? Object { get; set; } + [JsonPropertyName("template")] public T? Template { get; set; } +} + +internal sealed class ServeMessage +{ + [JsonPropertyName("title")] public string? Title { get; set; } + [JsonPropertyName("message")] public string? Message { get; set; } + [JsonPropertyName("raw")] public string? Raw { get; set; } +} + +/// Raised when the Vault Management API reports a failure. +public sealed class BwServeException(string message) : Exception(message); + +/// +/// An and over the local Vault Management +/// API exposed by bw serve. +/// +/// +/// +/// Preferred over the process-per-call adapter wherever the host outlives a single command: one +/// child process, HTTP for everything, no ~0.5s Node start-up per operation. +/// +/// +/// It also removes the awkward part of the CLI adapter entirely. There is no base64 payload and +/// no argv to keep secrets out of — an item is a JSON request body and the master password +/// is a field in one, over loopback. See for what that costs. +/// +/// +public sealed class BwServeVaultClient( + BwServeConnection connection, + ILogger? logger = null) : IVaultClient, IVaultSession +{ + private static readonly JsonSerializerOptions Json = new() + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + // ── IVaultSession ──────────────────────────────────────────────────────────────────────── + + public async Task GetStatusAsync(CancellationToken cancellationToken = default) + { + var wrapper = await GetAsync>("status", cancellationToken); + var status = wrapper.Template + ?? throw new BwServeException("status response carried no template"); + return new VaultStatus + { + Status = status.Status, + UserEmail = status.UserEmail, + ServerUrl = status.ServerUrl, + LastSync = status.LastSync, + }; + } + + public async Task UnlockAsync( + string masterPassword, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(masterPassword)) return UnlockResult.Failure("Enter your master password."); + + var http = await connection.GetClientAsync(cancellationToken); + using var response = await http.PostAsJsonAsync( + "unlock", new { password = masterPassword }, Json, cancellationToken); + + var envelope = await response.Content + .ReadFromJsonAsync>(Json, cancellationToken); + + if (envelope?.Success != true) + { + var error = envelope?.Message ?? $"unlock failed ({(int)response.StatusCode})"; + logger?.LogWarning("Unlock rejected: {Error}", error); + return UnlockResult.Failure(error); + } + + logger?.LogInformation("Vault unlocked"); + return UnlockResult.Success(); + } + + public async Task LockAsync(CancellationToken cancellationToken = default) + { + var http = await connection.GetClientAsync(cancellationToken); + using var response = await http.PostAsync("lock", content: null, cancellationToken); + response.EnsureSuccessStatusCode(); + logger?.LogInformation("Vault locked"); + } + + // ── IVaultClient ───────────────────────────────────────────────────────────────────────── + + public async Task SyncAsync(CancellationToken cancellationToken = default) + { + var http = await connection.GetClientAsync(cancellationToken); + using var response = await http.PostAsync("sync", content: null, cancellationToken); + await EnsureSucceededAsync(response, cancellationToken); + } + + public async Task> GetItemsAsync(CancellationToken cancellationToken = default) + { + var list = await GetAsync>("list/object/items", cancellationToken); + logger?.LogDebug("Read {Count} items from the vault", list.Data.Count); + return list.Data.Select(BwItemMapper.ToDomain).ToList(); + } + + public async Task> GetFoldersAsync(CancellationToken cancellationToken = default) + { + var list = await GetAsync>("list/object/folders", cancellationToken); + return list.Data.Select(BwItemMapper.ToDomain).ToList(); + } + + public async Task GetItemAsync(string id, CancellationToken cancellationToken = default) => + BwItemMapper.ToDomain(await GetWireItemAsync(id, cancellationToken)); + + public async Task UpdateItemAsync( + VaultItem item, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(item); + + // As with the CLI adapter, a PUT replaces the item wholesale, so the wire object is + // re-read and the domain changes applied onto it — otherwise fields this model does not + // name would be deleted from the vault. + var wire = BwItemMapper.ApplyTo(item, await GetWireItemAsync(item.Id, cancellationToken)); + + var http = await connection.GetClientAsync(cancellationToken); + using var response = await http.PutAsJsonAsync($"object/item/{item.Id}", wire, Json, cancellationToken); + var envelope = await ReadEnvelopeAsync(response, cancellationToken); + return BwItemMapper.ToDomain(envelope); + } + + public async Task DeleteItemAsync( + string id, + bool permanent = false, + CancellationToken cancellationToken = default) + { + // The API soft-deletes to trash by default, matching the CLI. + var path = permanent ? $"object/item/{id}?permanent=true" : $"object/item/{id}"; + var http = await connection.GetClientAsync(cancellationToken); + using var response = await http.DeleteAsync(path, cancellationToken); + await EnsureSucceededAsync(response, cancellationToken); + logger?.LogInformation( + "Deleted item {ItemId} ({Disposition})", id, permanent ? "permanently" : "to trash"); + } + + // ── plumbing ───────────────────────────────────────────────────────────────────────────── + + public async Task CreateItemAsync( + VaultItem item, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(item); + + var wire = BwItemMapper.ApplyTo(item, BwItemMapper.NewWireItem(item)); + var http = await connection.GetClientAsync(cancellationToken); + using var response = await http.PostAsJsonAsync("object/item", wire, Json, cancellationToken); + return BwItemMapper.ToDomain(await ReadEnvelopeAsync(response, cancellationToken)); + } + + public async Task CreateFolderAsync( + string name, CancellationToken cancellationToken = default) + { + var http = await connection.GetClientAsync(cancellationToken); + using var response = await http.PostAsJsonAsync( + "object/folder", new BwFolder { Name = name }, Json, cancellationToken); + return BwItemMapper.ToDomain(await ReadEnvelopeAsync(response, cancellationToken)); + } + + public async Task RenameFolderAsync( + string id, string name, CancellationToken cancellationToken = default) + { + var http = await connection.GetClientAsync(cancellationToken); + using var response = await http.PutAsJsonAsync( + $"object/folder/{id}", new BwFolder { Id = id, Name = name }, Json, cancellationToken); + return BwItemMapper.ToDomain(await ReadEnvelopeAsync(response, cancellationToken)); + } + + public async Task DeleteFolderAsync(string id, CancellationToken cancellationToken = default) + { + var http = await connection.GetClientAsync(cancellationToken); + using var response = await http.DeleteAsync($"object/folder/{id}", cancellationToken); + await EnsureSucceededAsync(response, cancellationToken); + logger?.LogInformation("Deleted folder {FolderId}", id); + } + + private async Task GetWireItemAsync(string id, CancellationToken cancellationToken) => + await GetAsync($"object/item/{id}", cancellationToken); + + private async Task GetAsync(string path, CancellationToken cancellationToken) + { + var http = await connection.GetClientAsync(cancellationToken); + using var response = await http.GetAsync(path, cancellationToken); + return await ReadEnvelopeAsync(response, cancellationToken); + } + + private static async Task ReadEnvelopeAsync( + HttpResponseMessage response, + CancellationToken cancellationToken) + { + var envelope = await response.Content + .ReadFromJsonAsync>(Json, cancellationToken); + + if (envelope is null) + throw new BwServeException($"empty response from {response.RequestMessage?.RequestUri}"); + + if (!envelope.Success || envelope.Data is null) + throw new BwServeException(envelope.Message ?? $"request failed ({(int)response.StatusCode})"); + + return envelope.Data; + } + + private static async Task EnsureSucceededAsync( + HttpResponseMessage response, + CancellationToken cancellationToken) + { + var envelope = await response.Content + .ReadFromJsonAsync>(Json, cancellationToken); + + if (envelope?.Success != true) + throw new BwServeException(envelope?.Message ?? $"request failed ({(int)response.StatusCode})"); + } +} diff --git a/src/Presentation/Cli/BitwardenSharp.Cli.csproj b/src/Presentation/Cli/BitwardenSharp.Cli.csproj new file mode 100644 index 0000000..726e507 --- /dev/null +++ b/src/Presentation/Cli/BitwardenSharp.Cli.csproj @@ -0,0 +1,20 @@ + + + Exe + Command-line tool for BitwardenSharp — scan a vault for duplicates and merge them. + bitwarden;vault;cli;dotnet-tool;password-manager + true + bwsharp + bwsharp + BitwardenSharp.Cli + + + + + + + + + + + diff --git a/src/Presentation/Cli/Commands/MergeCommand.cs b/src/Presentation/Cli/Commands/MergeCommand.cs new file mode 100644 index 0000000..59bbc41 --- /dev/null +++ b/src/Presentation/Cli/Commands/MergeCommand.cs @@ -0,0 +1,133 @@ +using System.ComponentModel; +using BitwardenSharp.Application.Abstractions; +using BitwardenSharp.Application.Duplicates; +using BitwardenSharp.Application.Merging; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace BitwardenSharp.Cli.Commands; + +public sealed class MergeSettings : CommandSettings +{ + [CommandArgument(0, "")] + [Description("Group ids from `bwsharp scan`, e.g. EXACT-001 RELATED-003")] + public string[] GroupIds { get; init; } = []; + + [CommandOption("--apply")] + [Description("Actually write. Without this the merge is computed and shown but nothing changes")] + public bool Apply { get; init; } + + [CommandOption("-y|--yes")] + [Description("Skip the confirmation prompt (for non-interactive use)")] + public bool AssumeYes { get; init; } +} + +/// +/// Merges approved duplicate groups. +/// +/// +/// Dry run is the default and --apply is the only way to write, because the losing side of +/// a merge is deleted. Deletions are soft, so Bitwarden's trash holds them for 30 days. +/// +public sealed class MergeCommand(IVaultClient vault, DuplicateScanner scanner, MergeExecutor executor) + : AsyncCommand +{ + protected override async Task ExecuteAsync(CommandContext context, MergeSettings settings, CancellationToken cancellationToken) + { + var status = await vault.GetStatusAsync(cancellationToken); + if (!status.IsUnlocked) + { + AnsiConsole.MarkupLine($"[red]Vault is {status.Status}.[/] Unlock it first."); + return 1; + } + + // Re-scan rather than trusting ids from an older run: group ids are only stable within + // one scan of one vault state. + await vault.SyncAsync(cancellationToken); + var result = scanner.Scan(await vault.GetItemsAsync(cancellationToken)); + + var wanted = settings.GroupIds.ToHashSet(StringComparer.OrdinalIgnoreCase); + var groups = result.Groups.Where(g => wanted.Contains(g.Id)).ToList(); + + var missing = wanted.Except(groups.Select(g => g.Id), StringComparer.OrdinalIgnoreCase).ToList(); + if (missing.Count > 0) + { + AnsiConsole.MarkupLine( + $"[red]Not found in the current scan:[/] {string.Join(", ", missing)}\n" + + "[grey]Group ids change between scans — re-run `bwsharp scan`.[/]"); + return 1; + } + + var refused = groups.Where(g => !g.CanMerge).ToList(); + foreach (var group in refused) + AnsiConsole.MarkupLine( + $"[yellow]{group.Id} will be skipped:[/] " + + Markup.Escape(string.Join("; ", + group.Warnings.Where(w => w.IsBlocking).Select(w => w.Message) + .DefaultIfEmpty($"{group.Category} is review-only")))); + + var actionable = groups.Where(g => g.CanMerge).ToList(); + if (actionable.Count == 0) + { + AnsiConsole.MarkupLine("[yellow]Nothing to merge.[/]"); + return 1; + } + + var deletions = actionable.Sum(g => g.Losers.Count()); + AnsiConsole.MarkupLine( + $"\n[bold]{(settings.Apply ? "APPLY" : "DRY RUN")}[/] — " + + $"{actionable.Count} group(s), {deletions} deletion(s)\n"); + + if (settings.Apply && !settings.AssumeYes) + { + foreach (var group in actionable) + AnsiConsole.MarkupLine( + $" {group.Id}: keep [green]{Markup.Escape(group.Survivor.Name)}[/], " + + $"delete [red]{string.Join(", ", group.Losers.Select(l => Markup.Escape(l.Name)))}[/]"); + + if (!AnsiConsole.Confirm($"\nDelete {deletions} item(s) to trash?", defaultValue: false)) + { + AnsiConsole.MarkupLine("[yellow]Aborted. Nothing was changed.[/]"); + return 1; + } + } + + var failures = 0; + foreach (var group in actionable) + { + var outcome = await executor.ApplyAsync(group, dryRun: !settings.Apply, cancellationToken); + + var colour = outcome.Status switch + { + MergeStatus.Merged => "green", + MergeStatus.Skipped => "yellow", + _ => "red", + }; + AnsiConsole.MarkupLine($"[{colour}]{outcome.Status}[/] {group.Id} — {Markup.Escape(group.Key)}"); + + foreach (var change in outcome.Changes) + AnsiConsole.MarkupLine($" [grey]{Markup.Escape(change)}[/]"); + + if (outcome.Message is not null) + AnsiConsole.MarkupLine($" [yellow]{Markup.Escape(outcome.Message)}[/]"); + + foreach (var id in outcome.DeletedItemIds) + AnsiConsole.MarkupLine($" [red]deleted[/] [grey]{id}[/] → trash"); + + if (outcome.Status is MergeStatus.Failed or MergeStatus.VerificationFailed) failures++; + } + + if (settings.Apply) + { + await vault.SyncAsync(cancellationToken); + AnsiConsole.MarkupLine( + "\n[grey]Deleted items are in Bitwarden's trash and restorable for 30 days.[/]"); + } + else + { + AnsiConsole.MarkupLine("\n[grey]Dry run. Re-run with --apply to write.[/]"); + } + + return failures > 0 ? 1 : 0; + } +} diff --git a/src/Presentation/Cli/Commands/ScanCommand.cs b/src/Presentation/Cli/Commands/ScanCommand.cs new file mode 100644 index 0000000..f639ac0 --- /dev/null +++ b/src/Presentation/Cli/Commands/ScanCommand.cs @@ -0,0 +1,120 @@ +using System.ComponentModel; +using BitwardenSharp.Application.Abstractions; +using BitwardenSharp.Application.Duplicates; +using BitwardenSharp.Domain.Duplicates; +using BitwardenSharp.Infrastructure.Bw; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace BitwardenSharp.Cli.Commands; + +public sealed class ScanSettings : CommandSettings +{ + [CommandOption("-c|--category ")] + [Description("Show only one category, e.g. ExactDuplicate")] + public string? Category { get; init; } + + [CommandOption("--detail")] + [Description("List every member of every group, not just the summary")] + public bool Detail { get; init; } + + [CommandOption("--no-sync")] + [Description("Skip the server sync and scan the local cache")] + public bool NoSync { get; init; } + + [CommandOption("--from ")] + [Description("Scan a saved `bw list items` dump instead of a live vault. Read-only.")] + public string? FromFile { get; init; } +} + +/// Reads the vault and reports duplicate groups. Never writes. +public sealed class ScanCommand(IVaultClient vault, DuplicateScanner scanner) + : AsyncCommand +{ + protected override async Task ExecuteAsync(CommandContext context, ScanSettings settings, CancellationToken cancellationToken) + { + // A file snapshot needs no unlock, and never reaches the live vault. + var source = settings.FromFile is null ? vault : new JsonFileVaultClient(settings.FromFile); + + var status = await source.GetStatusAsync(cancellationToken); + if (!status.IsUnlocked) + { + AnsiConsole.MarkupLine( + $"[red]Vault is {status.Status}.[/] Unlock it and export the session:\n" + + " [grey]export BW_SESSION=$(bw unlock --raw)[/]"); + return 1; + } + + DuplicateScanResult result = default!; + await AnsiConsole.Status().StartAsync("Reading vault…", async ctx => + { + if (!settings.NoSync && settings.FromFile is null) + { + ctx.Status("Syncing…"); + await source.SyncAsync(cancellationToken); + } + + ctx.Status("Reading items…"); + var items = await source.GetItemsAsync(cancellationToken); + + ctx.Status("Scanning for duplicates…"); + result = scanner.Scan(items); + }); + + AnsiConsole.MarkupLine( + $"\n[bold]{result.TotalItems}[/] items, [bold]{result.LoginCount}[/] logins, " + + $"[bold]{result.Groups.Count}[/] groups, " + + $"[bold green]{result.MergeableDeletions}[/] deletions available\n"); + + var summary = new Table().Border(TableBorder.Rounded); + summary.AddColumns("Category", "Groups", "Deletions", "Disposition"); + foreach (var category in Enum.GetValues()) + { + var groups = result.Groups.Where(g => g.Category == category).ToList(); + if (groups.Count == 0) continue; + var mergeable = category.Disposition() == MergeDisposition.Mergeable; + summary.AddRow( + category.ToString(), + groups.Count.ToString(), + mergeable ? groups.Where(g => g.CanMerge).Sum(g => g.Losers.Count()).ToString() : "—", + mergeable ? "[green]mergeable[/]" : "[yellow]review only[/]"); + } + AnsiConsole.Write(summary); + + var shown = result.Groups.AsEnumerable(); + if (!string.IsNullOrWhiteSpace(settings.Category)) + { + if (!Enum.TryParse(settings.Category, ignoreCase: true, out var wanted)) + { + AnsiConsole.MarkupLine($"[red]Unknown category '{settings.Category}'.[/]"); + return 1; + } + shown = shown.Where(g => g.Category == wanted); + } + + foreach (var group in shown) + { + var blocked = group.Warnings.Any(w => w.IsBlocking); + var marker = group.CanMerge ? "[green]●[/]" : blocked ? "[red]●[/]" : "[yellow]●[/]"; + AnsiConsole.MarkupLine( + $"\n{marker} [bold]{group.Id}[/] [grey]{group.Category}[/] {Markup.Escape(group.Key)}"); + AnsiConsole.MarkupLine($" keep [green]{Markup.Escape(group.Survivor.Name)}[/]"); + + foreach (var loser in group.Losers) + AnsiConsole.MarkupLine($" drop [red]{Markup.Escape(loser.Name)}[/]"); + + if (settings.Detail) + foreach (var uri in group.Members.SelectMany(m => m.Uris).Select(u => u.Uri).Distinct()) + AnsiConsole.MarkupLine($" [grey]{Markup.Escape(uri)}[/]"); + + foreach (var warning in group.Warnings) + AnsiConsole.MarkupLine( + $" {(warning.IsBlocking ? "[red]blocked[/]" : "[yellow]warn[/]")}: " + + Markup.Escape(warning.Message)); + } + + AnsiConsole.MarkupLine( + "\n[grey]Merge with:[/] bwsharp merge [grey](add --apply to write)[/]"); + return 0; + } +} diff --git a/src/Presentation/Cli/Hosting/TypeRegistrar.cs b/src/Presentation/Cli/Hosting/TypeRegistrar.cs new file mode 100644 index 0000000..382a0dd --- /dev/null +++ b/src/Presentation/Cli/Hosting/TypeRegistrar.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.DependencyInjection; +using Spectre.Console.Cli; + +namespace BitwardenSharp.Cli.Hosting; + +/// Bridges Spectre.Console.Cli's resolver onto Microsoft.Extensions.DependencyInjection. +internal sealed class TypeRegistrar(IServiceCollection services) : ITypeRegistrar +{ + public void Register(Type service, Type implementation) => services.AddSingleton(service, implementation); + + public void RegisterInstance(Type service, object implementation) => + services.AddSingleton(service, implementation); + + public void RegisterLazy(Type service, Func factory) => + services.AddSingleton(service, _ => factory()); + + public ITypeResolver Build() => new TypeResolver(services.BuildServiceProvider()); +} + +internal sealed class TypeResolver(ServiceProvider provider) : ITypeResolver, IDisposable +{ + public object? Resolve(Type? type) => type is null ? null : provider.GetService(type); + + public void Dispose() => provider.Dispose(); +} diff --git a/src/Presentation/Cli/Program.cs b/src/Presentation/Cli/Program.cs new file mode 100644 index 0000000..0644e92 --- /dev/null +++ b/src/Presentation/Cli/Program.cs @@ -0,0 +1,28 @@ +using BitwardenSharp.Application; +using BitwardenSharp.Cli.Commands; +using BitwardenSharp.Cli.Hosting; +using BitwardenSharp.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Spectre.Console.Cli; + +var services = new ServiceCollection(); +services.AddBitwardenSharpApplication(); +services.AddBitwardenCli(); + +var app = new CommandApp(new TypeRegistrar(services)); +app.Configure(config => +{ + config.SetApplicationName("bwsharp"); + + config.AddCommand("scan") + .WithDescription("Find duplicate logins in the vault. Read-only.") + .WithExample("scan") + .WithExample("scan", "--category", "ExactDuplicate", "--detail"); + + config.AddCommand("merge") + .WithDescription("Merge approved duplicate groups. Dry run unless --apply is given.") + .WithExample("merge", "EXACT-001", "EXACT-002") + .WithExample("merge", "EXACT-001", "--apply"); +}); + +return await app.RunAsync(args); diff --git a/src/Presentation/Desktop/App.axaml b/src/Presentation/Desktop/App.axaml new file mode 100644 index 0000000..e7feec7 --- /dev/null +++ b/src/Presentation/Desktop/App.axaml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/src/Presentation/Desktop/App.axaml.cs b/src/Presentation/Desktop/App.axaml.cs new file mode 100644 index 0000000..804460c --- /dev/null +++ b/src/Presentation/Desktop/App.axaml.cs @@ -0,0 +1,75 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using BitwardenSharp.Application; +using BitwardenSharp.Desktop.Services; +using BitwardenSharp.Desktop.ViewModels; +using BitwardenSharp.Desktop.Views; +using BitwardenSharp.Infrastructure; +using Microsoft.Extensions.DependencyInjection; + +namespace BitwardenSharp.Desktop; + +public partial class App : Avalonia.Application +{ + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var services = new ServiceCollection(); + services.AddBitwardenSharpApplication(); + // A GUI outlives every call, so the long-lived server beats process-per-call. + services.AddBitwardenServe(); + + // Website icons are fetched from Bitwarden's icon service, which means each lookup + // discloses a domain from the vault. Regional host to match the account; flip + // Enabled to false and nothing leaves the machine. + services.AddBitwardenIcons(o => o.Enabled = true); + services.AddSingleton(); + services.AddSingleton(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + var provider = services.BuildServiceProvider(); + + desktop.MainWindow = new MainWindow + { + DataContext = provider.GetRequiredService(), + }; + + // The session key dies with the process; lock explicitly anyway so the vault is not + // left unlocked for the next `bw` invocation from any other tool. + // + // ShutdownRequested is synchronous, so the only way to await here without blocking + // the UI thread is to veto the first request, do the work asynchronously, and then + // ask for shutdown again. Blocking instead -- even via Task.Run().Wait() -- is what + // froze this app once already. + var shuttingDown = false; + desktop.ShutdownRequested += async (_, e) => + { + if (shuttingDown) return; + + shuttingDown = true; + e.Cancel = true; + + try + { + await provider.GetRequiredService().ShutdownAsync(); + await provider.DisposeAsync(); + } + catch + { + // async void: an escaping exception here would crash on the way out rather + // than exiting. Nothing left to salvage at this point anyway. + } + + desktop.Shutdown(); + }; + } + + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/src/Presentation/Desktop/BitwardenSharp.Desktop.csproj b/src/Presentation/Desktop/BitwardenSharp.Desktop.csproj new file mode 100644 index 0000000..f750e5d --- /dev/null +++ b/src/Presentation/Desktop/BitwardenSharp.Desktop.csproj @@ -0,0 +1,26 @@ + + + WinExe + BitwardenSharp.Desktop + BitwardenSharp.Desktop + true + app.manifest + true + false + Desktop UI for BitwardenSharp. + + + + + + + + + + + + + + + + diff --git a/src/Presentation/Desktop/Program.cs b/src/Presentation/Desktop/Program.cs new file mode 100644 index 0000000..d80a45a --- /dev/null +++ b/src/Presentation/Desktop/Program.cs @@ -0,0 +1,18 @@ +using Avalonia; + +namespace BitwardenSharp.Desktop; + +internal static class Program +{ + // Avalonia requires this to be called before anything touches the toolkit; keep it free of + // application logic so a failure here is unambiguously a platform-init failure. + [STAThread] + public static void Main(string[] args) => + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + + public static AppBuilder BuildAvaloniaApp() => + AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); +} diff --git a/src/Presentation/Desktop/Services/IconLoader.cs b/src/Presentation/Desktop/Services/IconLoader.cs new file mode 100644 index 0000000..2b65877 --- /dev/null +++ b/src/Presentation/Desktop/Services/IconLoader.cs @@ -0,0 +1,72 @@ +using Avalonia.Media.Imaging; +using BitwardenSharp.Application.Abstractions; +using BitwardenSharp.Domain.Uris; +using BitwardenSharp.Domain.Vault; + +namespace BitwardenSharp.Desktop.Services; + +/// +/// Turns a vault item into a website icon, decoded once per domain. +/// +/// +/// Many items share a domain — a vault with 87 duplicate groups has a lot of repeats — so the +/// decoded is cached and shared rather than decoded per item. Bitmaps are +/// never disposed while in use here; they live for the life of the app, which for 24×24 favicons +/// is a few hundred kilobytes at most. +/// +public sealed class IconLoader(IIconProvider icons) +{ + private readonly Dictionary _decoded = new(StringComparer.OrdinalIgnoreCase); + private readonly SemaphoreSlim _gate = new(1, 1); + + public bool IsEnabled => icons.IsEnabled; + + /// + /// The domain an item's icon is derived from — its first web URI. Null for items with no URI, + /// which is why a secure note or an SSH key never has one in any Bitwarden client either. + /// + public static string? IconDomainFor(VaultItem item) => + item.Uris + .Select(u => UriTarget.Parse(u.Uri)) + .FirstOrDefault(t => t?.Kind == UriTargetKind.Domain) + ?.Value; + + public async Task GetAsync(string domain, CancellationToken cancellationToken = default) + { + lock (_decoded) + { + if (_decoded.TryGetValue(domain, out var cached)) return cached; + } + + var bytes = await icons.GetIconAsync(domain, cancellationToken); + + await _gate.WaitAsync(cancellationToken); + try + { + if (_decoded.TryGetValue(domain, out var raced)) return raced; + + Bitmap? bitmap = null; + if (bytes is { Length: > 0 }) + { + try + { + using var stream = new MemoryStream(bytes); + bitmap = new Bitmap(stream); + } + catch + { + // The service occasionally returns something that is not a decodable image. + // A placeholder is a fine outcome; a crash is not. + bitmap = null; + } + } + + lock (_decoded) _decoded[domain] = bitmap; + return bitmap; + } + finally + { + _gate.Release(); + } + } +} diff --git a/src/Presentation/Desktop/ViewLocator.cs b/src/Presentation/Desktop/ViewLocator.cs new file mode 100644 index 0000000..8a50a88 --- /dev/null +++ b/src/Presentation/Desktop/ViewLocator.cs @@ -0,0 +1,25 @@ +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using BitwardenSharp.Desktop.ViewModels; + +namespace BitwardenSharp.Desktop; + +/// Resolves a view for a view-model by naming convention: Foo/ViewModels/XViewModel -> Views/XView. +public class ViewLocator : IDataTemplate +{ + public Control Build(object? data) + { + if (data is null) return new TextBlock { Text = "no view-model" }; + + var name = data.GetType().FullName! + .Replace("ViewModels", "Views", StringComparison.Ordinal) + .Replace("ViewModel", "View", StringComparison.Ordinal); + + var type = Type.GetType(name); + return type is not null + ? (Control)Activator.CreateInstance(type)! + : new TextBlock { Text = $"view not found: {name}" }; + } + + public bool Match(object? data) => data is ViewModelBase; +} diff --git a/src/Presentation/Desktop/ViewModels/DuplicateGroupViewModel.cs b/src/Presentation/Desktop/ViewModels/DuplicateGroupViewModel.cs new file mode 100644 index 0000000..4c5e774 --- /dev/null +++ b/src/Presentation/Desktop/ViewModels/DuplicateGroupViewModel.cs @@ -0,0 +1,98 @@ +using BitwardenSharp.Application.Merging; +using BitwardenSharp.Domain.Duplicates; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace BitwardenSharp.Desktop.ViewModels; + +/// What has happened to a group during this session's queue run. +public enum QueueState +{ + Pending, + Merged, + Skipped, + Failed, +} + +/// One row in the duplicate queue. +public sealed partial class DuplicateGroupViewModel : ViewModelBase +{ + public DuplicateGroupViewModel(DuplicateGroup group) + { + Group = group; + Survivor = new ItemViewModel(group.Survivor); + + // Every group carries the default draft from the start, so the row can describe exactly + // what Approve would do without the editor ever being opened. + Draft = MergeDraft.Default(group); + } + + public DuplicateGroup Group { get; } + + public ItemViewModel Survivor { get; } + + /// Replaced when the editor resolves the group differently. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(PlanSummary), nameof(KeepsName), nameof(DropCount))] + private MergeDraft _draft; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsPending), nameof(StateGlyph))] + private QueueState _state = QueueState.Pending; + + [ObservableProperty] private string? _stateDetail; + + /// Whether this row is ticked for a bulk approve. + [ObservableProperty] private bool _isSelected; + + public string Id => Group.Id; + public DuplicateCategory Category => Group.Category; + public string Key => Group.Key; + public int MemberCount => Group.Members.Count; + public bool CanMerge => Group.CanMerge; + public bool IsPending => State == QueueState.Pending; + + /// + /// Whether the default is safe to approve without looking. True only for the categories that + /// are mergeable at all and where the credentials already agree — which is the whole point of + /// the fast path: those decisions are cosmetic. + /// + public bool IsRoutine => + CanMerge + && Category is DuplicateCategory.ExactDuplicate or DuplicateCategory.RelatedDomain + && Draft.Overwrites.Count == 0; + + /// Groups that need a human: a real credential conflict, or a blocking warning. + public bool NeedsAttention => !IsRoutine; + + public string KeepsName => Draft.TargetItem?.Name ?? "a new item"; + + public int DropCount => Draft.Doomed.Count(); + + public string PlanSummary => Draft.Target.IsNewItem + ? $"create a new item, delete all {MemberCount}" + : $"keep \"{KeepsName}\", delete {DropCount}"; + + public IReadOnlyList Warnings => Group.Warnings; + + public bool HasWarnings => Group.Warnings.Count > 0; + + public string WarningSummary => string.Join(" · ", Group.Warnings.Select(w => w.Message)); + + public string StateGlyph => State switch + { + QueueState.Merged => "✓", + QueueState.Skipped => "—", + QueueState.Failed => "✗", + _ => "", + }; + + public string CategoryLabel => Category switch + { + DuplicateCategory.ExactDuplicate => "same site", + DuplicateCategory.RelatedDomain => "related site", + DuplicateCategory.CredentialConflict => "different passwords", + DuplicateCategory.InfrastructureSharedCredential => "shared across hosts", + DuplicateCategory.SameName => "same name", + _ => Category.ToString(), + }; +} diff --git a/src/Presentation/Desktop/ViewModels/DuplicatesViewModel.cs b/src/Presentation/Desktop/ViewModels/DuplicatesViewModel.cs new file mode 100644 index 0000000..5ea05aa --- /dev/null +++ b/src/Presentation/Desktop/ViewModels/DuplicatesViewModel.cs @@ -0,0 +1,240 @@ +using System.Collections.ObjectModel; +using BitwardenSharp.Application.Abstractions; +using BitwardenSharp.Application.Duplicates; +using BitwardenSharp.Application.Merging; +using BitwardenSharp.Desktop.Services; +using BitwardenSharp.Domain.Duplicates; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace BitwardenSharp.Desktop.ViewModels; + +/// +/// The duplicate queue: every group the scanner found, with the merge it proposes. +/// +/// +/// The split between routine and needs-attention is the point of this screen. On a real vault the +/// overwhelming majority of mergeable groups involve no credential decision at all — the members +/// agree on username and password by definition, and only the name, folder and URI set differ. +/// Routing all of those through a three-pane editor would be ceremony. They get one click; the +/// ones that genuinely conflict get the editor. +/// +public sealed partial class DuplicatesViewModel( + IVaultClient vault, + DuplicateScanner scanner, + MergeExecutor executor, + IconLoader iconLoader) : ViewModelBase +{ + public event Action? Closed; + + /// Asks the view to confirm something destructive. Returns false if declined. + public Func>? Confirm { get; set; } + + public ObservableCollection Groups { get; } = []; + + [ObservableProperty] private bool _isBusy; + [ObservableProperty] private string? _error; + [ObservableProperty] private string _statusLine = string.Empty; + [ObservableProperty] private string? _progress; + + /// Non-null while the three-pane editor is open over the queue. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsEditorOpen))] + private MergeEditorViewModel? _editor; + + public bool IsEditorOpen => Editor is not null; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(VisibleGroups))] + private bool _showRoutine = true; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(VisibleGroups))] + private bool _showReviewOnly = true; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(VisibleGroups))] + private bool _showResolved; + + public IEnumerable VisibleGroups => Groups.Where(g => + (ShowResolved || g.IsPending) + && (g.IsRoutine ? ShowRoutine : ShowReviewOnly)); + + public int RoutineCount => Groups.Count(g => g.IsRoutine && g.IsPending); + + public int AttentionCount => Groups.Count(g => g.NeedsAttention && g.IsPending); + + public int PendingDeletions => Groups.Where(g => g.IsRoutine && g.IsPending).Sum(g => g.DropCount); + + // ── loading ────────────────────────────────────────────────────────────────────────────── + + public async Task LoadAsync() + { + IsBusy = true; + Error = null; + Progress = "Reading the vault…"; + try + { + await vault.SyncAsync(); + var items = await vault.GetItemsAsync(); + + Progress = "Scanning for duplicates…"; + var result = scanner.Scan(items); + + Groups.Clear(); + foreach (var group in result.Groups) Groups.Add(new DuplicateGroupViewModel(group)); + + StatusLine = + $"{result.LoginCount} logins · {result.Groups.Count} groups · " + + $"{result.MergeableDeletions} deletions available"; + + RefreshCounts(); + _ = LoadIconsAsync(); + } + catch (Exception ex) + { + Error = ex.Message; + } + finally + { + IsBusy = false; + Progress = null; + } + } + + private async Task LoadIconsAsync() + { + if (!iconLoader.IsEnabled) return; + foreach (var byDomain in Groups + .Select(g => g.Survivor) + .Where(s => s.IconDomain is not null) + .GroupBy(s => s.IconDomain!)) + { + var icon = await iconLoader.GetAsync(byDomain.Key); + if (icon is null) continue; + foreach (var survivor in byDomain) survivor.Icon = icon; + } + } + + private void RefreshCounts() + { + OnPropertyChanged(nameof(VisibleGroups)); + OnPropertyChanged(nameof(RoutineCount)); + OnPropertyChanged(nameof(AttentionCount)); + OnPropertyChanged(nameof(PendingDeletions)); + ApproveAllRoutineCommand.NotifyCanExecuteChanged(); + } + + // ── the fast path ──────────────────────────────────────────────────────────────────────── + + [RelayCommand] + private async Task ApproveAsync(DuplicateGroupViewModel row) + { + if (Confirm is null || row is null) return; + + var message = + $"{row.PlanSummary}.\n\nDeleted items go to Bitwarden's trash and stay restorable for 30 days."; + if (row.Draft.Overwrites.Count > 0) + message = "This replaces values on the item being kept:\n" + + string.Join("\n", row.Draft.Overwrites.Select(o => $" {o.Field}: {o.Before} → {o.After}")) + + "\n\n" + message; + + if (!await Confirm($"Merge {row.Id}", message)) return; + + await RunAsync([row]); + } + + private bool CanApproveAllRoutine => RoutineCount > 0; + + [RelayCommand(CanExecute = nameof(CanApproveAllRoutine))] + private async Task ApproveAllRoutineAsync() + { + if (Confirm is null) return; + + var rows = Groups.Where(g => g.IsRoutine && g.IsPending).ToList(); + var deletions = rows.Sum(r => r.DropCount); + + var confirmed = await Confirm( + "Approve routine merges", + $"Merge {rows.Count} group(s), deleting {deletions} item(s).\n\n" + + "Every one of these keeps the credentials unchanged — only names, folders and URI " + + "lists differ. Nothing here replaces a password.\n\n" + + "Deleted items go to Bitwarden's trash and stay restorable for 30 days."); + if (!confirmed) return; + + await RunAsync(rows); + } + + private async Task RunAsync(IReadOnlyList rows) + { + IsBusy = true; + Error = null; + var merged = 0; + var failed = 0; + + try + { + for (var i = 0; i < rows.Count; i++) + { + var row = rows[i]; + Progress = $"Merging {i + 1} of {rows.Count} — {row.Key}"; + + var outcome = await executor.ApplyAsync(row.Draft, dryRun: false); + + row.StateDetail = outcome.Message ?? string.Join(", ", outcome.Changes); + row.State = outcome.Status switch + { + MergeStatus.Merged => QueueState.Merged, + MergeStatus.Skipped => QueueState.Skipped, + _ => QueueState.Failed, + }; + + if (row.State == QueueState.Merged) merged++; + else if (row.State == QueueState.Failed) failed++; + } + + await vault.SyncAsync(); + StatusLine = $"{merged} merged" + + (failed > 0 ? $", {failed} failed — see the rows marked ✗" : string.Empty); + if (failed > 0) + Error = $"{failed} merge(s) did not complete. Nothing was deleted for those groups."; + } + catch (Exception ex) + { + Error = ex.Message; + } + finally + { + IsBusy = false; + Progress = null; + RefreshCounts(); + } + } + + // ── the editor ─────────────────────────────────────────────────────────────────────────── + + [RelayCommand] + private void OpenEditor(DuplicateGroupViewModel row) + { + if (row is null) return; + + var editor = new MergeEditorViewModel(row); + editor.Cancelled += () => Editor = null; + editor.Committed += async draft => + { + row.Draft = draft; + Editor = null; + await RunAsync([row]); + }; + Editor = editor; + } + + [RelayCommand] + private void DismissError() => Error = null; + + [RelayCommand] + private async Task RefreshAsync() => await LoadAsync(); + + [RelayCommand] + private void Close() => Closed?.Invoke(); +} diff --git a/src/Presentation/Desktop/ViewModels/FolderNode.cs b/src/Presentation/Desktop/ViewModels/FolderNode.cs new file mode 100644 index 0000000..ddaccae --- /dev/null +++ b/src/Presentation/Desktop/ViewModels/FolderNode.cs @@ -0,0 +1,73 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace BitwardenSharp.Desktop.ViewModels; + +/// +/// One node in the folder tree implied by Bitwarden's slash-separated folder names. +/// +/// +/// A node may exist purely as a path segment: if the vault has "Homelab/Proxmox" but no folder +/// literally named "Homelab", the parent is synthesised. Such a node has no , +/// holds no items directly, and cannot be renamed or deleted — there is nothing in the vault to +/// rename. It can still be dropped onto, because creating a child under it is a valid new name. +/// +public sealed partial class FolderNode(string name, string path) : ObservableObject +{ + public string Name { get; } = name; + + /// Full slash path, which is also the folder's name in Bitwarden. + public string Path { get; } = path; + + /// Null when this node is only an implied path segment, not a real folder. + public string? FolderId { get; set; } + + /// True for the synthetic "No folder" node, which is a filter rather than a folder. + public bool IsUnfiled { get; init; } + + /// Whether this node corresponds to a folder that can be renamed or deleted. + public bool IsRealFolder => FolderId is { Length: > 0 } && !IsUnfiled; + + public int DirectCount { get; set; } + + public ObservableCollection Children { get; } = []; + + [ObservableProperty] private bool _isExpanded = true; + + /// Highlighted while a drag hovers over it. + [ObservableProperty] private bool _isDropTarget; + + /// Items here and in everything below, which is what selecting this node shows. + public int TotalCount => DirectCount + Children.Sum(c => c.TotalCount); + + public string CountLabel => Children.Count == 0 || DirectCount == TotalCount + ? TotalCount.ToString() + : $"{DirectCount} / {TotalCount}"; + + /// Every real folder id at or below this node. + public IEnumerable DescendantFolderIds() + { + if (FolderId is not null) yield return FolderId; + foreach (var id in Children.SelectMany(c => c.DescendantFolderIds())) yield return id; + } + + /// Depth-first walk including this node. + public IEnumerable SelfAndDescendants() + { + yield return this; + foreach (var node in Children.SelectMany(c => c.SelfAndDescendants())) yield return node; + } +} + +/// Paints a folder row while a drag hovers over it. +internal sealed class DropTargetBrushConverter : Avalonia.Data.Converters.IValueConverter +{ + private static readonly Avalonia.Media.IBrush Highlight = + new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.FromArgb(0x40, 0x4C, 0x6E, 0xF5)); + + public object Convert(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture) => + value is true ? Highlight : Avalonia.Media.Brushes.Transparent; + + public object ConvertBack(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture) => + throw new NotSupportedException(); +} diff --git a/src/Presentation/Desktop/ViewModels/ItemViewModel.cs b/src/Presentation/Desktop/ViewModels/ItemViewModel.cs new file mode 100644 index 0000000..ed94422 --- /dev/null +++ b/src/Presentation/Desktop/ViewModels/ItemViewModel.cs @@ -0,0 +1,137 @@ +using Avalonia.Media; +using Avalonia.Media.Imaging; +using BitwardenSharp.Desktop.Services; +using BitwardenSharp.Domain.Vault; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace BitwardenSharp.Desktop.ViewModels; + +/// One vault entry as the list and detail pane see it. +public sealed partial class ItemViewModel(VaultItem item) : ViewModelBase +{ + /// + /// Placeholder tints. Muted enough to sit behind a letter without competing with real icons, + /// and picked deterministically from the name so an item keeps the same colour between runs. + /// + private static readonly Color[] PlaceholderColours = + [ + Color.FromRgb(0x4C, 0x6E, 0xF5), Color.FromRgb(0x7C, 0x4D, 0xC4), + Color.FromRgb(0x0C, 0x8C, 0x8C), Color.FromRgb(0xC2, 0x6B, 0x2B), + Color.FromRgb(0xB0, 0x3A, 0x6B), Color.FromRgb(0x3F, 0x7D, 0x3F), + Color.FromRgb(0x8A, 0x6D, 0x1F), Color.FromRgb(0x5A, 0x5A, 0x7A), + ]; + + public VaultItem Item { get; } = item; + + public string Id => Item.Id; + public string Name => Item.Name; + public string Username => Item.Login?.Username ?? string.Empty; + public ItemType Type => Item.Type; + public bool Favorite => Item.Favorite; + public string? Notes => Item.Notes; + public IReadOnlyList Uris => Item.Uris; + public IReadOnlyList Fields => Item.Fields; + public IReadOnlyList Attachments => Item.Attachments; + public IReadOnlyList PasswordHistory => Item.PasswordHistory; + public CardDetails? Card => Item.Card; + public IdentityDetails? Identity => Item.Identity; + public SshKeyDetails? SshKey => Item.SshKey; + public bool HasTotp => !string.IsNullOrWhiteSpace(Item.Login?.Totp); + public DateTimeOffset? Revised => Item.RevisionDate; + public bool RequiresReprompt => Item.RequiresReprompt; + + public bool IsLogin => Item.Type == ItemType.Login; + public bool HasCard => Item.Card is not null; + public bool HasIdentity => Item.Identity is not null; + public bool HasSshKey => Item.SshKey is not null; + public bool HasUris => Item.Uris.Count > 0; + public bool HasFields => Item.Fields.Count > 0; + public bool HasAttachments => Item.Attachments.Count > 0; + public bool HasNotes => !string.IsNullOrWhiteSpace(Item.Notes); + public bool HasUsername => Username.Length > 0; + public bool HasPasswordHistory => Item.PasswordHistory.Count > 0; + + public string TypeGlyph => Item.Type switch + { + ItemType.Login => "🔑", + ItemType.SecureNote => "📝", + ItemType.Card => "💳", + ItemType.Identity => "🪪", + ItemType.SshKey => "🖧", + _ => "•", + }; + + /// Primary URI or username, for the list's secondary line. + public string Subtitle => Username.Length > 0 + ? Username + : Item.Uris.FirstOrDefault()?.Uri ?? string.Empty; + + // ── icon ───────────────────────────────────────────────────────────────────────────────── + + /// The domain its icon comes from, or null when it has no web URI. + public string? IconDomain => IconLoader.IconDomainFor(Item); + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowPlaceholder))] + private Bitmap? _icon; + + /// True until a real icon arrives — and forever, for anything without one. + public bool ShowPlaceholder => Icon is null; + + /// The letter drawn in the placeholder. + public string Initial => Name.TrimStart() is { Length: > 0 } trimmed + ? trimmed[..1].ToUpperInvariant() + : "?"; + + /// + /// Deterministic tint from the name, so the same item is the same colour every launch and the + /// list stays visually stable rather than reshuffling colours on each load. + /// + public IBrush PlaceholderBrush + { + get + { + var hash = 0; + foreach (var c in Name) hash = unchecked(hash * 31 + char.ToLowerInvariant(c)); + return new SolidColorBrush(PlaceholderColours[Math.Abs(hash) % PlaceholderColours.Length]); + } + } + + public async Task LoadIconAsync(IconLoader loader, CancellationToken cancellationToken = default) + { + if (IconDomain is null || !loader.IsEnabled) return; + Icon = await loader.GetAsync(IconDomain, cancellationToken); + } + + // ── reveal ─────────────────────────────────────────────────────────────────────────────── + + /// + /// Whether secrets on this item are currently on screen. Defaults to hidden, applies to the + /// password, the card number and CVV and the SSH private key alike, and is deliberately not + /// persisted — revealing is a per-view action, never a stored preference. + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(PasswordDisplay), nameof(CardNumberDisplay), + nameof(CardCodeDisplay), nameof(SshPrivateKeyDisplay))] + private bool _isSecretVisible; + + private static string Mask(string? value, int cap = 24) => + new('•', Math.Min(value?.Length ?? 0, cap)); + + public string PasswordDisplay => + IsSecretVisible ? Item.Login?.Password ?? string.Empty : Mask(Item.Login?.Password); + + public string CardNumberDisplay => IsSecretVisible + ? Item.Card?.Number ?? string.Empty + : Item.Card?.LastFour is { } last ? $"•••• •••• •••• {last}" : string.Empty; + + public string CardCodeDisplay => + IsSecretVisible ? Item.Card?.Code ?? string.Empty : Mask(Item.Card?.Code, 4); + + public string SshPrivateKeyDisplay => + IsSecretVisible ? Item.SshKey?.PrivateKey ?? string.Empty : "•••• private key hidden ••••"; + + [RelayCommand] + private void ToggleSecrets() => IsSecretVisible = !IsSecretVisible; +} diff --git a/src/Presentation/Desktop/ViewModels/MainWindowViewModel.cs b/src/Presentation/Desktop/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..b04e49d --- /dev/null +++ b/src/Presentation/Desktop/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,68 @@ +using BitwardenSharp.Application.Abstractions; +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.Extensions.DependencyInjection; + +namespace BitwardenSharp.Desktop.ViewModels; + +/// Owns the top-level swap between the unlock screen and the vault browser. +public sealed partial class MainWindowViewModel : ViewModelBase +{ + private readonly IServiceProvider _services; + private readonly IVaultSession _session; + + [ObservableProperty] + private ViewModelBase _current; + + public MainWindowViewModel(IServiceProvider services, IVaultSession session) + { + _services = services; + _session = session; + + var unlock = services.GetRequiredService(); + unlock.Unlocked += OnUnlocked; + _current = unlock; + } + + private void OnUnlocked() => ShowVault(); + + private void ShowVault() + { + var vault = _services.GetRequiredService(); + vault.Locked += OnLocked; + vault.DuplicatesRequested += OnDuplicatesRequested; + Current = vault; + _ = vault.LoadAsync(); + } + + private void OnDuplicatesRequested() + { + var duplicates = _services.GetRequiredService(); + + // Returning to the vault reloads it: a merge deletes items, and coming back to a stale + // list showing entries that no longer exist would be worse than a moment's wait. + duplicates.Closed += ShowVault; + Current = duplicates; + _ = duplicates.LoadAsync(); + } + + private void OnLocked() + { + var unlock = _services.GetRequiredService(); + unlock.Unlocked += OnUnlocked; + Current = unlock; + } + + /// Locks the vault on the way out rather than leaving a live session behind. + public async Task ShutdownAsync() + { + try + { + await _session.LockAsync().WaitAsync(TimeSpan.FromSeconds(5)); + } + catch + { + // Shutting down regardless. The bw serve child is killed on dispose and the session + // key dies with it, so a failed lock leaves nothing recoverable behind. + } + } +} diff --git a/src/Presentation/Desktop/ViewModels/MergeEditorViewModel.cs b/src/Presentation/Desktop/ViewModels/MergeEditorViewModel.cs new file mode 100644 index 0000000..5abbecb --- /dev/null +++ b/src/Presentation/Desktop/ViewModels/MergeEditorViewModel.cs @@ -0,0 +1,319 @@ +using System.Collections.ObjectModel; +using BitwardenSharp.Application.Merging; +using BitwardenSharp.Domain.Vault; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace BitwardenSharp.Desktop.ViewModels; + +/// One scalar property, compared across the group and resolved in the middle. +public sealed partial class MergePropertyRow : ObservableObject +{ + private readonly Func _read; + + public MergePropertyRow( + string label, + Func read, + IReadOnlyList members, + string? resolved, + bool isSecret = false) + { + Label = label; + _read = read; + IsSecret = isSecret; + Members = members; + _resultValue = resolved; + + var distinct = members.Select(read) + .Select(v => v ?? string.Empty) + .Distinct(StringComparer.Ordinal) + .ToList(); + IsIdentical = distinct.Count <= 1; + } + + public string Label { get; } + public bool IsSecret { get; } + public IReadOnlyList Members { get; } + + /// Every member agrees, so there is nothing here to decide. + public bool IsIdentical { get; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(DisplayValue), nameof(IsEdited))] + private string? _resultValue; + + /// The value carried by whichever member is currently in the compare pane. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CompareDisplay), nameof(DiffersFromCompare))] + private VaultItem? _comparedMember; + + public string? CompareValue => ComparedMember is null ? null : _read(ComparedMember); + + public bool DiffersFromCompare => + !string.Equals(CompareValue ?? string.Empty, ResultValue ?? string.Empty, StringComparison.Ordinal); + + /// True once the value matches no member — i.e. it was typed. + public bool IsEdited => + !string.IsNullOrEmpty(ResultValue) + && !Members.Any(m => string.Equals(_read(m), ResultValue, StringComparison.Ordinal)); + + // Secrets are masked in both panes until the editor is set to reveal. A merge decision is + // about which value wins, and you can make it from "these differ" without reading either. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(DisplayValue), nameof(CompareDisplay))] + private bool _reveal; + + public string? DisplayValue => Render(ResultValue); + public string? CompareDisplay => Render(CompareValue); + + private string? Render(string? value) => + IsSecret && !Reveal && !string.IsNullOrEmpty(value) + ? new string('•', Math.Min(value.Length, 16)) + : value; + + /// Pull the compared member's value into the result. + [RelayCommand] + private void TakeFromCompare() => ResultValue = CompareValue; +} + +/// A URI or custom field, included in the result or not. +public sealed partial class MergeElementRow(string text, string detail, bool included = true) + : ObservableObject +{ + public string Text { get; } = text; + + /// Which members carry this element. + public string Detail { get; } = detail; + + [ObservableProperty] private bool _isIncluded = included; +} + +/// One option on the "what does the result become" radio. +public sealed partial class MergeTargetOption( + string label, string detail, MergeTarget target, bool enabled = true, string? blockedReason = null) + : ObservableObject +{ + public string Label { get; } = label; + public string Detail { get; } = detail; + public MergeTarget Target { get; } = target; + public bool IsEnabled { get; } = enabled; + public string? BlockedReason { get; } = blockedReason; + public bool IsBlocked => !IsEnabled; + + [ObservableProperty] private bool _isChosen; +} + +/// +/// The three-pane merge editor: members on the left, one of them compared in the middle, and the +/// resolved result on the right. +/// +/// +/// The left is a rail rather than a single pane because 15 of the mergeable groups on a real vault +/// have three to five members; a strict two-pane layout has nowhere to put the rest. For a +/// two-member group — the large majority — the rail holds one other item and it reads exactly like +/// a plain side-by-side. +/// +public sealed partial class MergeEditorViewModel : ViewModelBase +{ + private readonly DuplicateGroupViewModel _row; + + public event Action? Cancelled; + public event Action? Committed; + + public MergeEditorViewModel(DuplicateGroupViewModel row) + { + _row = row; + var group = row.Group; + var draft = row.Draft; + + Members = [.. group.Members]; + + Rows = + [ + new MergePropertyRow("Name", m => m.Name, Members, draft.Name.Value), + new MergePropertyRow("Username", m => m.Login?.Username, Members, draft.Username.Value), + new MergePropertyRow("Password", m => m.Login?.Password, Members, draft.Password.Value, isSecret: true), + new MergePropertyRow("TOTP", m => m.Login?.Totp, Members, draft.Totp.Value, isSecret: true), + new MergePropertyRow("Notes", m => m.Notes, Members, draft.Notes.Value), + ]; + + // URIs and custom fields are unioned by default and individually removable: "additive" + // only means anything for collections — you cannot have two usernames. + Uris = + [ + .. group.Members + .SelectMany(m => m.Uris.Select(u => (Member: m, u.Uri))) + .GroupBy(x => x.Uri.Trim(), StringComparer.OrdinalIgnoreCase) + .Select(g => new MergeElementRow( + g.Key, + $"from {string.Join(", ", g.Select(x => x.Member.Name).Distinct())}", + draft.Uris.Any(u => string.Equals(u.Uri.Trim(), g.Key, StringComparison.OrdinalIgnoreCase)))), + ]; + + Fields = + [ + .. group.Members + .SelectMany(m => m.Fields.Select(f => (Member: m, Field: f))) + .GroupBy(x => x.Field.Name ?? string.Empty, StringComparer.OrdinalIgnoreCase) + .Select(g => new MergeElementRow( + g.Key, + $"from {string.Join(", ", g.Select(x => x.Member.Name).Distinct())}", + draft.Fields.Any(f => string.Equals(f.Name, g.Key, StringComparison.OrdinalIgnoreCase)))), + ]; + + Targets = + [ + .. group.Members.Select(m => new MergeTargetOption( + $"Keep \"{m.Name}\"", + m.Attachments.Count > 0 + ? $"{m.Uris.Count} uri(s) · {m.Attachments.Count} attachment(s)" + : $"{m.Uris.Count} uri(s)", + MergeTarget.Existing(m.Id))), + new MergeTargetOption( + "Create a new item", + draft.CanTargetNewItem + ? $"all {group.Members.Count} originals are deleted" + : draft.NewItemBlockedReason!, + MergeTarget.NewItem, + enabled: draft.CanTargetNewItem, + blockedReason: draft.NewItemBlockedReason), + ]; + + ChosenTarget = Targets.FirstOrDefault(t => t.Target.ItemId == draft.Target.ItemId) ?? Targets[0]; + SelectedMember = Members.FirstOrDefault(m => m.Id != draft.Target.ItemId) ?? Members[0]; + } + + public IReadOnlyList Members { get; } + public ObservableCollection Rows { get; } + public ObservableCollection Uris { get; } + public ObservableCollection Fields { get; } + public ObservableCollection Targets { get; } + + public string GroupId => _row.Id; + public string GroupKey => _row.Key; + public bool HasFields => Fields.Count > 0; + + /// The member currently shown in the compare pane. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CompareTitle))] + private VaultItem? _selectedMember; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ResultTitle), nameof(TargetIsNewItem))] + private MergeTargetOption? _chosenTarget; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(VisibleRows))] + private bool _showIdentical; + + [ObservableProperty] private bool _revealSecrets; + + public string CompareTitle => SelectedMember?.Name ?? "—"; + + public string ResultTitle => ChosenTarget?.Target.IsNewItem == true + ? "Result — a new item" + : $"Result — {ChosenTarget?.Label}"; + + public bool TargetIsNewItem => ChosenTarget?.Target.IsNewItem == true; + + /// + /// Identical rows are hidden by default. Most properties agree — that is what made these + /// duplicates — and showing them all buries the one or two that actually need a decision. + /// + public IEnumerable VisibleRows => Rows.Where(r => ShowIdentical || !r.IsIdentical); + + partial void OnSelectedMemberChanged(VaultItem? value) + { + foreach (var row in Rows) row.ComparedMember = value; + } + + partial void OnChosenTargetChanged(MergeTargetOption? value) + { + foreach (var option in Targets) option.IsChosen = ReferenceEquals(option, value); + } + + partial void OnRevealSecretsChanged(bool value) + { + foreach (var row in Rows) row.Reveal = value; + } + + /// What this draft would replace on the item being kept. + public IReadOnlyList<(string Field, string? Before, string? After)> Overwrites => Build().Overwrites; + + /// Assembles the draft the panes currently describe. + public MergeDraft Build() + { + var byLabel = Rows.ToDictionary(r => r.Label, r => r.ResultValue); + var group = _row.Group; + + var keptUris = Uris.Where(u => u.IsIncluded).Select(u => u.Text).ToHashSet(StringComparer.OrdinalIgnoreCase); + var keptFields = Fields.Where(f => f.IsIncluded).Select(f => f.Text).ToHashSet(StringComparer.OrdinalIgnoreCase); + + return _row.Draft with + { + Target = ChosenTarget?.Target ?? _row.Draft.Target, + Name = Resolve(byLabel["Name"] ?? string.Empty, m => m.Name), + Username = Resolve(byLabel["Username"], m => m.Login?.Username), + Password = Resolve(byLabel["Password"], m => m.Login?.Password), + Totp = Resolve(byLabel["TOTP"], m => m.Login?.Totp), + Notes = Resolve(byLabel["Notes"], m => m.Notes), + Uris = group.Members + .SelectMany(m => m.Uris) + .GroupBy(u => u.Uri.Trim(), StringComparer.OrdinalIgnoreCase) + .Where(g => keptUris.Contains(g.Key)) + .Select(g => g.First()) + .ToList(), + Fields = group.Members + .SelectMany(m => m.Fields) + .GroupBy(f => f.Name ?? string.Empty, StringComparer.OrdinalIgnoreCase) + .Where(g => keptFields.Contains(g.Key)) + .Select(g => g.First()) + .ToList(), + }; + + Resolved Resolve(T value, Func read) + { + var source = group.Members.FirstOrDefault(m => EqualityComparer.Default.Equals(read(m), value)); + if (source is null) return Resolved.Edited(value); + return group.Members.All(m => EqualityComparer.Default.Equals(read(m), value)) + ? Resolved.Unanimous(value) + : Resolved.From(value, source.Id); + } + } + + [RelayCommand] + private void SelectMember(VaultItem member) => SelectedMember = member; + + [RelayCommand] + private void ChooseTarget(MergeTargetOption option) + { + if (option is { IsEnabled: true }) ChosenTarget = option; + } + + /// Pull every differing value from the compared member in one go. + [RelayCommand] + private void TakeAllFromCompare() + { + foreach (var row in Rows.Where(r => r.DiffersFromCompare)) row.ResultValue = row.CompareValue; + } + + [RelayCommand] + private void Reset() + { + var draft = MergeDraft.Default(_row.Group); + Rows[0].ResultValue = draft.Name.Value; + Rows[1].ResultValue = draft.Username.Value; + Rows[2].ResultValue = draft.Password.Value; + Rows[3].ResultValue = draft.Totp.Value; + Rows[4].ResultValue = draft.Notes.Value; + foreach (var uri in Uris) uri.IsIncluded = true; + foreach (var f in Fields) f.IsIncluded = true; + ChosenTarget = Targets.FirstOrDefault(t => t.Target.ItemId == draft.Target.ItemId) ?? Targets[0]; + } + + [RelayCommand] + private void Commit() => Committed?.Invoke(Build()); + + [RelayCommand] + private void Cancel() => Cancelled?.Invoke(); +} diff --git a/src/Presentation/Desktop/ViewModels/UnlockViewModel.cs b/src/Presentation/Desktop/ViewModels/UnlockViewModel.cs new file mode 100644 index 0000000..412c6b0 --- /dev/null +++ b/src/Presentation/Desktop/ViewModels/UnlockViewModel.cs @@ -0,0 +1,92 @@ +using BitwardenSharp.Application.Abstractions; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace BitwardenSharp.Desktop.ViewModels; + +/// The unlock screen. +public sealed partial class UnlockViewModel(IVaultSession session) : ViewModelBase +{ + public event Action? Unlocked; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(UnlockCommand))] + private string _masterPassword = string.Empty; + + [ObservableProperty] private string? _error; + [ObservableProperty] private string? _accountEmail; + [ObservableProperty] private string? _serverUrl; + [ObservableProperty] private bool _isBusy; + [ObservableProperty] private bool _isReady; + + /// + /// What we are waiting on. Starting bw serve takes a second or two, and a UI that + /// simply sits there during it reads as hung. + /// + [ObservableProperty] private string? _status; + + /// Reads who we would be unlocking as, so the screen can name the account. + public async Task InitialiseAsync() + { + IsBusy = true; + Status = "Starting the local Bitwarden API…"; + try + { + // First call starts bw serve; everything after it is fast. + var status = await session.GetStatusAsync(); + Status = null; + AccountEmail = status.UserEmail; + ServerUrl = status.ServerUrl; + + if (status.IsUnlocked) + { + // A session already exists — inherited from BW_SESSION, or left by an earlier run. + Unlocked?.Invoke(); + return; + } + + IsReady = string.Equals(status.Status, "locked", StringComparison.OrdinalIgnoreCase); + if (!IsReady) + Error = $"The bw client reports '{status.Status}'. Run `bw login` first."; + } + catch (Exception ex) + { + Error = $"Could not reach the bw client: {ex.Message}"; + } + finally + { + IsBusy = false; + Status = null; + } + } + + private bool CanUnlock => !IsBusy && MasterPassword.Length > 0; + + [RelayCommand(CanExecute = nameof(CanUnlock))] + private async Task UnlockAsync() + { + IsBusy = true; + Error = null; + Status = "Unlocking…"; + try + { + var result = await session.UnlockAsync(MasterPassword); + + // Drop the password either way — it is not needed again, and a failed attempt has no + // reason to leave it sitting in a bound property. + MasterPassword = string.Empty; + + if (result.Succeeded) Unlocked?.Invoke(); + else Error = result.Error; + } + catch (Exception ex) + { + Error = ex.Message; + } + finally + { + IsBusy = false; + Status = null; + } + } +} diff --git a/src/Presentation/Desktop/ViewModels/VaultViewModel.cs b/src/Presentation/Desktop/ViewModels/VaultViewModel.cs new file mode 100644 index 0000000..5bb6d2d --- /dev/null +++ b/src/Presentation/Desktop/ViewModels/VaultViewModel.cs @@ -0,0 +1,332 @@ +using System.Collections.ObjectModel; +using BitwardenSharp.Application.Abstractions; +using BitwardenSharp.Application.Folders; +using BitwardenSharp.Desktop.Services; +using BitwardenSharp.Domain.Vault; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace BitwardenSharp.Desktop.ViewModels; + +/// The vault browser: folder tree on the left, items in the middle, detail on the right. +public sealed partial class VaultViewModel( + IVaultClient vault, + IVaultSession session, + FolderService folders, + IconLoader iconLoader) : ViewModelBase +{ + public event Action? Locked; + public event Action? DuplicatesRequested; + + /// Raised when the view should ask the user for a name. Returns null if cancelled. + public Func>? PromptForName { get; set; } + + /// Raised when the view should ask the user to confirm something destructive. + public Func>? Confirm { get; set; } + + private IReadOnlyList _allItems = []; + private IReadOnlyList _allFolders = []; + private CancellationTokenSource _iconLoad = new(); + + public ObservableCollection Folders { get; } = []; + public ObservableCollection Items { get; } = []; + + [ObservableProperty] private bool _isBusy; + [ObservableProperty] private string? _error; + [ObservableProperty] private string _statusLine = string.Empty; + [ObservableProperty] private ItemViewModel? _selectedItem; + [ObservableProperty] private string _search = string.Empty; + + [NotifyPropertyChangedFor(nameof(FilterDescription))] + [NotifyCanExecuteChangedFor(nameof(RenameFolderCommand), nameof(DeleteFolderCommand))] + [ObservableProperty] + private FolderNode? _selectedFolder; + + public string FilterDescription => SelectedFolder?.Path ?? "All items"; + + public bool IconsEnabled => iconLoader.IsEnabled; + + partial void OnSelectedFolderChanged(FolderNode? value) => ApplyFilter(); + + partial void OnSearchChanged(string value) => ApplyFilter(); + + // ── loading ────────────────────────────────────────────────────────────────────────────── + + public async Task LoadAsync() + { + IsBusy = true; + Error = null; + try + { + await vault.SyncAsync(); + _allFolders = await vault.GetFoldersAsync(); + _allItems = await vault.GetItemsAsync(); + + RebuildFolderTree(); + ApplyFilter(); + + var status = await session.GetStatusAsync(); + StatusLine = $"{status.UserEmail} · {_allItems.Count} items · {_allFolders.Count} folders"; + } + catch (Exception ex) + { + Error = ex.Message; + } + finally + { + IsBusy = false; + } + } + + /// Reloads folders and items while keeping the selected folder path selected. + private async Task ReloadPreservingSelectionAsync() + { + var selectedPath = SelectedFolder?.Path; + await LoadAsync(); + + if (selectedPath is null) return; + SelectedFolder = Folders + .SelectMany(f => f.SelfAndDescendants()) + .FirstOrDefault(f => f.Path == selectedPath); + } + + /// + /// Bitwarden has no real folder hierarchy — nesting is a naming convention where + /// "Homelab/Proxmox" is one folder whose name contains a slash. Rebuild the implied tree so + /// the UI can present it as one, inserting intermediate nodes that have no folder of their own. + /// + private void RebuildFolderTree() + { + Folders.Clear(); + + var counts = _allItems + .GroupBy(i => i.FolderId ?? string.Empty) + .ToDictionary(g => g.Key, g => g.Count()); + + var roots = new List(); + var index = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var folder in _allFolders + .Where(f => f.Id.Length > 0) + .OrderBy(f => f.Name, StringComparer.OrdinalIgnoreCase)) + { + var segments = FolderPaths.Segments(folder.Name); + if (segments.Count == 0) continue; + + FolderNode? parent = null; + var path = string.Empty; + + for (var depth = 0; depth < segments.Count; depth++) + { + path = depth == 0 ? segments[depth] : $"{path}/{segments[depth]}"; + + if (!index.TryGetValue(path, out var node)) + { + node = new FolderNode(segments[depth], path); + index[path] = node; + (parent?.Children ?? (ICollection)roots).Add(node); + } + parent = node; + } + + parent!.FolderId = folder.Id; + parent.DirectCount = counts.GetValueOrDefault(folder.Id, 0); + } + + var unfiled = counts.GetValueOrDefault(string.Empty, 0); + if (unfiled > 0) + roots.Add(new FolderNode("No folder", "￿ unfiled") + { + FolderId = string.Empty, + DirectCount = unfiled, + IsUnfiled = true, + }); + + foreach (var root in roots) Folders.Add(root); + } + + private void ApplyFilter() + { + var query = _allItems.AsEnumerable(); + + if (SelectedFolder is not null) + { + // Selecting a parent shows everything beneath it, which is what the slash-naming + // implies even though Bitwarden stores the folders flat. + var ids = SelectedFolder.DescendantFolderIds().ToHashSet(StringComparer.Ordinal); + query = query.Where(i => ids.Contains(i.FolderId ?? string.Empty)); + } + + if (!string.IsNullOrWhiteSpace(Search)) + { + var term = Search.Trim(); + query = query.Where(i => + i.Name.Contains(term, StringComparison.OrdinalIgnoreCase) + || (i.Login?.Username?.Contains(term, StringComparison.OrdinalIgnoreCase) ?? false) + || i.Uris.Any(u => u.Uri.Contains(term, StringComparison.OrdinalIgnoreCase))); + } + + Items.Clear(); + foreach (var item in query.OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase)) + Items.Add(new ItemViewModel(item)); + + SelectedItem = Items.FirstOrDefault(); + _ = LoadIconsAsync(); + } + + /// + /// Fetches icons for what is currently listed. + /// + /// + /// Cancelled and restarted on every filter change: typing in the search box should not leave + /// hundreds of lookups in flight for rows that are no longer shown. Distinct by domain so a + /// site with a dozen duplicate entries is fetched once. + /// + private async Task LoadIconsAsync() + { + await _iconLoad.CancelAsync(); + _iconLoad.Dispose(); + _iconLoad = new CancellationTokenSource(); + var token = _iconLoad.Token; + + if (!iconLoader.IsEnabled) return; + + try + { + foreach (var group in Items.Where(i => i.IconDomain is not null).GroupBy(i => i.IconDomain!)) + { + if (token.IsCancellationRequested) return; + + var icon = await iconLoader.GetAsync(group.Key, token); + if (icon is null) continue; + foreach (var vm in group) vm.Icon = icon; + } + } + catch (OperationCanceledException) + { + // Expected whenever the filter changes mid-flight. + } + } + + // ── folder operations ──────────────────────────────────────────────────────────────────── + + [RelayCommand] + private async Task NewFolderAsync() + { + if (PromptForName is null) return; + + // A new folder is created under whatever is selected, mirroring a file explorer. + var parent = SelectedFolder is { IsUnfiled: false } node ? node.Path : null; + var name = await PromptForName( + "New folder", + parent is null ? "Name" : $"Name (inside {parent})", + null); + if (string.IsNullOrWhiteSpace(name)) return; + + await RunFolderOperationAsync(() => folders.CreateAsync(parent, name)); + } + + private bool CanActOnFolder => SelectedFolder?.IsRealFolder == true; + + [RelayCommand(CanExecute = nameof(CanActOnFolder))] + private async Task RenameFolderAsync() + { + if (PromptForName is null || SelectedFolder is not { IsRealFolder: true } node) return; + + var name = await PromptForName("Rename folder", "Name", node.Name); + if (string.IsNullOrWhiteSpace(name) || name == node.Name) return; + + await RunFolderOperationAsync(() => folders.RenameAsync(node.FolderId!, name)); + } + + [RelayCommand(CanExecute = nameof(CanActOnFolder))] + private async Task DeleteFolderAsync() + { + if (Confirm is null || SelectedFolder is not { IsRealFolder: true } node) return; + + var subtree = node.SelfAndDescendants().Count(n => n.IsRealFolder); + var items = node.TotalCount; + + var message = subtree > 1 + ? $"Delete \"{node.Path}\" and {subtree - 1} folder(s) beneath it?" + : $"Delete \"{node.Path}\"?"; + if (items > 0) + message += $"\n\n{items} item(s) will be moved out of any folder. Nothing is deleted."; + + if (!await Confirm("Delete folder", message)) return; + + await RunFolderOperationAsync(() => folders.DeleteAsync(node.FolderId!)); + } + + /// Moves items into a folder. Called by the view when a drag is dropped on the tree. + public async Task MoveItemsToFolderAsync(IReadOnlyList itemIds, FolderNode target) + { + if (itemIds.Count == 0) return; + + // An implied path segment has no folder to move into; unfiled means clearing the folder. + if (!target.IsUnfiled && !target.IsRealFolder) + { + Error = $"\"{target.Path}\" isn't a real folder yet — create it before moving items into it."; + return; + } + + var folderId = target.IsUnfiled ? null : target.FolderId; + await RunFolderOperationAsync(() => folders.MoveItemsAsync(itemIds, folderId)); + } + + /// Moves a folder under another. Called by the view on a folder-to-folder drop. + public async Task MoveFolderAsync(FolderNode source, FolderNode? target) + { + if (!source.IsRealFolder) return; + if (target is { IsUnfiled: true }) return; + if (target is not null && target.Path == source.Path) return; + + await RunFolderOperationAsync(() => folders.MoveAsync(source.FolderId!, target?.Path)); + } + + private async Task RunFolderOperationAsync(Func> operation) + { + IsBusy = true; + Error = null; + try + { + var result = await operation(); + if (!result.Succeeded) + { + Error = result.Error; + return; + } + await ReloadPreservingSelectionAsync(); + } + catch (Exception ex) + { + Error = ex.Message; + } + finally + { + IsBusy = false; + } + } + + // ── misc ───────────────────────────────────────────────────────────────────────────────── + + [RelayCommand] + private void OpenDuplicates() => DuplicatesRequested?.Invoke(); + + [RelayCommand] + private void ClearFolder() => SelectedFolder = null; + + [RelayCommand] + private void DismissError() => Error = null; + + [RelayCommand] + private async Task RefreshAsync() => await ReloadPreservingSelectionAsync(); + + [RelayCommand] + private async Task LockAsync() + { + await _iconLoad.CancelAsync(); + await session.LockAsync(); + Locked?.Invoke(); + } +} diff --git a/src/Presentation/Desktop/ViewModels/ViewModelBase.cs b/src/Presentation/Desktop/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..9514938 --- /dev/null +++ b/src/Presentation/Desktop/ViewModels/ViewModelBase.cs @@ -0,0 +1,5 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace BitwardenSharp.Desktop.ViewModels; + +public abstract class ViewModelBase : ObservableObject; diff --git a/src/Presentation/Desktop/Views/ConfirmWindow.axaml b/src/Presentation/Desktop/Views/ConfirmWindow.axaml new file mode 100644 index 0000000..bc99668 --- /dev/null +++ b/src/Presentation/Desktop/Views/ConfirmWindow.axaml @@ -0,0 +1,15 @@ + + + + +