From 4461143a9c29db4f405ae070e74e8887c9629cdc Mon Sep 17 00:00:00 2001 From: Christian Simon Date: Sun, 16 Aug 2026 14:06:24 +1200 Subject: [PATCH 01/11] Rewrite as a hexagonal vault-management toolkit The prototype on archive/v0-copilot built cleanly and did nothing useful: IBwRunner returned Task exit codes rather than data, so there was no domain model and no way to act on an item programmatically. It also passed the master password as a command-line argument, visible in ps, and carried seven classes duplicated across two projects. Replaced with Domain <- Application <- Infrastructure <- Presentation, enforced by ArchUnitNET rather than convention. The duplicate ruleset is the substance. A shared password is deliberately not treated as evidence of a duplicate: on the vault this was built for, one password covered hundreds of unrelated accounts, so matching credentials only ever promote a group that a stronger signal has already established. Two regression tests pin the failure modes found while prototyping the analysis -- a single known brand among unrelated sites must not make them related, and one credential across distinct hosts is a homelab, not a duplicate. Merges are survivor-first and verified before any delete, so a failure cannot leave data in neither item. --- .config/dotnet-tools.json | 13 ++ .fallout/.gitignore | 1 + .fallout/parameters.json | 3 + BitwardenSharp.slnx | 16 ++ CHANGELOG.md | 25 +++ Directory.Build.props | 32 +++ Directory.Packages.props | 30 +++ README.md | 113 +++++++++- build.cmd | 6 + build.ps1 | 9 + build.sh | 3 + build/Build.CI.GitHubActions.cs | 72 ++++++ build/Build.cs | 44 ++++ build/_build.csproj | 20 ++ global.json | 6 + nuget.config | 7 + src/Application/Abstractions/IVaultClient.cs | 55 +++++ .../ApplicationServiceExtensions.cs | 16 ++ .../BitwardenSharp.Application.csproj | 13 ++ .../Duplicates/DuplicateScanResult.cs | 23 ++ .../Duplicates/DuplicateScanner.cs | 201 +++++++++++++++++ src/Application/Duplicates/MergeWarnings.cs | 78 +++++++ src/Application/Merging/MergeBuilder.cs | 94 ++++++++ src/Application/Merging/MergeExecutor.cs | 135 +++++++++++ src/Domain/BitwardenSharp.Domain.csproj | 6 + src/Domain/Duplicates/DuplicateCategory.cs | 61 +++++ src/Domain/Duplicates/DuplicateGroup.cs | 48 ++++ src/Domain/Uris/PublicSuffix.cs | 42 ++++ src/Domain/Uris/ServiceFamily.cs | 48 ++++ src/Domain/Uris/UriTarget.cs | 90 ++++++++ src/Domain/Vault/CustomField.cs | 19 ++ src/Domain/Vault/FieldType.cs | 10 + src/Domain/Vault/ItemAttachment.cs | 15 ++ src/Domain/Vault/ItemType.cs | 11 + src/Domain/Vault/LoginDetails.cs | 55 +++++ src/Domain/Vault/LoginUri.cs | 10 + src/Domain/Vault/UriMatchType.cs | 15 ++ src/Domain/Vault/VaultFolder.cs | 16 ++ src/Domain/Vault/VaultItem.cs | 58 +++++ .../BitwardenSharp.Infrastructure.csproj | 13 ++ src/Infrastructure/Bw/BwCliOptions.cs | 19 ++ src/Infrastructure/Bw/BwCliVaultClient.cs | 101 +++++++++ src/Infrastructure/Bw/BwItemMapper.cs | 97 ++++++++ src/Infrastructure/Bw/BwProcessRunner.cs | 124 +++++++++++ .../Bw/Contracts/BwContracts.cs | 90 ++++++++ src/Infrastructure/Bw/JsonFileVaultClient.cs | 58 +++++ .../InfrastructureServiceExtensions.cs | 22 ++ .../Cli/BitwardenSharp.Cli.csproj | 20 ++ src/Presentation/Cli/Commands/MergeCommand.cs | 133 +++++++++++ src/Presentation/Cli/Commands/ScanCommand.cs | 120 ++++++++++ src/Presentation/Cli/Hosting/TypeRegistrar.cs | 25 +++ src/Presentation/Cli/Program.cs | 28 +++ .../BitwardenSharp.Application.Tests.csproj | 15 ++ .../DuplicateScannerSpecs.cs | 209 ++++++++++++++++++ tests/Application.Tests/MergeExecutorSpecs.cs | 141 ++++++++++++ tests/Application.Tests/TestVault.cs | 38 ++++ tests/Architecture.Tests/ArchRuleAssert.cs | 26 +++ .../BitwardenSharp.Architecture.Tests.csproj | 18 ++ .../HexagonalArchitectureSpecs.cs | 76 +++++++ .../BitwardenSharp.Domain.Tests.csproj | 15 ++ tests/Domain.Tests/UriTargetSpecs.cs | 100 +++++++++ ...BitwardenSharp.Infrastructure.Tests.csproj | 15 ++ 62 files changed, 3020 insertions(+), 2 deletions(-) create mode 100644 .config/dotnet-tools.json create mode 100644 .fallout/.gitignore create mode 100644 .fallout/parameters.json create mode 100644 BitwardenSharp.slnx create mode 100644 CHANGELOG.md create mode 100644 Directory.Build.props create mode 100644 Directory.Packages.props create mode 100755 build.cmd create mode 100755 build.ps1 create mode 100755 build.sh create mode 100644 build/Build.CI.GitHubActions.cs create mode 100644 build/Build.cs create mode 100644 build/_build.csproj create mode 100644 global.json create mode 100644 nuget.config create mode 100644 src/Application/Abstractions/IVaultClient.cs create mode 100644 src/Application/ApplicationServiceExtensions.cs create mode 100644 src/Application/BitwardenSharp.Application.csproj create mode 100644 src/Application/Duplicates/DuplicateScanResult.cs create mode 100644 src/Application/Duplicates/DuplicateScanner.cs create mode 100644 src/Application/Duplicates/MergeWarnings.cs create mode 100644 src/Application/Merging/MergeBuilder.cs create mode 100644 src/Application/Merging/MergeExecutor.cs create mode 100644 src/Domain/BitwardenSharp.Domain.csproj create mode 100644 src/Domain/Duplicates/DuplicateCategory.cs create mode 100644 src/Domain/Duplicates/DuplicateGroup.cs create mode 100644 src/Domain/Uris/PublicSuffix.cs create mode 100644 src/Domain/Uris/ServiceFamily.cs create mode 100644 src/Domain/Uris/UriTarget.cs create mode 100644 src/Domain/Vault/CustomField.cs create mode 100644 src/Domain/Vault/FieldType.cs create mode 100644 src/Domain/Vault/ItemAttachment.cs create mode 100644 src/Domain/Vault/ItemType.cs create mode 100644 src/Domain/Vault/LoginDetails.cs create mode 100644 src/Domain/Vault/LoginUri.cs create mode 100644 src/Domain/Vault/UriMatchType.cs create mode 100644 src/Domain/Vault/VaultFolder.cs create mode 100644 src/Domain/Vault/VaultItem.cs create mode 100644 src/Infrastructure/BitwardenSharp.Infrastructure.csproj create mode 100644 src/Infrastructure/Bw/BwCliOptions.cs create mode 100644 src/Infrastructure/Bw/BwCliVaultClient.cs create mode 100644 src/Infrastructure/Bw/BwItemMapper.cs create mode 100644 src/Infrastructure/Bw/BwProcessRunner.cs create mode 100644 src/Infrastructure/Bw/Contracts/BwContracts.cs create mode 100644 src/Infrastructure/Bw/JsonFileVaultClient.cs create mode 100644 src/Infrastructure/InfrastructureServiceExtensions.cs create mode 100644 src/Presentation/Cli/BitwardenSharp.Cli.csproj create mode 100644 src/Presentation/Cli/Commands/MergeCommand.cs create mode 100644 src/Presentation/Cli/Commands/ScanCommand.cs create mode 100644 src/Presentation/Cli/Hosting/TypeRegistrar.cs create mode 100644 src/Presentation/Cli/Program.cs create mode 100644 tests/Application.Tests/BitwardenSharp.Application.Tests.csproj create mode 100644 tests/Application.Tests/DuplicateScannerSpecs.cs create mode 100644 tests/Application.Tests/MergeExecutorSpecs.cs create mode 100644 tests/Application.Tests/TestVault.cs create mode 100644 tests/Architecture.Tests/ArchRuleAssert.cs create mode 100644 tests/Architecture.Tests/BitwardenSharp.Architecture.Tests.csproj create mode 100644 tests/Architecture.Tests/HexagonalArchitectureSpecs.cs create mode 100644 tests/Domain.Tests/BitwardenSharp.Domain.Tests.csproj create mode 100644 tests/Domain.Tests/UriTargetSpecs.cs create mode 100644 tests/Infrastructure.Tests/BitwardenSharp.Infrastructure.Tests.csproj diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..258a13d --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "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/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/BitwardenSharp.slnx b/BitwardenSharp.slnx new file mode 100644 index 0000000..951ee44 --- /dev/null +++ b/BitwardenSharp.slnx @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..de4a476 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# 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. + +### 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. + +### 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.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..4cbfead --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,30 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md index 2a4b2ab..d4620ef 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,111 @@ -# 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 on 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 IVaultClient port. +src/Infrastructure the adapter that drives `bw`, plus the wire contracts. +src/Presentation/Cli the `bwsharp` tool, on Spectre.Console. +``` + +### 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. + +## 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..c3a9692 --- /dev/null +++ b/build/Build.CI.GitHubActions.cs @@ -0,0 +1,72 @@ +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/IVaultClient.cs b/src/Application/Abstractions/IVaultClient.cs new file mode 100644 index 0000000..c24527a --- /dev/null +++ b/src/Application/Abstractions/IVaultClient.cs @@ -0,0 +1,55 @@ +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); +} + +/// 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/ApplicationServiceExtensions.cs b/src/Application/ApplicationServiceExtensions.cs new file mode 100644 index 0000000..60eaccc --- /dev/null +++ b/src/Application/ApplicationServiceExtensions.cs @@ -0,0 +1,16 @@ +using BitwardenSharp.Application.Duplicates; +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(); + 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/Merging/MergeBuilder.cs b/src/Application/Merging/MergeBuilder.cs new file mode 100644 index 0000000..7fca8cb --- /dev/null +++ b/src/Application/Merging/MergeBuilder.cs @@ -0,0 +1,94 @@ +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"; + + 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/MergeExecutor.cs b/src/Application/Merging/MergeExecutor.cs new file mode 100644 index 0000000..97801c4 --- /dev/null +++ b/src/Application/Merging/MergeExecutor.cs @@ -0,0 +1,135 @@ +using BitwardenSharp.Application.Abstractions; +using BitwardenSharp.Domain.Duplicates; +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 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/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/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..6878d88 --- /dev/null +++ b/src/Domain/Vault/VaultItem.cs @@ -0,0 +1,58 @@ +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 IReadOnlyList Fields { get; init; } = []; + + public IReadOnlyList Attachments { 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 ?? []; + + /// + /// 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 — see . + 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..cfb8a53 --- /dev/null +++ b/src/Infrastructure/Bw/BwCliVaultClient.cs @@ -0,0 +1,101 @@ +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"); + } + + 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..ab8ab6b --- /dev/null +++ b/src/Infrastructure/Bw/BwItemMapper.cs @@ -0,0 +1,97 @@ +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, + 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() ?? [], + }; + + /// + /// 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..2de7b23 --- /dev/null +++ b/src/Infrastructure/Bw/BwProcessRunner.cs @@ -0,0 +1,124 @@ +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, + CancellationToken cancellationToken = default) + { + var result = await TryRunAsync(arguments, standardInput, 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, + 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"; + + 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/Contracts/BwContracts.cs b/src/Infrastructure/Bw/Contracts/BwContracts.cs new file mode 100644 index 0000000..8a69f4b --- /dev/null +++ b/src/Infrastructure/Bw/Contracts/BwContracts.cs @@ -0,0 +1,90 @@ +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("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 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..98a15a0 --- /dev/null +++ b/src/Infrastructure/Bw/JsonFileVaultClient.cs @@ -0,0 +1,58 @@ +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"); +} diff --git a/src/Infrastructure/InfrastructureServiceExtensions.cs b/src/Infrastructure/InfrastructureServiceExtensions.cs new file mode 100644 index 0000000..8747bfd --- /dev/null +++ b/src/Infrastructure/InfrastructureServiceExtensions.cs @@ -0,0 +1,22 @@ +using BitwardenSharp.Application.Abstractions; +using BitwardenSharp.Infrastructure.Bw; +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(); + return services; + } +} 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/tests/Application.Tests/BitwardenSharp.Application.Tests.csproj b/tests/Application.Tests/BitwardenSharp.Application.Tests.csproj new file mode 100644 index 0000000..1a5bdab --- /dev/null +++ b/tests/Application.Tests/BitwardenSharp.Application.Tests.csproj @@ -0,0 +1,15 @@ + + + false + + + + + + + + + + + + diff --git a/tests/Application.Tests/DuplicateScannerSpecs.cs b/tests/Application.Tests/DuplicateScannerSpecs.cs new file mode 100644 index 0000000..e7a04c3 --- /dev/null +++ b/tests/Application.Tests/DuplicateScannerSpecs.cs @@ -0,0 +1,209 @@ +using BitwardenSharp.Application.Duplicates; +using BitwardenSharp.Domain.Duplicates; +using BitwardenSharp.Domain.Vault; +using Shouldly; +using Xunit; + +namespace BitwardenSharp.Application.Tests; + +public class DuplicateScannerSpecs +{ + private readonly DuplicateScanner _scanner = new(); + + [Fact] + public void Subdomains_of_one_site_with_one_credential_are_an_exact_duplicate() + { + var result = _scanner.Scan([ + TestVault.Login("eu.battle.net", uris: ["https://eu.battle.net/login"]), + TestVault.Login("us.battle.net", uris: ["https://us.battle.net/login"]), + TestVault.Login("account.battle.net", uris: ["https://account.battle.net/"]), + ]); + + var group = result.Groups.ShouldHaveSingleItem(); + group.Category.ShouldBe(DuplicateCategory.ExactDuplicate); + group.Members.Count.ShouldBe(3); + group.CanMerge.ShouldBeTrue(); + group.Losers.Count().ShouldBe(2); + } + + [Fact] + public void One_brand_under_two_tlds_is_a_related_domain_merge() + { + var result = _scanner.Scan([ + TestVault.Login("digikey.com", uris: ["https://auth.digikey.com/"]), + TestVault.Login("digikey.co.nz", uris: ["https://www.digikey.co.nz/"]), + ]); + + var group = result.Groups.ShouldHaveSingleItem(); + group.Category.ShouldBe(DuplicateCategory.RelatedDomain); + group.CanMerge.ShouldBeTrue(); + } + + [Fact] + public void Two_front_doors_onto_one_service_family_are_a_related_domain_merge() + { + var result = _scanner.Scan([ + TestVault.Login("Gmail", uris: ["https://mail.google.com/"]), + TestVault.Login("YouTube", uris: ["https://www.youtube.com/"]), + ]); + + var group = result.Groups.ShouldHaveSingleItem(); + group.Category.ShouldBe(DuplicateCategory.RelatedDomain); + } + + /// + /// Regression. Password reuse is not evidence of a duplicate: on a real vault a single + /// password covered hundreds of unrelated accounts. Grouping on credentials alone would have + /// proposed deleting live accounts. + /// + [Fact] + public void One_password_reused_across_unrelated_sites_is_never_grouped() + { + var result = _scanner.Scan([ + TestVault.Login("Geekzone", uris: ["https://www.geekzone.co.nz/"]), + TestVault.Login("Docker Hub", uris: ["https://hub.docker.com/"]), + TestVault.Login("MyAnimeList", uris: ["https://myanimelist.net/"]), + ]); + + result.Groups.ShouldBeEmpty(); + } + + /// + /// Regression for the specific defect: the original rule accepted a group when any one + /// of its domains belonged to a known service family, so a single Nintendo entry dragged nine + /// unrelated sites in with it. + /// + [Fact] + public void A_single_known_brand_among_unrelated_sites_does_not_make_them_related() + { + var result = _scanner.Scan([ + TestVault.Login("Nintendo", uris: ["https://accounts.nintendo.com/"]), + TestVault.Login("Geekzone", uris: ["https://www.geekzone.co.nz/"]), + TestVault.Login("EDDB", uris: ["https://eddb.io/"]), + ]); + + result.Groups.ShouldNotContain(g => g.Category == DuplicateCategory.RelatedDomain); + } + + /// + /// Regression. A homelab reuses one login across many machines. Those are separate hosts, and + /// merging them would delete the record of every host but one. + /// + [Fact] + public void One_credential_across_distinct_hosts_is_review_only_and_never_merged() + { + var result = _scanner.Scan([ + TestVault.Login("NUC-01", username: "root", uris: ["https://10.0.0.11:8006/"]), + TestVault.Login("NUC-02", username: "root", uris: ["https://10.0.0.12:8006/"]), + TestVault.Login("NUC-03", username: "root", uris: ["https://10.0.0.13:8006/"]), + ]); + + var group = result.Groups.ShouldHaveSingleItem(); + group.Category.ShouldBe(DuplicateCategory.InfrastructureSharedCredential); + group.CanMerge.ShouldBeFalse(); + result.MergeableDeletions.ShouldBe(0); + } + + [Fact] + public void Same_site_and_username_with_different_passwords_is_a_conflict() + { + var result = _scanner.Scan([ + TestVault.Login("reddit A", username: "chrison", password: "old", uris: ["https://reddit.com/"]), + TestVault.Login("reddit B", username: "chrison", password: "new", uris: ["https://www.reddit.com/"]), + ]); + + var group = result.Groups.ShouldHaveSingleItem(); + group.Category.ShouldBe(DuplicateCategory.CredentialConflict); + group.CanMerge.ShouldBeFalse(); + } + + [Fact] + public void Native_app_uris_are_not_folded_into_web_domains() + { + // "com.google.android.gm" must not be reduced to a domain and matched against google.com. + var result = _scanner.Scan([ + TestVault.Login("Gmail web", uris: ["https://mail.google.com/"]), + TestVault.Login("Gmail app", uris: ["androidapp://com.google.android.gm"]), + ]); + + result.Groups.ShouldNotContain(g => g.Category == DuplicateCategory.ExactDuplicate); + } + + [Fact] + public void The_richest_item_survives() + { + var sparse = TestVault.Login("sparse", uris: ["https://example.com/"]); + var rich = TestVault.Login("rich", uris: ["https://example.com/"], totp: "SEED", + notes: "recovery codes", folderId: "folder-1"); + + var group = _scanner.Scan([sparse, rich]).Groups.ShouldHaveSingleItem(); + + group.Survivor.Name.ShouldBe("rich"); + } + + [Fact] + public void An_attachment_anywhere_in_the_group_blocks_the_merge() + { + var result = _scanner.Scan([ + TestVault.Login("plain", uris: ["https://example.com/"]), + TestVault.Login("with file", uris: ["https://example.com/"], + attachments: [new ItemAttachment { Id = "a1", FileName = "recovery.pdf" }]), + ]); + + var group = result.Groups.ShouldHaveSingleItem(); + group.Category.ShouldBe(DuplicateCategory.ExactDuplicate); + group.CanMerge.ShouldBeFalse(); + group.Warnings.ShouldContain(w => w.Code == "attachments" && w.IsBlocking); + } + + [Fact] + public void Two_different_totp_seeds_block_the_merge() + { + var result = _scanner.Scan([ + TestVault.Login("a", uris: ["https://example.com/"], totp: "SEED-ONE"), + TestVault.Login("b", uris: ["https://example.com/"], totp: "SEED-TWO"), + ]); + + var group = result.Groups.ShouldHaveSingleItem(); + group.CanMerge.ShouldBeFalse(); + group.Warnings.ShouldContain(w => w.Code == "totp-conflict" && w.IsBlocking); + } + + [Fact] + public void Usernames_differing_only_in_case_are_the_same_account() + { + var result = _scanner.Scan([ + TestVault.Login("a", username: "Chrison", uris: ["https://example.com/"]), + TestVault.Login("b", username: "chrison", uris: ["https://example.com/"]), + ]); + + result.Groups.ShouldHaveSingleItem().Category.ShouldBe(DuplicateCategory.ExactDuplicate); + } + + [Fact] + public void Items_without_a_password_are_not_grouped_as_duplicates() + { + var result = _scanner.Scan([ + TestVault.Login("a", password: null, uris: ["https://example.com/"]), + TestVault.Login("b", password: null, uris: ["https://example.com/"]), + ]); + + result.Groups.ShouldNotContain(g => g.Category == DuplicateCategory.ExactDuplicate); + } + + [Fact] + public void Group_ids_are_stable_across_repeated_scans_of_the_same_input() + { + VaultItem[] items = [ + TestVault.Login("a", uris: ["https://example.com/"]), + TestVault.Login("b", uris: ["https://example.com/"]), + TestVault.Login("c", username: "other", uris: ["https://other.com/"]), + TestVault.Login("d", username: "other", uris: ["https://other.com/"]), + ]; + + var first = _scanner.Scan(items).Groups.Select(g => $"{g.Id}:{g.Survivor.Id}"); + var second = _scanner.Scan([.. items.Reverse()]).Groups.Select(g => $"{g.Id}:{g.Survivor.Id}"); + + second.ShouldBe(first, ignoreOrder: true); + } +} diff --git a/tests/Application.Tests/MergeExecutorSpecs.cs b/tests/Application.Tests/MergeExecutorSpecs.cs new file mode 100644 index 0000000..c0d941c --- /dev/null +++ b/tests/Application.Tests/MergeExecutorSpecs.cs @@ -0,0 +1,141 @@ +using BitwardenSharp.Application.Abstractions; +using BitwardenSharp.Application.Duplicates; +using BitwardenSharp.Application.Merging; +using BitwardenSharp.Domain.Vault; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace BitwardenSharp.Application.Tests; + +public class MergeExecutorSpecs +{ + private static (IVaultClient Vault, Dictionary Store) FakeVault( + params VaultItem[] items) + { + var store = items.ToDictionary(i => i.Id); + var vault = Substitute.For(); + + vault.GetItemAsync(Arg.Any(), Arg.Any()) + .Returns(call => Task.FromResult(store[call.ArgAt(0)])); + + vault.UpdateItemAsync(Arg.Any(), Arg.Any()) + .Returns(call => + { + var item = call.ArgAt(0); + store[item.Id] = item; + return Task.FromResult(item); + }); + + vault.DeleteItemAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(call => { store.Remove(call.ArgAt(0)); return Task.CompletedTask; }); + + return (vault, store); + } + + [Fact] + public async Task A_dry_run_reads_and_computes_but_writes_nothing() + { + var group = new DuplicateScanner().Scan([ + TestVault.Login("keep", uris: ["https://example.com/a"]), + TestVault.Login("drop", uris: ["https://example.com/b"]), + ]).Groups.Single(); + + var (vault, store) = FakeVault([.. group.Members]); + + var outcome = await new MergeExecutor(vault).ApplyAsync(group, dryRun: true, TestContext.Current.CancellationToken); + + outcome.Status.ShouldBe(MergeStatus.Merged); + outcome.Changes.ShouldContain(c => c.StartsWith("+uri")); + outcome.DeletedItemIds.ShouldBeEmpty(); + store.Count.ShouldBe(2); + await vault.DidNotReceive().UpdateItemAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Applying_unions_the_uris_onto_the_survivor_and_trashes_the_loser() + { + var group = new DuplicateScanner().Scan([ + TestVault.Login("keep", uris: ["https://example.com/a"]), + TestVault.Login("drop", uris: ["https://example.com/b"]), + ]).Groups.Single(); + + var (vault, store) = FakeVault([.. group.Members]); + + var outcome = await new MergeExecutor(vault).ApplyAsync(group, dryRun: false, TestContext.Current.CancellationToken); + + outcome.Status.ShouldBe(MergeStatus.Merged); + store.Count.ShouldBe(1); + store[group.Survivor.Id].Uris.Select(u => u.Uri) + .ShouldBe(["https://example.com/a", "https://example.com/b"], ignoreOrder: true); + + // Soft delete: the trash is the only undo a merge has. + await vault.Received().DeleteItemAsync( + group.Losers.Single().Id, permanent: false, Arg.Any()); + } + + /// + /// The core safety property: if the survivor does not read back with the merged content, no + /// loser is deleted, so nothing is lost and the operation can simply be re-run. + /// + [Fact] + public async Task A_survivor_that_does_not_verify_leaves_every_loser_in_place() + { + var group = new DuplicateScanner().Scan([ + TestVault.Login("keep", uris: ["https://example.com/a"]), + TestVault.Login("drop", uris: ["https://example.com/b"]), + ]).Groups.Single(); + + var (vault, store) = FakeVault([.. group.Members]); + + // The vault silently accepts the write but stores the original — the failure mode a + // blind "update then delete" would turn into data loss. + vault.UpdateItemAsync(Arg.Any(), Arg.Any()) + .Returns(call => Task.FromResult(call.ArgAt(0))); + + var outcome = await new MergeExecutor(vault).ApplyAsync(group, dryRun: false, TestContext.Current.CancellationToken); + + outcome.Status.ShouldBe(MergeStatus.VerificationFailed); + outcome.DeletedItemIds.ShouldBeEmpty(); + store.Count.ShouldBe(2); + await vault.DidNotReceive().DeleteItemAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task A_review_only_group_is_refused_without_touching_the_vault() + { + var group = new DuplicateScanner().Scan([ + TestVault.Login("NUC-01", username: "root", uris: ["https://10.0.0.11:8006/"]), + TestVault.Login("NUC-02", username: "root", uris: ["https://10.0.0.12:8006/"]), + ]).Groups.Single(); + + var (vault, store) = FakeVault([.. group.Members]); + + var outcome = await new MergeExecutor(vault).ApplyAsync(group, dryRun: false, TestContext.Current.CancellationToken); + + outcome.Status.ShouldBe(MergeStatus.Skipped); + store.Count.ShouldBe(2); + await vault.DidNotReceive().GetItemAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task A_failure_partway_through_reports_it_rather_than_continuing() + { + var group = new DuplicateScanner().Scan([ + TestVault.Login("keep", uris: ["https://example.com/a"]), + TestVault.Login("drop", uris: ["https://example.com/b"]), + ]).Groups.Single(); + + var (vault, _) = FakeVault([.. group.Members]); + vault.UpdateItemAsync(Arg.Any(), Arg.Any()) + .Returns(_ => throw new InvalidOperationException("vault rejected the edit")); + + var outcome = await new MergeExecutor(vault).ApplyAsync(group, dryRun: false, TestContext.Current.CancellationToken); + + outcome.Status.ShouldBe(MergeStatus.Failed); + outcome.Message.ShouldNotBeNull().ShouldContain("vault rejected the edit"); + await vault.DidNotReceive().DeleteItemAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } +} diff --git a/tests/Application.Tests/TestVault.cs b/tests/Application.Tests/TestVault.cs new file mode 100644 index 0000000..b7f7585 --- /dev/null +++ b/tests/Application.Tests/TestVault.cs @@ -0,0 +1,38 @@ +using BitwardenSharp.Domain.Vault; + +namespace BitwardenSharp.Application.Tests; + +/// Terse construction of vault items for tests. +internal static class TestVault +{ + private static int _sequence; + + public static VaultItem Login( + string name, + string? username = "user@example.com", + string? password = "hunter2", + string[]? uris = null, + string? folderId = null, + string? totp = null, + string? notes = null, + CustomField[]? fields = null, + ItemAttachment[]? attachments = null, + DateTimeOffset? revised = null) => new() + { + Id = $"item-{Interlocked.Increment(ref _sequence):D4}", + Type = ItemType.Login, + Name = name, + FolderId = folderId, + Notes = notes, + Fields = fields ?? [], + Attachments = attachments ?? [], + RevisionDate = revised, + Login = new LoginDetails + { + Username = username, + Password = password, + Totp = totp, + Uris = (uris ?? []).Select(u => new LoginUri { Uri = u }).ToList(), + }, + }; +} diff --git a/tests/Architecture.Tests/ArchRuleAssert.cs b/tests/Architecture.Tests/ArchRuleAssert.cs new file mode 100644 index 0000000..c8bf2da --- /dev/null +++ b/tests/Architecture.Tests/ArchRuleAssert.cs @@ -0,0 +1,26 @@ +using ArchUnitNET.Fluent; +using Xunit; + +namespace BitwardenSharp.Architecture.Tests; + +/// +/// Replaces TngTech.ArchUnitNET.xUnit's Check() extension. That package still depends +/// on xunit.assert 2.x, which cannot coexist with xunit v3, so we evaluate the rule against +/// the core ArchUnitNET API and fail through xunit ourselves. +/// +internal static class ArchRuleAssert +{ + /// Evaluates the rule and fails the test with every violation listed if it does not hold. + public static void Check(this IArchRule rule, ArchUnitNET.Domain.Architecture architecture) + { + if (rule.HasNoViolations(architecture)) return; + + var violations = rule.Evaluate(architecture) + .Where(result => !result.Passed) + .Select(result => $" - {result.Description}") + .ToList(); + + Assert.Fail($"Architecture rule violated: {rule.Description}{Environment.NewLine}" + + string.Join(Environment.NewLine, violations)); + } +} diff --git a/tests/Architecture.Tests/BitwardenSharp.Architecture.Tests.csproj b/tests/Architecture.Tests/BitwardenSharp.Architecture.Tests.csproj new file mode 100644 index 0000000..0f4d0b6 --- /dev/null +++ b/tests/Architecture.Tests/BitwardenSharp.Architecture.Tests.csproj @@ -0,0 +1,18 @@ + + + false + + + + + + + + + + + + + + + diff --git a/tests/Architecture.Tests/HexagonalArchitectureSpecs.cs b/tests/Architecture.Tests/HexagonalArchitectureSpecs.cs new file mode 100644 index 0000000..a768145 --- /dev/null +++ b/tests/Architecture.Tests/HexagonalArchitectureSpecs.cs @@ -0,0 +1,76 @@ +using ArchUnitNET.Loader; +using Xunit; +using static ArchUnitNET.Fluent.ArchRuleDefinition; + +namespace BitwardenSharp.Architecture.Tests; + +/// +/// Enforces the hexagon: Domain ← Application ← Infrastructure, with Presentation as the outer +/// composition layer. Dependencies may only point inward. +/// +public class HexagonalArchitectureSpecs +{ + private static readonly ArchUnitNET.Domain.Architecture Architecture = new ArchLoader() + .LoadAssemblies( + typeof(Domain.Vault.VaultItem).Assembly, + typeof(Application.ApplicationServiceExtensions).Assembly, + typeof(Infrastructure.InfrastructureServiceExtensions).Assembly, + typeof(Cli.Commands.ScanCommand).Assembly) + .Build(); + + [Fact] + public void Domain_depends_on_no_other_layer() + { + Types().That().ResideInNamespaceMatching(@"BitwardenSharp\.Domain") + .Should().NotDependOnAnyTypesThat() + .ResideInNamespaceMatching(@"BitwardenSharp\.(Application|Infrastructure|Cli)") + .Because("Domain is the core of the hexagon and must depend on nothing else.") + .Check(Architecture); + } + + [Fact] + public void Application_depends_only_on_domain() + { + Types().That().ResideInNamespaceMatching(@"BitwardenSharp\.Application") + .Should().NotDependOnAnyTypesThat() + .ResideInNamespaceMatching(@"BitwardenSharp\.(Infrastructure|Cli)") + .Because("Application owns the ports; adapters depend on it, never the reverse.") + .Check(Architecture); + } + + [Fact] + public void Infrastructure_does_not_depend_on_presentation() + { + Types().That().ResideInNamespaceMatching(@"BitwardenSharp\.Infrastructure") + .Should().NotDependOnAnyTypesThat() + .ResideInNamespaceMatching(@"BitwardenSharp\.Cli") + .Because("Infrastructure implements ports; it must not reach into a presentation host.") + .Check(Architecture); + } + + [Fact] + public void Domain_does_not_depend_on_serialization() + { + // The wire contracts live in Infrastructure precisely so the domain model can stay free of + // transport concerns; a JsonPropertyName appearing on a domain type means that boundary + // has started to leak. + Types().That().ResideInNamespaceMatching(@"BitwardenSharp\.Domain") + .Should().NotDependOnAnyTypesThat() + .ResideInNamespaceMatching(@"System\.Text\.Json.*") + .Because("the domain model is not a serialization contract; BwItem is.") + .Check(Architecture); + } + + [Fact] + public void Only_infrastructure_starts_processes() + { + // Shelling out to `bw` is an adapter concern. If Application or Domain ever reaches for + // Process directly, the port has been bypassed and the code is no longer testable + // without a real vault. + Types().That().ResideInNamespaceMatching(@"BitwardenSharp\.(Domain|Application)") + .Should().NotDependOnAnyTypesThat() + .HaveFullNameContaining("System.Diagnostics.Process") + .Because("only Infrastructure may run the bw client.") + .Check(Architecture); + } +} diff --git a/tests/Domain.Tests/BitwardenSharp.Domain.Tests.csproj b/tests/Domain.Tests/BitwardenSharp.Domain.Tests.csproj new file mode 100644 index 0000000..848eb31 --- /dev/null +++ b/tests/Domain.Tests/BitwardenSharp.Domain.Tests.csproj @@ -0,0 +1,15 @@ + + + false + + + + + + + + + + + + diff --git a/tests/Domain.Tests/UriTargetSpecs.cs b/tests/Domain.Tests/UriTargetSpecs.cs new file mode 100644 index 0000000..7c6c8ab --- /dev/null +++ b/tests/Domain.Tests/UriTargetSpecs.cs @@ -0,0 +1,100 @@ +using BitwardenSharp.Domain.Uris; +using Shouldly; +using Xunit; + +namespace BitwardenSharp.Domain.Tests; + +public class UriTargetSpecs +{ + [Theory] + [InlineData("https://www.example.com/login", "example.com")] + [InlineData("https://auth.digikey.com/as/authorization.oauth2", "digikey.com")] + [InlineData("example.com", "example.com")] + [InlineData("HTTPS://WWW.EXAMPLE.COM/", "example.com")] + [InlineData("https://user:pass@example.com/x", "example.com")] + [InlineData("https://example.com:8443/", "example.com")] + [InlineData("https://example.com./", "example.com")] + public void Reduces_a_web_uri_to_its_registrable_domain(string uri, string expected) + { + var target = UriTarget.Parse(uri); + + target.ShouldNotBeNull(); + target.Kind.ShouldBe(UriTargetKind.Domain); + target.Value.ShouldBe(expected); + } + + [Theory] + [InlineData("https://www.countdown.co.nz/shop", "countdown.co.nz")] + [InlineData("https://sso.countdown.co.nz/", "countdown.co.nz")] + [InlineData("https://www.amazon.com.au/ap/signin", "amazon.com.au")] + [InlineData("https://www.bbc.co.uk/news", "bbc.co.uk")] + [InlineData("https://tracing.covid19.govt.nz/x", "covid19.govt.nz")] + public void Respects_multi_label_public_suffixes(string uri, string expected) + { + UriTarget.Parse(uri)!.Value.ShouldBe(expected); + } + + [Theory] + [InlineData("https://192.168.1.1:8443/", "192.168.1.1")] + [InlineData("10.0.0.11", "10.0.0.11")] + public void Treats_a_literal_ip_as_its_own_kind(string uri, string expected) + { + var target = UriTarget.Parse(uri); + + target!.Kind.ShouldBe(UriTargetKind.IpAddress); + target.Value.ShouldBe(expected); + // An IP has no brand, so it can never be folded together with a domain by brand rules. + target.Brand.ShouldBeNull(); + } + + [Theory] + [InlineData("localhost:3000", "localhost")] + [InlineData("http://synology/", "synology")] + public void Treats_a_dotless_host_as_its_own_kind(string uri, string expected) + { + var target = UriTarget.Parse(uri); + + target!.Kind.ShouldBe(UriTargetKind.Host); + target.Value.ShouldBe(expected); + } + + [Fact] + public void Keeps_native_app_ids_out_of_the_domain_namespace() + { + var target = UriTarget.Parse("androidapp://com.google.android.gm"); + + target!.Kind.ShouldBe(UriTargetKind.App); + target.Value.ShouldBe("com.google.android.gm"); + target.Brand.ShouldBeNull(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("https://")] + public void Yields_nothing_for_an_unusable_uri(string? uri) => UriTarget.Parse(uri).ShouldBeNull(); + + [Fact] + public void Brand_is_the_leading_label_of_a_registrable_domain() + { + UriTarget.Parse("https://www.digikey.co.nz/")!.Brand.ShouldBe("digikey"); + UriTarget.Parse("https://auth.digikey.com/")!.Brand.ShouldBe("digikey"); + } + + [Theory] + [InlineData("https://mail.google.com/", "google")] + [InlineData("https://www.youtube.com/", "google")] + [InlineData("https://outlook.live.com/", "microsoft")] + [InlineData("https://login.microsoftonline.com/", "microsoft")] + public void Maps_a_known_brand_to_its_service_family(string uri, string family) + { + ServiceFamily.ForTarget(UriTarget.Parse(uri)!).ShouldBe(family); + } + + [Fact] + public void An_unknown_brand_has_no_family() + { + ServiceFamily.ForTarget(UriTarget.Parse("https://www.geekzone.co.nz/")!).ShouldBeNull(); + } +} diff --git a/tests/Infrastructure.Tests/BitwardenSharp.Infrastructure.Tests.csproj b/tests/Infrastructure.Tests/BitwardenSharp.Infrastructure.Tests.csproj new file mode 100644 index 0000000..72f67a8 --- /dev/null +++ b/tests/Infrastructure.Tests/BitwardenSharp.Infrastructure.Tests.csproj @@ -0,0 +1,15 @@ + + + false + + + + + + + + + + + + From 70e031658a6332497c7675d8882672ffc441415a Mon Sep 17 00:00:00 2001 From: Christian Simon Date: Sun, 16 Aug 2026 14:09:05 +1200 Subject: [PATCH 02/11] Generate CI workflows from the Fallout definition build and publish, targeting the GitFlow branch set. The YAML is generated from Build.CI.GitHubActions.cs and must not be hand-edited. --- .fallout/build.schema.json | 113 ++++++++++++++++++++++++++++++++ .github/workflows/build.yml | 54 +++++++++++++++ .github/workflows/publish.yml | 48 ++++++++++++++ build/Build.CI.GitHubActions.cs | 1 + 4 files changed, 216 insertions(+) create mode 100644 .fallout/build.schema.json create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/publish.yml 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/.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/build/Build.CI.GitHubActions.cs b/build/Build.CI.GitHubActions.cs index c3a9692..5c7a92f 100644 --- a/build/Build.CI.GitHubActions.cs +++ b/build/Build.CI.GitHubActions.cs @@ -1,3 +1,4 @@ +using Fallout.Common; using Fallout.Common.CI.GitHubActions; /// From a697b3bbaf1e8cd1270159a7b5fef6d90835483b Mon Sep 17 00:00:00 2001 From: Christian Simon Date: Sun, 16 Aug 2026 21:13:23 +1200 Subject: [PATCH 03/11] Add an Avalonia desktop host and a bw serve transport The CLI is the wrong shape for reviewing 800 items, so the vault browser gets its own presentation project: unlock screen, folder tree, item list, detail pane. Process-per-call is also the wrong transport behind a GUI -- every read paid a fresh ~0.5s Node start-up. BwServeVaultClient talks to the local Vault Management API instead: one child process, HTTP for everything. Both adapters satisfy the same IVaultClient port, so the CLI keeps the process-per-call one (a one-shot command has no reason to open a port) and the desktop takes the server. That API is unauthenticated -- anything that can reach the port reads the whole vault. Mitigated structurally: loopback only, a random ephemeral port rather than the well-known 8087, and the process is killed on dispose so the window is exactly the app's lifetime. Unlock is a separate IVaultSession port because only the desktop takes a master password; the CLI inherits a session from the environment. The password reaches bw through --passwordenv or an HTTP body, never argv. Verified against bw 2026.7.0 rather than the docs, which caught that /status nests its payload under data.template while every other endpoint puts it directly in data. Avalonia 11.3 rather than 12.x: Diagnostics and much of the ecosystem still track 11, and DevTools is worth more here than being current. --- BitwardenSharp.slnx | 1 + CHANGELOG.md | 7 + Directory.Packages.props | 11 + README.md | 42 +++- src/Application/Abstractions/IVaultSession.cs | 40 ++++ src/Infrastructure/Bw/BwProcessRunner.cs | 9 +- src/Infrastructure/Bw/BwVaultSession.cs | 78 +++++++ .../InfrastructureServiceExtensions.cs | 37 ++++ src/Infrastructure/Serve/BwServeProcess.cs | 143 ++++++++++++ .../Serve/BwServeVaultClient.cs | 209 ++++++++++++++++++ src/Presentation/Desktop/App.axaml | 14 ++ src/Presentation/Desktop/App.axaml.cs | 43 ++++ .../Desktop/BitwardenSharp.Desktop.csproj | 27 +++ src/Presentation/Desktop/Program.cs | 18 ++ src/Presentation/Desktop/ViewLocator.cs | 25 +++ .../Desktop/ViewModels/FolderNode.cs | 39 ++++ .../Desktop/ViewModels/ItemViewModel.cs | 52 +++++ .../Desktop/ViewModels/MainWindowViewModel.cs | 47 ++++ .../Desktop/ViewModels/UnlockViewModel.cs | 75 +++++++ .../Desktop/ViewModels/VaultViewModel.cs | Bin 0 -> 5356 bytes .../Desktop/ViewModels/ViewModelBase.cs | 5 + .../Desktop/Views/MainWindow.axaml | 10 + .../Desktop/Views/MainWindow.axaml.cs | 8 + .../Desktop/Views/UnlockView.axaml | 47 ++++ .../Desktop/Views/UnlockView.axaml.cs | 22 ++ .../Desktop/Views/VaultView.axaml | 168 ++++++++++++++ .../Desktop/Views/VaultView.axaml.cs | 8 + src/Presentation/Desktop/app.manifest | 9 + .../BitwardenSharp.Architecture.Tests.csproj | 1 + .../HexagonalArchitectureSpecs.cs | 9 +- 30 files changed, 1195 insertions(+), 9 deletions(-) create mode 100644 src/Application/Abstractions/IVaultSession.cs create mode 100644 src/Infrastructure/Bw/BwVaultSession.cs create mode 100644 src/Infrastructure/Serve/BwServeProcess.cs create mode 100644 src/Infrastructure/Serve/BwServeVaultClient.cs create mode 100644 src/Presentation/Desktop/App.axaml create mode 100644 src/Presentation/Desktop/App.axaml.cs create mode 100644 src/Presentation/Desktop/BitwardenSharp.Desktop.csproj create mode 100644 src/Presentation/Desktop/Program.cs create mode 100644 src/Presentation/Desktop/ViewLocator.cs create mode 100644 src/Presentation/Desktop/ViewModels/FolderNode.cs create mode 100644 src/Presentation/Desktop/ViewModels/ItemViewModel.cs create mode 100644 src/Presentation/Desktop/ViewModels/MainWindowViewModel.cs create mode 100644 src/Presentation/Desktop/ViewModels/UnlockViewModel.cs create mode 100644 src/Presentation/Desktop/ViewModels/VaultViewModel.cs create mode 100644 src/Presentation/Desktop/ViewModels/ViewModelBase.cs create mode 100644 src/Presentation/Desktop/Views/MainWindow.axaml create mode 100644 src/Presentation/Desktop/Views/MainWindow.axaml.cs create mode 100644 src/Presentation/Desktop/Views/UnlockView.axaml create mode 100644 src/Presentation/Desktop/Views/UnlockView.axaml.cs create mode 100644 src/Presentation/Desktop/Views/VaultView.axaml create mode 100644 src/Presentation/Desktop/Views/VaultView.axaml.cs create mode 100644 src/Presentation/Desktop/app.manifest diff --git a/BitwardenSharp.slnx b/BitwardenSharp.slnx index 951ee44..df6d3ad 100644 --- a/BitwardenSharp.slnx +++ b/BitwardenSharp.slnx @@ -6,6 +6,7 @@ + diff --git a/CHANGELOG.md b/CHANGELOG.md index de4a476..052b471 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,13 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `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 diff --git a/Directory.Packages.props b/Directory.Packages.props index 4cbfead..ee71b28 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,6 +16,17 @@ + + + + + + + + + + diff --git a/README.md b/README.md index d4620ef..09cc357 100644 --- a/README.md +++ b/README.md @@ -66,12 +66,33 @@ 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 IVaultClient port. -src/Infrastructure the adapter that drives `bw`, plus the wire contracts. -src/Presentation/Cli the `bwsharp` tool, on Spectre.Console. +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` @@ -96,6 +117,19 @@ Two rules hold throughout, because process arguments are world-readable via `ps` `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 +``` + +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. + +Next iteration is the duplicate reviewer: left/right compare with per-property merge in either +direction. + ## Building ``` 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/Infrastructure/Bw/BwProcessRunner.cs b/src/Infrastructure/Bw/BwProcessRunner.cs index 2de7b23..3e84d11 100644 --- a/src/Infrastructure/Bw/BwProcessRunner.cs +++ b/src/Infrastructure/Bw/BwProcessRunner.cs @@ -49,9 +49,10 @@ public sealed class BwProcessRunner(BwCliOptions options, ILogger RunAsync( IEnumerable arguments, string? standardInput = null, + IReadOnlyDictionary? environment = null, CancellationToken cancellationToken = default) { - var result = await TryRunAsync(arguments, standardInput, cancellationToken); + var result = await TryRunAsync(arguments, standardInput, environment, cancellationToken); if (!result.Succeeded) throw new BwCommandException(DescribeSafely(arguments), result); return result.StandardOutput; } @@ -60,6 +61,7 @@ public async Task RunAsync( public async Task TryRunAsync( IEnumerable arguments, string? standardInput = null, + IReadOnlyDictionary? environment = null, CancellationToken cancellationToken = default) { var args = arguments.ToList(); @@ -84,6 +86,11 @@ public async Task TryRunAsync( // 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 }; 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/InfrastructureServiceExtensions.cs b/src/Infrastructure/InfrastructureServiceExtensions.cs index 8747bfd..8fa0ee7 100644 --- a/src/Infrastructure/InfrastructureServiceExtensions.cs +++ b/src/Infrastructure/InfrastructureServiceExtensions.cs @@ -1,5 +1,6 @@ using BitwardenSharp.Application.Abstractions; using BitwardenSharp.Infrastructure.Bw; +using BitwardenSharp.Infrastructure.Serve; using Microsoft.Extensions.DependencyInjection; namespace BitwardenSharp.Infrastructure; @@ -17,6 +18,42 @@ public static IServiceCollection AddBitwardenCli( 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(); + + // The base address is not known until the server has picked a port, so the HttpClient is + // built after StartAsync rather than configured up front. + services.AddSingleton(provider => + { + var server = provider.GetRequiredService(); + server.StartAsync().GetAwaiter().GetResult(); + return new HttpClient { BaseAddress = server.BaseAddress, Timeout = TimeSpan.FromMinutes(2) }; + }); + + services.AddSingleton(); + services.AddSingleton(p => p.GetRequiredService()); + services.AddSingleton(p => p.GetRequiredService()); return services; } } diff --git a/src/Infrastructure/Serve/BwServeProcess.cs b/src/Infrastructure/Serve/BwServeProcess.cs new file mode 100644 index 0000000..5dac05a --- /dev/null +++ b/src/Infrastructure/Serve/BwServeProcess.cs @@ -0,0 +1,143 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +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; + + 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); + + 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; + } + + public async ValueTask DisposeAsync() + { + 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..9dbcde2 --- /dev/null +++ b/src/Infrastructure/Serve/BwServeVaultClient.cs @@ -0,0 +1,209 @@ +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( + HttpClient http, + 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."); + + 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) + { + using var response = await http.PostAsync("lock", content: null, cancellationToken); + response.EnsureSuccessStatusCode(); + logger?.LogInformation("Vault locked"); + } + + // ── IVaultClient ───────────────────────────────────────────────────────────────────────── + + public async Task SyncAsync(CancellationToken cancellationToken = default) + { + 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)); + + 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}"; + using var response = await http.DeleteAsync(path, cancellationToken); + await EnsureSucceededAsync(response, cancellationToken); + logger?.LogInformation( + "Deleted item {ItemId} ({Disposition})", id, permanent ? "permanently" : "to trash"); + } + + // ── plumbing ───────────────────────────────────────────────────────────────────────────── + + private async Task GetWireItemAsync(string id, CancellationToken cancellationToken) => + await GetAsync($"object/item/{id}", cancellationToken); + + private async Task GetAsync(string path, CancellationToken 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/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..68be4c0 --- /dev/null +++ b/src/Presentation/Desktop/App.axaml.cs @@ -0,0 +1,43 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using BitwardenSharp.Application; +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(); + services.AddSingleton(); + 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. + desktop.ShutdownRequested += (_, _) => + provider.GetRequiredService().OnShutdown(); + } + + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/src/Presentation/Desktop/BitwardenSharp.Desktop.csproj b/src/Presentation/Desktop/BitwardenSharp.Desktop.csproj new file mode 100644 index 0000000..e0818e6 --- /dev/null +++ b/src/Presentation/Desktop/BitwardenSharp.Desktop.csproj @@ -0,0 +1,27 @@ + + + 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/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/FolderNode.cs b/src/Presentation/Desktop/ViewModels/FolderNode.cs new file mode 100644 index 0000000..b0d87cb --- /dev/null +++ b/src/Presentation/Desktop/ViewModels/FolderNode.cs @@ -0,0 +1,39 @@ +using System.Collections.ObjectModel; + +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 and has no of its +/// own. Such a node holds no items directly but still aggregates its children. +/// +public sealed class FolderNode(string name, string path) +{ + public string Name { get; } = name; + + public string Path { get; } = path; + + /// Null when this node is only an implied path segment, not a real folder. + public string? FolderId { get; set; } + + public int DirectCount { get; set; } + + public ObservableCollection Children { get; } = []; + + /// Items here and in everything below, which is what selecting this node shows. + public int TotalCount => DirectCount + Children.Sum(c => c.TotalCount); + + public string Label => Children.Count == 0 || DirectCount == TotalCount + ? $"{Name} ({TotalCount})" + : $"{Name} ({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; + } +} diff --git a/src/Presentation/Desktop/ViewModels/ItemViewModel.cs b/src/Presentation/Desktop/ViewModels/ItemViewModel.cs new file mode 100644 index 0000000..8235ba4 --- /dev/null +++ b/src/Presentation/Desktop/ViewModels/ItemViewModel.cs @@ -0,0 +1,52 @@ +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 +{ + public VaultItem Item { get; } = item; + + 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 bool HasTotp => !string.IsNullOrWhiteSpace(Item.Login?.Totp); + public DateTimeOffset? Revised => Item.RevisionDate; + + public string TypeGlyph => Item.Type switch + { + ItemType.Login => "🔑", + ItemType.SecureNote => "📝", + ItemType.Card => "💳", + ItemType.Identity => "🪪", + ItemType.SshKey => "🖧", + _ => "•", + }; + + /// Primary URI, for the list's secondary line. + public string Subtitle => Username.Length > 0 + ? Username + : Item.Uris.FirstOrDefault()?.Uri ?? string.Empty; + + /// + /// Whether the password is currently on screen. Defaults to hidden and is deliberately not + /// persisted anywhere — revealing is a per-view action, never a stored preference. + /// + [ObservableProperty] private bool _isPasswordVisible; + + public string PasswordDisplay => IsPasswordVisible + ? Item.Login?.Password ?? string.Empty + : new string('•', Math.Min(Item.Login?.Password?.Length ?? 0, 24)); + + partial void OnIsPasswordVisibleChanged(bool value) => OnPropertyChanged(nameof(PasswordDisplay)); + + [RelayCommand] + private void TogglePassword() => IsPasswordVisible = !IsPasswordVisible; +} diff --git a/src/Presentation/Desktop/ViewModels/MainWindowViewModel.cs b/src/Presentation/Desktop/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..641dadd --- /dev/null +++ b/src/Presentation/Desktop/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,47 @@ +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() + { + var vault = _services.GetRequiredService(); + vault.Locked += OnLocked; + Current = vault; + _ = vault.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 void OnShutdown() + { + try { _session.LockAsync().GetAwaiter().GetResult(); } + catch { /* shutting down anyway; a failure to lock must not block exit */ } + } +} diff --git a/src/Presentation/Desktop/ViewModels/UnlockViewModel.cs b/src/Presentation/Desktop/ViewModels/UnlockViewModel.cs new file mode 100644 index 0000000..1dfd127 --- /dev/null +++ b/src/Presentation/Desktop/ViewModels/UnlockViewModel.cs @@ -0,0 +1,75 @@ +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; + + /// Reads who we would be unlocking as, so the screen can name the account. + public async Task InitialiseAsync() + { + try + { + var status = await session.GetStatusAsync(); + 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}"; + } + } + + private bool CanUnlock => !IsBusy && MasterPassword.Length > 0; + + [RelayCommand(CanExecute = nameof(CanUnlock))] + private async Task UnlockAsync() + { + IsBusy = true; + Error = null; + 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; + } + } +} diff --git a/src/Presentation/Desktop/ViewModels/VaultViewModel.cs b/src/Presentation/Desktop/ViewModels/VaultViewModel.cs new file mode 100644 index 0000000000000000000000000000000000000000..5849ec822f8961dc045989049235cbea3106b110 GIT binary patch literal 5356 zcmbVQ%W@kx7R_2;fv{%LtT3&tZAmOimJ?QNY08wHh05gu*&vBg6W{=7Qe6tG<|Fbi z+0UoUm*gHi`XNzvYI?(F_r-nT-g7RxvQjPCw6;#H^GjWpV(z3?Houwui*DcRLX@wM zDlc^*-Hw|=sOgHEO@6-Flyc4?o}bUGGdwJPLHvzgbE)!AyeeH1xzy`*rKGEGwJz_a z%inLeaAUpEN~pf|!?F{#sYu_?Vs8YQ5&B zKD%8BwuM)0X7tVq^NKBWSqQ_N5rS#O97&X7;YLhSnk|F$8o6A# z{Ny;PKiX6?$`iAKm!e=BZk*&Lo0r^L=CjebmQDQs%TiMC!KV=m&!)m!N;GNlzeDzl zHOnrz6-SRpjIL-)Y@y)X@5h+E)AM^#P}p9=$Srq*O+E--+^DjCC#^g2b|)0v8T-P^ z5_guJvb)b|RfyqcM(Nq|St;65IeF`obxoCW#=^a2k8CO2YqpO%I<6g50eek2XDpb1 zqQiTC*Dn93(Hmi0{h4iINHZ!T_C?x@%GR&HElw3nwLfNGgfZIu?FHJ?Rv+S9h?DULHW4yueIKQTl*m%qa=Vck% zYmjO#cFI=Eg59Y8!kCn-8`PGUl^DVZps5b#v*AnX5JplHgZqYt&;;DpYR+!Cy{FFN z#X0_FL!bZ9gsAq3uR_X_GnELng~wMYHF~P5l3*22a5+;q_pJ)N;X5uP668~|6OFY? zA++TtA{KhKyX0r#LW1nUh22(vvdp(&v#9Y7<}NB(QZ)i0ZwVl?g}=3wXS_|#m@W3;4vu4KN2ck5xbOo#{T;!V~#3hw-`-t1& zW%M__ihrrPXLw7*knXT?MQo-mp(JG2XBIl%fx6)QXi+~Q|K?SXr$ZeIg4`PN|@%$;* zG%XPSc}}&uF#vA;IAL?H2*-s5Z&Afl3}l4(R?*f5Zxun_S`-rGrHE`SspXEs-Qs`p zM=xMa8m4zDZ;W)<7(15;!z0Vnay~$eq1ew-lRfSA@Y?2=B|&3@H!y@kpQ8fNm`zc7 zx9}QQ{F{`8s# zMckgmsGs;2U;pq!8x{2QBf}G9(1x%`ZOCAw>(1Mtu?>>lD|#HgA$J(nSJd)~5tc$= zfBYDq?Gp=kbs(?b`?APyO-)e!35=Uz<%sDDL}h$$c6KJMe;x~JM82*zg!U%(JdM)n;f2BhrU0C`FT-fPIKoRO8Dw|*PKE)e*1Dr0b+kbW>?gYTk(L4Wo0QVSXgRQvP#sAm*u>gOk_?~CnU zgf)1LvtcNrbQbbq;89FuOk$6Ndc%@;s*M4e{&yuzP0lwIlAqfvRjrBE=`7Zx9OSh> zDxNsNP5zPsb@SJq-K*K0q z&-y=xiK3TT(jMsFE5qSgSIbprX0eVSJ=A}|Gs_kwcl}~VB4hz3?Z=nnAS6`5RUCFF zh0TWft^S3Ekg0IlVIJw!H8PM=?0@s$Fa$+PE<#xpt?`9($dGANZ%cz zeWvO6-x=lNonA^c_Tx7uK*HFRr@yTkXzZumwld)k4D=3T-8~H$)Ljj|HBIA zprDko&gS?-PCK?GiQcRN3`SFjx=3?M9M-NW>V5*->8F#ver&<2rx*e^{A)gaWU$s7 z+ES@QjH)24eJhQ1En4@1ovZXaaDxR0zItpQ)&Rj%paDMiSUe!gu|&%*jJSC z_89y@EU0}~@yLda3a81=@i={9c-gs*K76o>%DfkCQv|)~u-K7n_cwg)8s`(W)%PA7 Kv9J51{n1}BoNRpn literal 0 HcmV?d00001 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/MainWindow.axaml b/src/Presentation/Desktop/Views/MainWindow.axaml new file mode 100644 index 0000000..bf2e0d5 --- /dev/null +++ b/src/Presentation/Desktop/Views/MainWindow.axaml @@ -0,0 +1,10 @@ + + + diff --git a/src/Presentation/Desktop/Views/MainWindow.axaml.cs b/src/Presentation/Desktop/Views/MainWindow.axaml.cs new file mode 100644 index 0000000..3a01a28 --- /dev/null +++ b/src/Presentation/Desktop/Views/MainWindow.axaml.cs @@ -0,0 +1,8 @@ +using Avalonia.Controls; + +namespace BitwardenSharp.Desktop.Views; + +public partial class MainWindow : Window +{ + public MainWindow() => InitializeComponent(); +} diff --git a/src/Presentation/Desktop/Views/UnlockView.axaml b/src/Presentation/Desktop/Views/UnlockView.axaml new file mode 100644 index 0000000..1a5f632 --- /dev/null +++ b/src/Presentation/Desktop/Views/UnlockView.axaml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + Task DeleteItemAsync(string id, bool permanent = false, 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 . diff --git a/src/Application/ApplicationServiceExtensions.cs b/src/Application/ApplicationServiceExtensions.cs index 60eaccc..92f82df 100644 --- a/src/Application/ApplicationServiceExtensions.cs +++ b/src/Application/ApplicationServiceExtensions.cs @@ -1,4 +1,5 @@ using BitwardenSharp.Application.Duplicates; +using BitwardenSharp.Application.Folders; using BitwardenSharp.Application.Merging; using Microsoft.Extensions.DependencyInjection; @@ -11,6 +12,7 @@ public static IServiceCollection AddBitwardenSharpApplication(this IServiceColle { services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); return services; } } 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/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/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/VaultItem.cs b/src/Domain/Vault/VaultItem.cs index 6878d88..c7bce75 100644 --- a/src/Domain/Vault/VaultItem.cs +++ b/src/Domain/Vault/VaultItem.cs @@ -28,10 +28,32 @@ public sealed record VaultItem 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; } @@ -39,6 +61,9 @@ public sealed record VaultItem /// 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 @@ -53,6 +78,9 @@ public sealed record VaultItem + (Favorite ? 1 : 0) + Uris.Count; - /// Deliberately excludes — see . + /// + /// 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/Bw/BwCliVaultClient.cs b/src/Infrastructure/Bw/BwCliVaultClient.cs index cfb8a53..8bbb330 100644 --- a/src/Infrastructure/Bw/BwCliVaultClient.cs +++ b/src/Infrastructure/Bw/BwCliVaultClient.cs @@ -92,6 +92,36 @@ public async Task DeleteItemAsync( "Deleted item {ItemId} ({Disposition})", id, permanent ? "permanently" : "to trash"); } + 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); diff --git a/src/Infrastructure/Bw/BwItemMapper.cs b/src/Infrastructure/Bw/BwItemMapper.cs index ab8ab6b..13e8cd3 100644 --- a/src/Infrastructure/Bw/BwItemMapper.cs +++ b/src/Infrastructure/Bw/BwItemMapper.cs @@ -17,6 +17,51 @@ internal static class BwItemMapper 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, diff --git a/src/Infrastructure/Bw/Contracts/BwContracts.cs b/src/Infrastructure/Bw/Contracts/BwContracts.cs index 8a69f4b..2947b9c 100644 --- a/src/Infrastructure/Bw/Contracts/BwContracts.cs +++ b/src/Infrastructure/Bw/Contracts/BwContracts.cs @@ -22,6 +22,14 @@ public sealed class BwItem [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; } @@ -75,6 +83,64 @@ public sealed class BwAttachment [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; } diff --git a/src/Infrastructure/Bw/JsonFileVaultClient.cs b/src/Infrastructure/Bw/JsonFileVaultClient.cs index 98a15a0..219ea36 100644 --- a/src/Infrastructure/Bw/JsonFileVaultClient.cs +++ b/src/Infrastructure/Bw/JsonFileVaultClient.cs @@ -55,4 +55,14 @@ public Task UpdateItemAsync(VaultItem item, CancellationToken cancell 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 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..82b5905 --- /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 +/// ones like git.internal.example, 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 index 073612a..c3b0610 100644 --- a/src/Infrastructure/InfrastructureServiceExtensions.cs +++ b/src/Infrastructure/InfrastructureServiceExtensions.cs @@ -1,5 +1,6 @@ using BitwardenSharp.Application.Abstractions; using BitwardenSharp.Infrastructure.Bw; +using BitwardenSharp.Infrastructure.Icons; using BitwardenSharp.Infrastructure.Serve; using Microsoft.Extensions.DependencyInjection; @@ -51,4 +52,23 @@ public static IServiceCollection AddBitwardenServe( 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/BwServeVaultClient.cs b/src/Infrastructure/Serve/BwServeVaultClient.cs index 617fe66..8fdc051 100644 --- a/src/Infrastructure/Serve/BwServeVaultClient.cs +++ b/src/Infrastructure/Serve/BwServeVaultClient.cs @@ -176,6 +176,32 @@ public async Task DeleteItemAsync( // ── plumbing ───────────────────────────────────────────────────────────────────────────── + 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); diff --git a/src/Presentation/Desktop/App.axaml.cs b/src/Presentation/Desktop/App.axaml.cs index 2ff04e6..282d2c4 100644 --- a/src/Presentation/Desktop/App.axaml.cs +++ b/src/Presentation/Desktop/App.axaml.cs @@ -2,6 +2,7 @@ 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; @@ -21,6 +22,12 @@ public override void OnFrameworkInitializationCompleted() 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(); 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/ViewModels/FolderNode.cs b/src/Presentation/Desktop/ViewModels/FolderNode.cs index b0d87cb..ddaccae 100644 --- a/src/Presentation/Desktop/ViewModels/FolderNode.cs +++ b/src/Presentation/Desktop/ViewModels/FolderNode.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; namespace BitwardenSharp.Desktop.ViewModels; @@ -7,28 +8,41 @@ namespace BitwardenSharp.Desktop.ViewModels; /// /// /// 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 and has no of its -/// own. Such a node holds no items directly but still aggregates its children. +/// 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 class FolderNode(string name, string path) +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 Label => Children.Count == 0 || DirectCount == TotalCount - ? $"{Name} ({TotalCount})" - : $"{Name} ({DirectCount} / {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() @@ -36,4 +50,24 @@ 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 index f39fecf..ed94422 100644 --- a/src/Presentation/Desktop/ViewModels/ItemViewModel.cs +++ b/src/Presentation/Desktop/ViewModels/ItemViewModel.cs @@ -1,3 +1,6 @@ +using Avalonia.Media; +using Avalonia.Media.Imaging; +using BitwardenSharp.Desktop.Services; using BitwardenSharp.Domain.Vault; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -7,8 +10,21 @@ 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; @@ -17,17 +33,24 @@ public sealed partial class ItemViewModel(VaultItem item) : ViewModelBase 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; - // Bound directly to IsVisible. Binding `Uris.Count` there instead would hand an int to a - // bool property, which Avalonia reports as a binding error at runtime and silently leaves - // the section in whatever state it started in. + 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 DateTimeOffset? Revised => Item.RevisionDate; + public bool HasPasswordHistory => Item.PasswordHistory.Count > 0; public string TypeGlyph => Item.Type switch { @@ -39,23 +62,76 @@ public sealed partial class ItemViewModel(VaultItem item) : ViewModelBase _ => "•", }; - /// Primary URI, for the list's secondary line. + /// 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() + : "?"; + /// - /// Whether the password is currently on screen. Defaults to hidden and is deliberately not - /// persisted anywhere — revealing is a per-view action, never a stored preference. + /// 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. /// - [ObservableProperty] private bool _isPasswordVisible; + 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 PasswordDisplay => IsPasswordVisible - ? Item.Login?.Password ?? string.Empty - : new string('•', Math.Min(Item.Login?.Password?.Length ?? 0, 24)); + public string CardCodeDisplay => + IsSecretVisible ? Item.Card?.Code ?? string.Empty : Mask(Item.Card?.Code, 4); - partial void OnIsPasswordVisibleChanged(bool value) => OnPropertyChanged(nameof(PasswordDisplay)); + public string SshPrivateKeyDisplay => + IsSecretVisible ? Item.SshKey?.PrivateKey ?? string.Empty : "•••• private key hidden ••••"; [RelayCommand] - private void TogglePassword() => IsPasswordVisible = !IsPasswordVisible; + private void ToggleSecrets() => IsSecretVisible = !IsSecretVisible; } diff --git a/src/Presentation/Desktop/ViewModels/VaultViewModel.cs b/src/Presentation/Desktop/ViewModels/VaultViewModel.cs index 5849ec822f8961dc045989049235cbea3106b110..bc5a5145016a975c1b7d4ee9043383d7a5115c9d 100644 GIT binary patch literal 12222 zcmd^F-EJGl6~5O~jEN$p5P99)`bUH;+X+$IG9)>WL4F69vv20W+!!8(yydWEW{2y;?3Q*L+drHt&nGeeF13 zG$6N=$8%dP;)ykDVteksPwl0s2mI|kc<{hHuA2EgE?4`fGi#RQ%AA$OWo65UW-0=k zy0q35xv8-tv(tLdq|ke1QhhW}lO(fyCb4y#W`1Frp3my&@dHnOXVIKN@usqIW)riB z%Q}rSGs)trGNO%^wc(Dz&sZKFW+^nFjO}%vpV+E`2|-SN>sM+ZX8^~2<#tVq{ACft zekN@{?wW_DJ@si^*_~@S!m(WoJ@W?nS;vf%^`W>w75X_D4G&s_0f9|_T=_*xa%CkS?TBuc3#G3nGIR# zv7Yi8Non8s6P=(p-J>-?wvwQHt*7oA6?ct>2>vc?hYu`lS+71AuR>}Y5>!u{YNdMm zK8&Qi-0!AMbK#Pg0y+StfUz@cEapVB>N!+c;iz z^t!0i>B{Tpa2Dt1HbJlq8Jfj(_#(|}TOwsn%5=d>vAg@ByA#gw{K`(6+HUSWwj`ov zX0DF&WOq+u9)zDkn0oC?PkM{tdafM2sG}8q*!MwIxY-cGJlQuVPMf-K6upV-nHi1D z;DA|(K{V(S6PFny=&R>B-8vx^9|N|G#{RkA=JNWoNE36E_s?_M8g@ll##v)`NtKm( zrCf)wS|>-VdNNMXPg6jN`}OCa@XusO|IAnV=l{e1n(AF@imO#VkwSq|7AFV%bGR!m zb}j$2*o-ANAZ(D)t%e&PKW75KQx>c6v@Tce#z2DWo@VYe&tf(p9a@TBzlW7@f<)fyD{cp|1{>hEnPUf7(s^3FsE7Z}|LbVkLVSd!=^f(!Qa*VeM7)3P>tG?-)H0oTfQ!b(^tUpP9y4 zwXuII-mr|t$|E#`9O>k}oTZsH7uGHqsRW!p|9ddHV8-$fH}Hll`*HB~V>#CBOZ6~5!BBZVX>xQ0HA(krM+cl-2zd%K~ViBTu z!pEGsTbq&`S_{^~S8=`?PMI;Md@?=A6P7BQCvjd^!`)zO+4LeUtNIAlY)WwNH>zx? z&K+4@JZ!U=@IlbqwiRX;Lzn`R0L7~}nptW~Cg_#<^{1Z!kE)thgwO(d`5A?Tpqj`X zaJjTC-NARjlUaQB09E;FUR({h4c(6?eK{*CD`k{4&}aZU$EHH}Fe8?h&=!A5=jiWH zrle`9iuM5d(%Ug%HsBDP0mvRH3NqjjG&DKfL%GdS`sX%Di8*s%whCn98saYbx96vr z^0c%x#bq9~hjiK<>fYlnDTZ6}kL(M+=WHGwW&kg32_zwU<%D^G>8DKfVK5Yu<5MJWHj)K?^uM=hpM+uXeR(+-J!~@TrbhlbNz8gcm?y zBY0eo6O7xUfTQ|Ic4AcrLUW?1AN#Ls&zrfo@x)7oxRv6 zcn#EoiAj_ER`z@j7o<5=e#T>kaMs5|l_%m!YgI~fiZ0V)V4gey>k<}e&Z^y$TglTf zBR1Xi`}I2RnOZQlt^@WSF*dNG?*x^FYya{H?H@lrZbee2-1D!$4qD(beNFI*JJb;b zMFW3zYIrA4KcGuT<>bPf2iI%g-FUo8TKs7kBeV*m*}5K9CUTJ?g1eR~Z8?X{1qfh*Fr~ zBLjFi-ri2`N*wM+2T3v{$t`DxD(+7RUa-N0Oa1+o_d+gt;sPxiV-Vt3@wd{9o~0!U zl;jKusTqp^N%Y^TK@zPXvIoZvggU3wKn(^B(s0~PsGHdOwCDn!z(!LVIgAQK% z1V7j_gMa_>i)Ui+IX70XK)UV6j}D)@n(C{_vADTxT)l0@Cl>FoxPjJM{r3_fMdM&j z9mB&2k(G~}ww-CK}~NP%3}QDGDinD+!AvFNn;4(y!R zdbqt>bex|jcooZUSEsa`Cl%7F!k%Fo;&HvUcj$G3Nz$PUm2veNCO9g8n4#N0;iMRr zB4+EP!oFE3G9i)3%$3Ng>AYLyKgD{_23(j*Q47+LbibIU6-M)$rGJCck3Smd;$?B3 z<|ApSICw$c^m@88WoB6EU4mzlQ~uzLGd?c`-nB{ zhh%EhYl@vu_J-%86Fzxi(X}D~Ib33ESZYusxF=1?l9;VbhS4u3ZfYYdg}7kUs$D+Xl-zG5aa2YhJil{BC3b_4en@Og7qT=|hc^B55N zGBhuv`h2~+MG_B3@7U6AM@q$$PW4l) z2cMx3-GO9ivPB$nc>BPr7jEb5q!@4N>Dn*2Xrog!>F`uLNM4x;FV!DvPOND09fo1?%H2wQkg(X2 z4PM(z#V{_y*Rla!rGLHH`i!4ywE+h^9Z0x4?PBEwT|2WcQYLIh+$P4uH+~eg2eWbB ztT2#000x+g4Ct&3R%CK$8yR1qlx=3hYfQ2ofW|d8=aS||o4Aqu(uwxW_OK%1Yn3BD*Sfz zMt*mKgq4tL3ulEDNu-AUy zee=zY^Q&RCYeH*08uaLKfVQ392=gw?$yV1a}UE@!ioK&%UP~>uf#r98V=nVIbp}_^sJa z6|v3N*YaJjPk9Bw0J23rX^t0R;#RanT#i9#V0R7=ftc&!k)N2sS~2>#3GXsk$-`VF6mc=Sa*3$&1Fx^X zGWXhtoltB2G{{2F^;eki%fYqV=Yu7!@;{^H>RnQ;Y%SMQ3PhkdxGfHX^ab#dRBolbo>FIG{NQ~mrJr4KDGU#aT4?C29ddrz15)9;2s z=u)cS4H&3JJk)QspzFURRDAgtL%|I@-ozkr4A8j(mDVf2)OxB8M^m*TvzntMd^PWJU z9CrV2zG&e)+Id<{{!mTq9beh{Y_BWV@p8$ov7yb-S|5$mmU_zZ90;1YeNj^GXDxx Xhg?{q?@OZ%%$FE$`G~*Sx!L&-n)07r delta 463 zcmdlN|3-5{*2di`jGCTdiKRIu&N-Q>c_j*EAhwPIL@YS9xHvOEPoWq>Pj+Xrnykno zJ(-=PzmRuh_R&)LRWmRO{aHu)51mbg=CW=@J*eojhiQAkm0 zszw@^Sv=W|Yb9ga + + + +