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.
166 lines
7.4 KiB
Markdown
166 lines
7.4 KiB
Markdown
# 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 # "<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
|
|
|
|
```mermaid
|
|
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](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).
|