Files
mopac-keyproxy/README.md
T
mrcharles 47343d53a2 Add Docker-routed dev tooling, smoke test, example config, and docs
Route every compile/vet/test path through a digest-pinned golang:1.26
builder container (dev.sh + Makefile) so the host never runs a Go
toolchain. Add an end-to-end smoke that serves keyproxy in a container
with a throwaway config and drives 401/200/404/400/501/405 paths with
python urllib, asserting the server log is redacted. Ship a commented
keyproxy.toml.example (the real config stays gitignored along with any
*.env tripwire) and rewrite the README as the full quickstart, config,
HTTP, CLI, and redaction reference.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-08-28 22:30:50 -05:00

10 KiB

mopac-keyproxy

A key-material proxy where the vault stays authoritative. Consumers receive opaque placeholder refs (mpk-<name>); real secrets live only in the configured backends (v0: 0600 env files and process env; phase 3: Bitwarden Secrets Manager and HashiCorp Vault) and are resolved to real material at the wire, in memory, never persisted, never logged, with no admin UI to attack.

Status: 2026-08-28 — v0 LIVE: keyproxy serve (localhost HTTP resolve hop)

  • keyproxy get (exec-style CLI) on the file and env backends; bitwarden/vault ship as explicit not-implemented stubs behind the same interface (phase 3 drop-in).

Quickstart

All dev work happens inside a Docker builder (host stays toolchain-free); docker pull of the builder is pre-authorized. Commands below were verified on 2026-08-28 from a fresh clone.

Build and test

dev.sh routes every compile/vet/test path through the digest-pinned builder container (or use make build|vet|test|check, same routing):

./dev.sh check   # = go build + go vet + go test, all inside the builder

The equivalent raw command:

docker run --rm -v "$PWD:/h" -w /h \
  -u "$(id -u):$(id -g)" -e HOME=/tmp -e GOFLAGS=-buildvcs=false \
  golang@sha256:e8c859f5632dcfde7b32d2012b4351728f6437930887c2f6a91ea242459e5514 \
  sh -c 'go build -o bin/keyproxy ./cmd/keyproxy && go vet ./... && go test ./...'

(that digest = golang:1.26-bookworm.)

Expected output (tail):

ok  	git.knownelement.com/ukrrs/mopac-keyproxy/internal/backend
ok  	git.knownelement.com/ukrrs/mopac-keyproxy/internal/config
ok  	git.knownelement.com/ukrrs/mopac-keyproxy/internal/server

End-to-end smoke (builds, serves in a container on port 8082 with a throwaway config, drives 401/200/404/400/501/502 paths with python urllib, and asserts the server log is redacted):

./dev.sh smoke

Expected output (tail):

probe: OK
--- server log (redaction check) ---
... keyproxy: listening addr=:8082 refs=5 backends=bitwarden,env,file,vault auth=mpk-keyproxy-self=***
... keyproxy: resolve ref=mpk-smoke=*** backend=file status=200

Configure

mkdir -p ~/.config/keyproxy && umask 077
printf 'KEYPROXY_TOKEN=%s\n' "$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')" \
  > ~/.config/keyproxy/keyproxy.env
printf 'EXAMPLE_API_KEY=%s\n' "$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')" \
  > ~/.config/keyproxy/example.env
cp keyproxy.toml.example keyproxy.toml

keyproxy.toml is gitignored (as is any *.env landing in the repo). It holds the ref MAP — paths and key names — never material itself.

Serve and resolve

./bin/keyproxy serve          # binds 127.0.0.1:8082 (config: listen)

Resolve a ref over the HTTP hop (python3 urllib; curl-free hosts work fine). The token is read from the 0600 env file, and the resolved value is shown only as a length + mask — copy-paste safe:

KEYPROXY_TOKEN="$(sed -n 's/^KEYPROXY_TOKEN=//p' ~/.config/keyproxy/keyproxy.env)" \
python3 - <<'PY'
import json, os, urllib.request, urllib.error

req = urllib.request.Request(
    "http://127.0.0.1:8082/v1/resolve",
    data=json.dumps({"ref": "mpk-example"}).encode(), method="POST")
req.add_header("Authorization", "Bearer " + os.environ["KEYPROXY_TOKEN"])
try:
    with urllib.request.urlopen(req, timeout=5) as r:
        v = json.loads(r.read())["value"]
        print(f"200: mpk-example={len(v)} chars, mpk-example=***")
except urllib.error.HTTPError as e:
    print(e.code, e.read().decode().strip())
PY

Expected output:

200: mpk-example=64 chars, mpk-example=***

Without (or with a wrong) bearer token the response is 401 {"error":"unauthorized"} and the server logs only auth failure remote=... — never the presented token.

Exec-style plumbing, no HTTP hop:

./bin/keyproxy get mpk-example > /tmp/.k && wc -c < /tmp/.k   # bare value, no newline

Config reference (keyproxy.toml)

Key Scope Meaning
listen top level bind address (default 127.0.0.1:8082; keep it loopback unless you know why not)
auth.token_ref [auth] ref holding the bearer token for /v1/resolve; MUST be a file-backend ref, resolved once at startup, never logged
refs."mpk-<name>".backend ref file, env, bitwarden (stub), vault (stub)
refs."mpk-<name>".source ref file: path to a 0600 env file (~/ expanded); env: variable name; bitwarden/vault: reserved mount/project path
refs."mpk-<name>".key ref file: KEY inside the env file (required); env: forbidden (source IS the name); stubs: informational
refs."mpk-<name>".mode ref file only: allowed permission mask (default 0600; stricter files like 0400 pass, looser files are refused before being read)

Ref names must match mpk-[a-z0-9][a-z0-9-]*. Because the pattern is enforced before any echo, refs can be named safely in logs and errors: an mpk- ref is a placeholder, by construction not material.

Env files are KEY=VALUE lines parsed in Go — NEVER sourced or exec'd (no shell expansion, no interpolation; comments, blank lines, export prefix and surrounding quotes are handled; later duplicate keys win).

HTTP reference (keyproxy serve)

Route Auth Behavior
POST /v1/resolve bearer token body {"ref":"mpk-<name>"}{"value":"..."}; material crosses the wire here and nowhere else
GET /healthz none liveness

Status codes: 200 resolved; 401 missing/wrong token (constant-time compare); 400 malformed body or non-mpk- ref (an invalid "ref" field is never echoed — it might hold pasted material); 404 unknown ref; 405 wrong method; 413 body over 4 KiB; 500 recovered panic (detail suppressed); 501 backend not implemented in v0; 502 backend failure. Failure bodies carry the ref, the backend and a fixed reason enum (missing_key, insecure_source_mode, unreadable_source, empty_value, malformed_source, not_implemented) — reason enums cannot embed material by construction.

CLI reference

Command Purpose
keyproxy serve run the resolve HTTP hop until SIGINT/SIGTERM
keyproxy get REF resolve one ref to stdout (bare value, no trailing newline — for $(...) plumbing)
keyproxy help print usage

Flags: -config PATH (default $KEYPROXY_CONFIG, then ./keyproxy.toml), -listen ADDR (serve only, overrides config).

Exit codes: 0 ok; 1 usage/config error; 2 resolution failure.

Redaction rules (enforced by tests)

  • No admin UI, no persistence, no cache files: material exists in memory per resolve and is dropped.
  • Every log line that names a ref masks any value as <ref>=***.
  • No material in logs, error bodies, or crash paths: panics are recovered with the panic value discarded (never stringified); auth failures log the remote address only; parse errors carry line numbers, never line contents.
  • Resolution failures name the ref and the backend, never the value.
  • 0600 enforced on secret env files (looser modes are refused before the file is read).

Architecture

flowchart LR
    C["consumer<br/>(harness, CLI, container)<br/>holds mpk-... refs only"] -->|"POST /v1/resolve<br/>bearer token"| S["keyproxy serve<br/>auth -> ref lookup -> backend"]
    S --> F["file backend<br/>0600 env files, parsed in Go<br/>(never sourced)"]
    S --> E["env backend<br/>process env indirection"]
    S --> B["bitwarden stub<br/>501 not_implemented"]
    S --> V["vault stub<br/>501 not_implemented"]
    S -->|"200 {value} — memory only"| C

All backends sit behind one interface (internal/backend.Backend); phase-3 Bitwarden Secrets Manager (plain REST, machine accounts) and Vault KV v2 + AppRole connectors replace the stubs in that one place.

Scope

  • Placeholders in, real keys on the wire out: consumers hold mpk-... opaque refs only. A leaked placeholder is revoke-and-remap, not an incident — zero upstream exposure.
  • Backends: v0 file + env; planned Bitwarden Secrets Manager REST (machine accounts) and HashiCorp Vault KV v2 + AppRole (official Go api pkg is MPL-2.0, AGPL-compatible, vendored). Stdlib-first; NO official Bitwarden SDK (its source-available license is AGPL-incompatible).
  • Two shapes, one resolver: an HTTP hop for services, and a CLI (keyproxy get <ref>) for exec-style plumbing.
  • Memory-only material handling: fetch-on-demand, never persisted, never written to logs; ref redaction everywhere.
  • Config-driven: generic, no organizational hosts/paths/defaults baked into code.

Non-goals

  • No admin UI — there is no admin surface at all; configuration only.
  • Not a secrets manager: the vault stays authoritative; keyproxy never becomes a second place secrets live.
  • No persistence of key material to disk, cache files, or crash dumps.
  • Not org-specific: policy lives outside this repo (loose-coupling rules for the MOPAC tool family).

Today vs planned

State
Today v0 live: serve + get on file (0600 env files) and env backends; bearer auth bootstrapped from the file backend; table-driven tests incl. redaction + fake-HTTP end-to-end; docker-routed build discipline.
Planned Phase 3: Bitwarden Secrets Manager REST + Vault KV v2/AppRole connectors behind the same interface (stubs and tests already in place); short-TTL memory cache.

Design references

License

AGPLv3 — see LICENSE. (MIT fallback only if a license conflict still arises; none known today.)