# 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..:` 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 # " " per line bitwarden-go secrets list # " " per line bitwarden-go get # 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:` 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 ```