Files
mrcharles 9b730afd12 Add end-to-end smoke and rewrite the README for v0
The smoke boots the fake Secrets Manager in a container and drives the
real binary from the host through a 0600 env file: login, listings, get
by name and uuid, failure paths, and a redaction sweep over every
captured output. README documents the implemented wire protocol, the
library surface keyproxy will call, the verified quickstart, and the
config table.
2026-08-29 00:08:38 -05:00

7.4 KiB

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/tokengrant_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/tokengrant_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)

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).

./dev.sh check   # = go build + go vet + go test, inside golang:1.26-bookworm

Expected output (tail):

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):

./dev.sh smoke

Expected output (tail):

--- redaction: no material in any captured output
smoke: OK

Configure

Credentials NEVER arrive via flags or arguments:

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

./bin/bitwarden-go login                    # credential check, prints summary only
./bin/bitwarden-go projects                 # "<id> <name>" per line
./bin/bitwarden-go secrets list             # "<id> <name>" 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 (<url>/identity/connect/token, <url>/api/...); default https://vault.bitwarden.com; self-hosted Vaultwarden sets this
BW_ACCESS_TOKEN env or env file full machine credential 0.<uuid>.<secret>:<key> 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

flowchart LR
    C["consumer<br/>(keyproxy bitwarden backend, CLI)"] --> L["library: Authenticate / GetSecret<br/>stdlib-only REST + decrypt"]
    L -->|"POST /identity/connect/token<br/>client_credentials"| I["Bitwarden / Vaultwarden<br/>/identity"]
    L -->|"GET /api/accounts/{id}/secrets ...<br/>bearer"| A["Bitwarden / Vaultwarden<br/>/api"]
    L -.-> F["fakesm (tests + smoke only)<br/>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
  • 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.