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

8.3 KiB

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)

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.
  2. 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.
  3. 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:

  1. 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.
  2. Secret inventory: exact secret names/uuids for Redmine + LiteLLM keys (current fake names redmine-api-key/litellm-key are placeholders).
  3. Scope: strictly the SM read path consumed via keyproxy, or also absorb KNELCredsManager's ~/.creds/*.env contract.
  4. 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:

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