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
246 lines
10 KiB
Markdown
246 lines
10 KiB
Markdown
# 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):
|
|
|
|
```sh
|
|
./dev.sh check # = go build + go vet + go test, all inside the builder
|
|
```
|
|
|
|
The equivalent raw command:
|
|
|
|
```sh
|
|
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):
|
|
|
|
```text
|
|
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):
|
|
|
|
```sh
|
|
./dev.sh smoke
|
|
```
|
|
|
|
Expected output (tail):
|
|
|
|
```text
|
|
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
|
|
|
|
```sh
|
|
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
|
|
|
|
```sh
|
|
./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:
|
|
|
|
```sh
|
|
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:
|
|
|
|
```text
|
|
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:
|
|
|
|
```sh
|
|
./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
|
|
|
|
```mermaid
|
|
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
|
|
|
|
- [MOPAC harness DESIGN.md — Key proxy: placeholders only, material never leaves the vault](https://git.knownelement.com/ukrrs/MOPAC/src/branch/main/DESIGN.md#user-content-key-proxy-placeholders-only-material-never-leaves-the-vault-charles-2026-08-28-evening)
|
|
- [MOPAC harness DESIGN.md — Tooling = standalone public FLOSS repos](https://git.knownelement.com/ukrrs/MOPAC/src/branch/main/DESIGN.md#user-content-tooling--standalone-public-floss-repos-loosely-coupled-charles-2026-08-28-evening)
|
|
- [Exposure-minimization protocol (crush porting notes)](https://git.knownelement.com/ukrrs/MOPAC/src/branch/main/docs/PORTING-NOTES-crush.md)
|
|
- Sibling: [mopac-bitwarden-go](https://git.knownelement.com/ukrrs/mopac-bitwarden-go) — how material gets INTO the vault
|
|
- Parent: [ukrrs/MOPAC](https://git.knownelement.com/ukrrs/MOPAC) — the harness this serves
|
|
|
|
## License
|
|
|
|
AGPLv3 — see [LICENSE](LICENSE). (MIT fallback only if a license conflict
|
|
still arises; none known today.)
|