Files
mopac-pmo/archive/REPORT-20260828-2330-bitwarden-go.md

173 lines
8.3 KiB
Markdown

# REPORT — mopac-bitwarden-go v0: Bitwarden Secrets Manager REST client (fake-server tested)
- Date: 2026-08-28 23:30 (work session 2026-08-28 late)
- Repo: https://git.knownelement.com/ukrrs/mopac-bitwarden-go (main, pushed:
`643de0f..9b730af`, 6 commits after the spec seed)
- Local: ~/projects/meta/MOPAC/bitwarden-go
- Status: v0 COMPLETE and green. Stdlib-only Go (zero third-party
modules), ALL dev in Docker (digest-pinned golang:1.26-bookworm, same
image as keyproxy), built and tested entirely against a fake Secrets
Manager — no live vault access exists on this account yet; live creds
attach later with zero code change (config only).
## 1. What was built
A plain-REST Bitwarden Secrets Manager client for machine accounts +
thin CLI. No official SDK imported (AGPL-incompatible); the wire protocol
was reconstructed from Bitwarden's public API behavior and pinned to
published SDK test vectors.
Wire protocol implemented (all verified against the fake server):
| Step | Wire |
|---|---|
| login | POST /identity/connect/token — grant_type=client_credentials, scope=api.secrets, machine client_id/client_secret |
| org key | response's `encrypted_payload`: type-2 EncString sealed for the credential's embedded 16-byte key (HKDF-SHA256, salt `bitwarden-accesstoken`, info `sm-access-token`); unwraps to the organization key |
| refresh | grant_type=refresh_token, fired automatically 30s before expiry (memory-only) |
| secrets | GET /api/accounts/{id}/secrets, GET /api/secrets/{id}, GET /api/projects/{id}/secrets; names/values are EncStrings decrypted in memory with the org key |
| projects | GET /api/accounts/{id}/projects |
Machine credential format handled: `0.<uuid>.<secret>:<b64-16B-key>` as
printed by Secrets Manager (uuid -> client_id; secret -> client_secret;
key -> payload-unwrap derivation). Split BW_CLIENTID/BW_CLIENTSECRET also
accepted (plaintext-mode servers only).
## 2. Library surface (the exact keyproxy integration contract)
```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") // secret NAME or uuid
secrets, err := bw.ListSecrets(ctx, tok) // metadata only, no values
projects, err := bw.ListProjects(ctx, tok)
```
- Errors are `errors.Is`-friendly sentinels: ErrAuthFailed,
ErrTokenExpired, ErrSecretNotFound, ErrMalformedResponse, ErrDecrypt,
ErrServer, ErrUnreachable, ErrInvalidCredentials. Error strings carry
fixed reason enums + uuids/names ONLY — never values, tokens,
ciphertext, or response bodies.
- Token: memory-only, auto-refresh-before-expiry, `Zero()` wipes key
material, `String()` is log-safe (expiry + ids only).
- Decryption fails closed: MAC verified before decrypt; tampering is a
typed error, never passthrough of ciphertext.
## 3. CLI usage
```
bitwarden-go login # credential check; prints summary only
bitwarden-go projects # "<id> <name>" per line
bitwarden-go secrets list # "<id> <name>" per line
bitwarden-go get <name-or-uuid> # bare value to stdout, no newline
```
Config: BW_* env vars or 0600 env file ($BITWARDENGO_CONFIG, default
~/.config/bitwarden-go/env). NEVER flags/args. Keys: BW_SERVER_URL
(default https://vault.bitwarden.com), BW_ACCESS_TOKEN (full credential)
or BW_CLIENTID+BW_CLIENTSECRET. Exit codes 0/1/2 mirror keyproxy
(ok / usage-config / auth-or-resolution).
## 4. Fake-server test results
`./dev.sh check` (in the Docker builder): build + vet + gofmt clean,
**53 tests pass, 0 fail** across 3 packages (38 top-level + 15 subtests).
Coverage areas:
- Crypto pinned to published SDK vectors: credential parsing, HKDF
shareable-key derivation (3 vectors), type-2 EncString AE vector,
round-trips, tamper detection (MAC + ciphertext).
- Auth table vs fake server: happy path, wrong secret, server 500,
unreachable, malformed token body, split-creds-on-encrypted-server
(fails loudly), plaintext server mode.
- Token lifecycle: refresh-before-expiry (short-TTL), refresh rejection
surfacing, expiry without refresh token, omitted-value list fallback to
by-id fetch.
- Reads: by name, by uuid, lists with decrypted names, missing secret
(name + uuid), malformed list body, tampered value MAC, rejected
bearer.
- Config: 0600 enforcement (0644/0666 refused BEFORE read; 0400 passes),
env-over-file precedence, pure-Go KEY=VALUE discipline, missing-file
behavior, no-credentials error.
- CLI (end-to-end via real 0600 env file): login/get/lists/failure
paths/usage.
- Redaction sweeps: every error path and every CLI run asserted free of
client secret, credential key, access/refresh tokens, secret values;
stdout carries material only in `get` (pinned to the exact value).
Smoke (`./dev.sh smoke`): fake Secrets Manager booted in a container on
127.0.0.1:8600 (full crypto chain, random org key per boot); real binary
driven from host: login/projects/secrets list/get by name/get by
uuid/missing-secret/bad-credential + redaction sweep. Result: `smoke: OK`.
Exact container IDs cleaned up; no broad kills.
## 5. Open questions from PORTING-NOTES-secrets.md §3
Answered by code (v0 decision, reversible):
1. Substrate: direct REST in Go, zero Node/bw. The client implements the
machine-account flow including client-side decryption, so shelling to
`bw` is unnecessary for the harness's read needs.
3. Ref syntax (read side): `bw:<name>` maps to GetSecret(name) = Secrets
Manager secret by NAME (uuid also accepted). `#field`/`#totp` are
vault-item concepts; Secrets Manager secrets have no fields — code
answer: not needed for the SM path.
4. Session lifetime: memory-only for the process lifetime; a long-lived
keyproxy keeps its Token and auto-refreshes before expiry (30s skew).
No disk persistence exists in the library at all.
Still needing Charles:
2. Machine account: dedicated service account for keyproxy/harness (not
coo@turnsys.com), with access scoped to a harness project; the vault
side must exist before phase 3 flips on.
5. Secret inventory: exact secret names/uuids for Redmine + LiteLLM keys
(current fake names redmine-api-key/litellm-key are placeholders).
6. Scope: strictly the SM read path consumed via keyproxy, or also absorb
KNELCredsManager's ~/.creds/*.env contract.
7. Subprocess injection: does anything need secrets as child-process env
(LiteLLM), or HTTP-header-only as today?
+ NEW — module wiring: keyproxy must consume this module from the private
gitea; recommend `go mod vendor` (matches "one auditable vendored
module tree") or GOPRIVATE=git.knownelement.com. Founder's call.
+ NEW — live verification checklist (first day with real creds): run
login/get against Vaultwarden pwvault.turnsys.com and against Bitwarden
cloud to confirm the accounts-path list endpoint and encrypted_payload
shapes match the fake exactly (protocol reconstructed from public
behavior + SDK vectors; end-to-end vs a live server is the one thing
tests cannot prove).
## 6. Exact keyproxy integration point
`keyproxy/internal/backend/bitwarden.go` (currently the 501 stub,
`Resolve` at bitwarden.go:20). Phase 3 replacement sketch:
```go
// keyproxy: internal/backend/bitwarden.go
func (b *Bitwarden) Resolve(ctx context.Context, ref Ref) (string, error) {
cfg, err := config.Load(ref.Source) // ref.Source = 0600 env file path (BW_* keys)
if err != nil { return "", Err(ref, b.Name(), ReasonUnreadableSource) }
tok, err := bw.Authenticate(ctx, bw.Credentials{
BaseURL: cfg.ServerURL, AccessToken: cfg.AccessToken,
ClientID: cfg.ClientID, ClientSecret: cfg.ClientSecret,
})
if err != nil { return "", Err(ref, b.Name(), ReasonUnreadableSource) } // auth -> typed reason
defer tok.Zero()
return bw.GetSecret(ctx, tok, ref.Key) // ref.Key = secret name or uuid
}
```
keyproxy.toml ref shape (existing stub semantics): backend `bitwarden`,
source = path to the 0600 env file, key = secret name/uuid. The library's
typed errors map 1:1 onto keyproxy's Reason enums. Wiring requires adding
the module dependency (see open question: vendor vs GOPRIVATE).
## 7. Reproduce
```
cd ~/projects/meta/MOPAC/bitwarden-go
./dev.sh check # build + vet + test (Docker builder)
./dev.sh smoke # containerized fake server + real CLI end-to-end
```