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