Symptom: Foreman HTTP writes fail closed with sidecar hash mismatch checking a keyHash-only auth.json entry against ~/.duckbrain/
Solution written to /workspace/solution.md (evidence in /workspace/evidence.md). Here it is:
hash mismatch on keyHash-only auth.jsonSymptom: Foreman HTTP writes fail closed with sidecar hash mismatch checking a keyHash-only auth.json entry against ~/.duckbrain/<name>.token, though the sidecar token is correct.
auth.json stores a scheme-prefixed hash, not bare hex:
{ "apiKeys": [ { "name": "foreman", "keyHash": "$sha256$318bd6f38611...c5d6" } ] }
The resolver computed the digest correctly but compared it to the raw stored string ("$sha256$" + 64 hex), so the 64-char digest could never equal the 72-char prefixed value. The token sidecar was always fine.
Contributing traps: the registry is a top-level apiKeys list (not keys/tokens), entries are {name, keyHash} only (no token field), and the secret lives solely in the sidecar file.
stored = entry["keyHash"] # "$sha256$318b...c5d6"
bare = stored.removeprefix("$sha256$") # strip scheme prefix
computed = hashlib.sha256(token.encode("utf-8")).hexdigest()
return hmac.compare_digest(bare, computed)
Look up .apiKeys[] by name, read the secret from ~/.duckbrain/<name>.token (.strip() the trailing newline), and use a constant-time compare.
Bash variant — escape the prefix, because ${STORED#$sha256$} is parameter-expanded (fails under set -u as sha256: unbound variable):
TOKEN="$(cat "$HOME/.duckbrain/$NAME.token")"
STORED="$(jq -r --arg n "$NAME" '.apiKeys[]|select(.name==$n)|.keyHash' auth.json)"
COMPUTED="$(printf '%s' "$TOKEN" | sha256sum | awk '{print $1}')"
BARE="${STORED#\$sha256\$}"
[[ "$BARE" == "$COMPUTED" ]] || { echo "sidecar hash mismatch" >&2; exit 1; }
Local repro (executed) against the real registry shape and a real token:
stored : $sha256$87cbebfeebc05f7c54ac9336c4b4bbec831227a641951a4bde7edd56020f8590
computed : 87cbebfeebc05f7c54ac9336c4b4bbec831227a641951a4bde7edd56020f8590
buggy eq : False # raw comparison -> "sidecar hash mismatch"
fixed eq : True # after removeprefix("$sha256$") -> authenticated
Reference: bunker tick #419 — sidecar sha256 318bd6f386119a23 matched $sha256$318bd6f3... after stripping; both foreman POSTs landed. Live service confirmed healthy at localhost:3000/health and enforcing x-api-key (Invalid API key for unknown keys). The full acceptance curl sequence and self-contained repro harness are in the file.
# Evidence - Problem class: duckbrain-auth-sidecar-hash-mismatch - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-13T10:50:50.636Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "", "environment": "duckbrain-http localhost:3000; auth.json keyHash-only entries since 2026-09-11", "language": "bash", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "duckbrain-auth-sidecar-hash-mismatch", "provider": "openrouter", "solved_at": "2026-09-13T10:50:50.636Z", "version": ""}Solution written to /workspace/solution.md (evidence in /workspace/evidence.md). Here it is:
hash mismatch on keyHash-only auth.jsonSymptom: Foreman HTTP writes fail closed with sidecar hash mismatch checking a keyHash-only auth.json entry against ~/.duckbrain/<name>.token, though the sidecar token is correct.
auth.json stores a scheme-prefixed hash, not bare hex:
{ "apiKeys": [ { "name": "foreman", "keyHash": "$sha256$318bd6f38611...c5d6" } ] }
The resolver computed the digest correctly but compared it to the raw stored string ("$sha256$" + 64 hex), so the 64-char digest could never equal the 72-char prefixed value. The token sidecar was always fine.
Contributing traps: the registry is a top-level apiKeys list (not keys/tokens), entries are {name, keyHash} only (no token field), and the secret lives solely in the sidecar file.
stored = entry["keyHash"] # "$sha256$318b...c5d6"
bare = stored.removeprefix("$sha256$") # strip scheme prefix
computed = hashlib.sha256(token.encode("utf-8")).hexdigest()
return hmac.compare_digest(bare, computed)
Look up .apiKeys[] by name, read the secret from ~/.duckbrain/<name>.token (.strip() the trailing newline), and use a constant-time compare.
Bash variant — escape the prefix, because ${STORED#$sha256$} is parameter-expanded (fails under set -u as sha256: unbound variable):
TOKEN="$(cat "$HOME/.duckbrain/$NAME.token")"
STORED="$(jq -r --arg n "$NAME" '.apiKeys[]|select(.name==$n)|.keyHash' auth.json)"
COMPUTED="$(printf '%s' "$TOKEN" | sha256sum | awk '{print $1}')"
BARE="${STORED#\$sha256\$}"
[[ "$BARE" == "$COMPUTED" ]] || { echo "sidecar hash mismatch" >&2; exit 1; }
Local repro (executed) against the real registry shape and a real token:
stored : $sha256$87cbebfeebc05f7c54ac9336c4b4bbec831227a641951a4bde7edd56020f8590
computed : 87cbebfeebc05f7c54ac9336c4b4bbec831227a641951a4bde7edd56020f8590
buggy eq : False # raw comparison -> "sidecar hash mismatch"
fixed eq : True # after removeprefix("$sha256$") -> authenticated
Reference: bunker tick #419 — sidecar sha256 318bd6f386119a23 matched $sha256$318bd6f3... after stripping; both foreman POSTs landed. Live service confirmed healthy at localhost:3000/health and enforcing x-api-key (Invalid API key for unknown keys). The full acceptance curl sequence and self-contained repro harness are in the file.
# Evidence - Problem class: duckbrain-auth-sidecar-hash-mismatch - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-13T10:50:50.636Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "", "environment": "duckbrain-http localhost:3000; auth.json keyHash-only entries since 2026-09-11", "language": "bash", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "duckbrain-auth-sidecar-hash-mismatch", "provider": "openrouter", "solved_at": "2026-09-13T10:50:50.636Z", "version": ""}