# mopac-bitwarden-go A 100% Go client for the Bitwarden Secrets Manager REST API, replacing the Node `bw` CLI in supply-chain-sensitive (CMMC L3/TS posture) environments. One static binary, zero third-party modules (stdlib only), no Node runtime, no official SDK (source-available, AGPL-incompatible). Status: 2026-08-28 — v0 complete and green: machine-account auth (client_credentials, `encrypted_payload` organization-key unwrap), refresh-before-expiry, secret/project reads with full client-side decryption (type-2 EncStrings), 0600 env-file config, thin CLI. Built and tested entirely against a fake Secrets Manager (no live vault access on this account yet); live credentials attach with zero code change. ## What it implements The wire protocol, in plain REST with stdlib: | Step | Wire | |---|---| | login | `POST /identity/connect/token` — `grant_type=client_credentials`, `scope=api.secrets`, machine client_id/secret. Response carries a JWT access token plus `encrypted_payload` | | org key | `encrypted_payload` is a type-2 EncString sealed for the credential's embedded 16-byte key (HKDF-SHA256, salt `bitwarden-accesstoken`, info `sm-access-token`); it unwraps to the organization key | | refresh | `POST /identity/connect/token` — `grant_type=refresh_token`, fired automatically 30 s before expiry | | secrets | `GET /api/accounts/{id}/secrets`, `GET /api/secrets/{id}`, `GET /api/projects/{id}/secrets` — names/values arrive as EncStrings, decrypted in memory with the org key | | projects | `GET /api/accounts/{id}/projects` | The crypto construction is pinned to the official clients by published SDK test vectors (key derivation, EncString AE, credential parsing) — see `encstring_test.go`. ## Library surface (what keyproxy calls) ```go import bw "git.knownelement.com/ukrrs/mopac-bitwarden-go" tok, err := bw.Authenticate(ctx, bw.Credentials{BaseURL: url, AccessToken: cred}) defer tok.Zero() value, err := bw.GetSecret(ctx, tok, "redmine-api-key") // name or uuid secrets, err := bw.ListSecrets(ctx, tok) // metadata only projects, err := bw.ListProjects(ctx, tok) ``` Tokens and keys are memory-only; `Token.String()` renders expiry and ids, never material; error strings carry fixed reason enums plus uuids/names, never values. Errors classify as `ErrAuthFailed`, `ErrTokenExpired`, `ErrSecretNotFound`, `ErrMalformedResponse`, `ErrDecrypt`, `ErrServer`, `ErrUnreachable`, `ErrInvalidCredentials` (`errors.Is` friendly). ## Quickstart (verified 2026-08-28, all against the fake server) All dev work happens inside a Docker builder (host stays toolchain-free). ```sh ./dev.sh check # = go build + go vet + go test, inside golang:1.26-bookworm ``` Expected output (tail): ```text ok git.knownelement.com/ukrrs/mopac-bitwarden-go ok git.knownelement.com/ukrrs/mopac-bitwarden-go/internal/cli ok git.knownelement.com/ukrrs/mopac-bitwarden-go/internal/config ``` End-to-end smoke — builds the CLI, boots the fake Secrets Manager in a container on `127.0.0.1:8600`, drives the real binary from the host through a 0600 env file (login / projects / secrets list / get by name and uuid / failure paths / redaction sweep): ```sh ./dev.sh smoke ``` Expected output (tail): ```text --- redaction: no material in any captured output smoke: OK ``` ### Configure Credentials NEVER arrive via flags or arguments: ```sh mkdir -p ~/.config/bitwarden-go && umask 077 cp env.example ~/.config/bitwarden-go/env $EDITOR ~/.config/bitwarden-go/env # fill BW_ACCESS_TOKEN etc. ``` ### Use ```sh ./bin/bitwarden-go login # credential check, prints summary only ./bin/bitwarden-go projects # " " per line ./bin/bitwarden-go secrets list # " " per line ./bin/bitwarden-go get smoke-redmine-key # bare value, no newline ($(...) plumbing) ``` `get` accepts a secret NAME or a secret UUID. Exit codes: 0 ok, 1 usage/config, 2 auth or resolution failure. ## Config reference | Key | Where | Meaning | |---|---|---| | `BW_SERVER_URL` | env or env file | server root (`/identity/connect/token`, `/api/...`); default `https://vault.bitwarden.com`; self-hosted Vaultwarden sets this | | `BW_ACCESS_TOKEN` | env or env file | full machine credential `0..:` as printed by Secrets Manager; unlocks full decryption | | `BW_CLIENTID` / `BW_CLIENTSECRET` | env or env file | split form; works only against servers returning unencrypted payloads (test doubles) | | `-config PATH` / `$BITWARDENGO_CONFIG` | CLI | env-file location; default `~/.config/bitwarden-go/env` | Env-file discipline (same as mopac-keyproxy): `KEY=VALUE` parsed in pure Go — never sourced or exec'd; mode MUST be 0600 or stricter, checked BEFORE the file is read; process env wins over the file; `*.env` files are gitignored anywhere in this repo. Any `*.env` landing in the repo is a tripwire. ## Security rules (enforced by tests) - Tokens, keys and values are memory-only: no disk cache, no persistence, no admin UI, nothing written to disk — ever. - Errors carry fixed reason enums plus non-secret identifiers (uuids, secret names); response bodies, credentials, tokens and ciphertext are never embedded. Transport errors are scrubbed of URLs and peer text. - `get` prints the value to stdout bare — that is the one place material appears; stderr and every other command stay material-free (asserted in tests and smoke). - Type-2 EncStrings fail closed: MAC verified before decryption; tampered MAC/ciphertext is a typed `ErrDecrypt`, never passthrough. - JWT signatures are not verified locally (same posture as the official clients; the endpoint is reached over TLS). ## Architecture ```mermaid flowchart LR C["consumer
(keyproxy bitwarden backend, CLI)"] --> L["library: Authenticate / GetSecret
stdlib-only REST + decrypt"] L -->|"POST /identity/connect/token
client_credentials"| I["Bitwarden / Vaultwarden
/identity"] L -->|"GET /api/accounts/{id}/secrets ...
bearer"| A["Bitwarden / Vaultwarden
/api"] L -.-> F["fakesm (tests + smoke only)
same wire protocol + crypto"] ``` - `bitwarden.go` — public surface: `Authenticate`, `GetSecret`, `ListSecrets`, `ListProjects`, error sentinels. - `token.go`, `api.go` — token lifecycle (refresh-before-expiry) and REST transport. - `cred.go`, `encstring.go` — credential parsing/key derivation and EncString crypto (AES-256-CBC + HMAC-SHA256, PKCS#7). - `internal/config` — 0600 env-file + env resolution. - `internal/cli`, `cmd/bitwarden-go` — the thin CLI. - `internal/fakesm`, `smoke/` — the fake Secrets Manager (tests run in-process; smoke boots it in a container). ## Non-goals - No writes: v0 is the read path keyproxy needs (create/update/delete, human auth flows: later). - Not a vault server; Bitwarden/Vaultwarden stays the store of record. - No persisted sessions (memory-only by design; see open question on session lifetime in the porting notes). - Not org-specific: hosts and credentials come from config, never baked in. ## Design references - [Porting notes: KNELSecretsManager](https://git.knownelement.com/ukrrs/MOPAC/src/branch/main/docs/PORTING-NOTES-secrets.md) - [ukrrs/mopac-keyproxy](https://git.knownelement.com/ukrrs/mopac-keyproxy) — the `bitwarden:` backend that consumes this library (phase 3) - Bitwarden public API behavior (identity connect/token, machine accounts, `/api/accounts/{id}/secrets`) — reimplemented from protocol observation; the official SDK is NOT imported. ## License AGPLv3 — see [LICENSE](LICENSE).