name: pqc-secrets description: Post-quantum cryptography secrets management system for protecting API keys, tokens, and private data. Includes the 10 browser_secrets_* MCP tools (betterbrowsermcp v0.7.0+ rotates v0.8.0+), the append-only audit log at ~/.config/pqc-secrets/audit.log, and the PQC Rust binary (ML-KEM-768 + AES-256-GCM).
PQC Secrets Management Agent Skill
Companion skill: pqc-signatures-security — ML-DSA-65 code signing, integrity verification, and secure coding patterns. Use both together for complete PQC coverage: this skill encrypts secrets at rest; pqc-signatures-security verifies code hasn't been tampered with.
New in v0.8.0 (betterbrowsermcp):
browser_secrets_rotatetool (data-key rotation, identity key stays),secrets_get mode=plain|redact,secrets_add dry_run. New module:src/audit.ts— append-only audit log at~/.config/pqc-secrets/audit.logmode 0o600 with SHA3-256 value fingerprints (first 16 hex chars, never the value). Bug fix:browser_secrets_addwas broken since v0.7.0 (dead execFile call removed). Seereferences/mcp-tool-surface.mdfor the full per-tool reference.
Availability-first design: these tools are designed to be called freely by agents with no human-in-the-loop gatekeeping. No auth tokens, no redaction default, no required
tabIdfor non-browser-context operations. The verification surface is the audit log +browser_secrets_rotate's auto-backup.
This skill provides comprehensive instructions, policies, and blueprints for managing repository and application secrets (API keys, credentials, private user data) using post-quantum cryptographic (PQC) algorithms.
AI agents using this skill are equipped to:
- Protect credentials at rest and in memory using FIPS-compliant algorithms.
- Integrate with the platform's native keychain and standard runtime environments.
- Validate environments and ensure zero plaintext credentials are written to disk.
1. Core Philosophy: Zero Plaintext on Disk
Traditional secrets management relies heavily on plaintext .env files or insecure environment variables. Under the PQC mandate:
- Always protect API keys and private data with FIPS 203 (ML-KEM-768) key encapsulation and AES-256-GCM symmetric encryption.
- Never store raw API keys, secrets, or tokens in git repositories, task files, logs, or local unencrypted files.
- Always load secrets on-demand directly into the shell environment from an encrypted bundle, and ensure they never persist to disk.
1.1 ⚠️ Common Agent Trap: Settings Files with env Blocks
Many CLI tools (Claude Code, VS Code, Docker, etc.) use JSON/YAML settings files with an env block. Agents will naturally want to put API keys directly in these files. This is a PQC violation.
NEVER do this:
{
"env": {
"ANTHROPIC_AUTH_TOKEN": "sk-ss-v1-abc123...",
"ANTHROPIC_API_KEY": "sk-ant-..."
}
}
env:
API_KEY: "sk-..."
{
"env": {
"OPENAI_API_KEY": "sk-proj-..."
}
}
Why this is wrong: The settings file is a regular file on disk. Anyone with file read access (backup tools, cloud sync, other processes, disk imaging) can extract the key. It will also be committed to git if the file is tracked, and it shows up in file search/grep results.
ALWAYS do this instead — inject at runtime from the OS keychain:
# Shell wrapper reads from Keychain and passes via env var
my-tool() {
local api_key
api_key="$(security find-generic-password -s "my-tool" -a "api-key" -w 2>/dev/null)"
if [[ -z "$api_key" ]]; then
echo "Error: API key not found in Keychain." >&2
return 1
fi
ANTHROPIC_AUTH_TOKEN="$api_key" command my-tool "$@"
}
The settings file keeps everything except the secret value:
{
"env": {
"ANTHROPIC_BASE_URL": "https://zenmux.ai/api/anthropic",
"ANTHROPIC_API_KEY": "",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "deepseek/deepseek-v4-flash"
}
}
The key exists only in two places: the OS keychain (encrypted at rest) and process memory (volatile). Never on disk in any config file.
2. Infrastructure Architecture
The PQC secrets system consists of a dual-implementation architecture to ensure maximum performance and cross-platform compatibility:
- Rust Native (Primary): The compiled binary at
bin/pqc-secrets(source insrc/pqc-secrets/) uses the NIST FIPS 203 compliantfips203crate. It implements a secure double-envelope structure:- ML-KEM-768 encapsulates a Key Encapsulating Key (KEK).
- The KEK wraps a Data Encryption Key (DEK) via AES-256-GCM keywrap.
- The DEK encrypts the secret payload via AES-256-GCM.
- Stores metadata including Additional Authenticated Data (
aad) insecrets.bundle.json.
- Python Fallback (Secondary): A script at
.agents/skills/pqc-secrets/scripts/pqc_secrets.pyuseskyber-pyand implements a single-envelope structure (ML-KEM-768 encapsulates the DEK directly).
[!WARNING] Format Incompatibility & Mismatch Errors: Because the Rust binary and Python script use different envelope structures, their serialized bundles are incompatible. Running the Rust binary on a Python-packed bundle results in:
Error: missing field aad at line X column YIf this occurs, you must perform a one-time migration:
# 1. Export plaintext secrets using the Python fallback SECRETS_TXT=$(uv run .agents/skills/pqc-secrets/scripts/pqc_secrets.py export) # 2. Generate a new keypair using the Rust binary ./bin/pqc-secrets keygen # 3. Pack the secrets back into the Rust-compatible bundle echo "$SECRETS_TXT" | ./bin/pqc-secrets pack
The local secrets infrastructure lives at ~/.config/pqc-secrets/:
System Keychain / File ~/.config/pqc-secrets/ ┌──────────────────────┐ ┌────────────────────────────┐ │ macOS Keychain, │ │ recipient.pub │ │ Linux Secret Service,│ │ ML-KEM-768 public key │ │ or Encrypted File │ │ (safe to commit) │ └──────────┬───────────┘ └────────────┬───────────────┘ │ │ │ decaps (ML-KEM-768) │ encaps ▼ ▼ ┌──────────────────────────────────────────────────────────────┐ │ secrets.bundle.json │ │ ┌─────────────────┐ ┌──────────────────────────────────┐ │ │ │ kem.ciphertext │ │ data.ciphertext (AES-256-GCM) │ │ │ │ (ML-KEM-768) │ │ 24+ API keys encrypted at rest │ │ │ └─────────────────┘ └──────────────┬───────────────────┘ │ └──────────────────────────────────────┼───────────────────────┘ │ decrypt ▼ ┌──────────────────────────────────────────────────────────────┐ │ Exported environment variables (never touch disk) │ │ ANTHROPIC_AUTH_TOKEN ZENMUX_API_KEY NEBIUS_API_KEY │ │ OPENROUTER_API_KEY WAFER_API_KEY ... (in-memory only) │ └──────────────────────────────────────────────────────────────┘
3. Cryptographic Standards
For all secrets operations, only NIST-approved post-quantum algorithms are permitted. Traditional classical algorithms are strictly forbidden for protecting key material:
| Use Case | Permitted Algorithms (FIPS) | Forbidden Algorithms |
|---|---|---|
| Key Encapsulation (KEM) | ML-KEM-768, ML-KEM-1024 | RSA, ECDH, ECDSA, Ed25519 |
| Symmetric Encryption | AES-256-GCM | AES-CBC, DES, 3DES, Blowfish, RC4 |
| Digital Signatures | ML-DSA-65/87, SLH-DSA-SHA2-128s | MD5, SHA-1, RSA, DSA |
4. Lifecycle Commands
Use the primary native Rust binary bin/pqc-secrets (or run the Python fallback via uv run .agents/skills/pqc-secrets/scripts/pqc_secrets.py):
| Step | Rust Command | Python Fallback Command | Description |
|---|---|---|---|
| Keygen | bin/pqc-secrets keygen |
uv run .agents/skills/pqc-secrets/scripts/pqc_secrets.py keygen |
Generates a new ML-KEM-768 keypair. Stores the private key in local encrypted file (system keychain if opted-in via PQC_USE_KEYCHAIN=true). Public key → ~/.config/pqc-secrets/recipient.pub. |
| Pack | bin/pqc-secrets pack |
uv run .agents/skills/pqc-secrets/scripts/pqc_secrets.py pack |
Reads KEY=VAL lines from stdin, encrypts via AES-256-GCM + ML-KEM-768, writes secrets.bundle.json. |
| Load | bin/pqc-secrets export |
uv run .agents/skills/pqc-secrets/scripts/pqc_secrets.py export |
Decrypts bundle in-memory, outputs export KEY=VALUE lines. Use secrets-load shell function. |
| Verify | bin/pqc-secrets verify |
uv run .agents/skills/pqc-secrets/scripts/pqc_secrets.py verify |
Verifies bundle can be decrypted, lists key names. |
| Rotate | bin/pqc-secrets keygen && bin/pqc-secrets pack |
keygen && pack |
Generates new keypair and re-packs secrets under new public key. |
| Rewrap | bin/pqc-secrets rewrap --new-pub <path> --out <path> |
— | Re-encrypts bundle under a different public key without exposing plaintext. |
| Migrate | bin/pqc-secrets migrate |
uv run ... pqc_secrets.py migrate |
Migrates keychain entry from old account name to new. |
Migration from Legacy default Account
If you have an existing keychain entry with account name default (from older versions), migrate to pqc-secrets-key:
bin/pqc-secrets migrate
# Or with custom account names:
PQC_KEYCHAIN_ACCOUNT_OLD=default PQC_KEYCHAIN_ACCOUNT_NEW=pqc-secrets-key bin/pqc-secrets migrate
Environment Variables
| Variable | Default | Description |
|---|---|---|
PQC_KEYCHAIN_ACCOUNT |
pqc-secrets-key |
|
PQC_CONFIG_DIR |
~/.config/pqc-secrets |
|
PQC_USE_KEYCHAIN |
false |
Implementation Details
- Rust Primary Engine:
fips203crate (rust-fips203, NSA CNSA 2.0 / FIPS 203 compliant ML-KEM-768). - Rust Primary Dependencies:
security-framework(macOS Keychain),aes-gcm,serde,serde_json,sha3. - Python Fallback Engine:
kyber-py(pure Python ML-KEM-768) +cryptography(AES-256-GCM). - Python Fallback Dependencies: Managed inline via UV script metadata —
kyber-py>=0.2.0,cryptography>=44.0(auto-resolved byuv run).
5. Application Integration Guidelines
Applications must read secrets exclusively from environment variables populated dynamically in memory. Do not store or read plaintext files inside the application context.
Pattern 1: Safe Environment Variable Consumption (Python)
import os
import sys
def get_api_key(name: str) -> str:
"""Retrieve secret from environment, ensuring no fallback to disk files."""
api_key = os.environ.get(name)
if not api_key:
print(f"CRITICAL ERROR: Environment variable '{name}' is not set.", file=sys.stderr)
print("Please load secrets via 'secrets-load' before running this command.", file=sys.stderr)
sys.exit(1)
return api_key
Pattern 2: Safe Environment Variable Consumption (Node.js)
function getApiKey(name) {
const apiKey = process.env[name];
if (!apiKey) {
console.error(`CRITICAL ERROR: Environment variable '${name}' is not set.`);
console.error("Please load secrets via 'secrets-load' before running this application.");
process.exit(1);
}
return apiKey;
}
Pattern 3: Safe Environment Variable Consumption (Rust)
fn get_api_key(name: &str) -> String {
std::env::var(name).unwrap_or_else(|_| {
eprintln!("CRITICAL ERROR: Environment variable '{}' is not set.", name);
eprintln!("Please load secrets via 'secrets-load' before running this command.");
std::process::exit(1);
})
}
Pattern 4: Shell Wrapper Integration
When wrapping command-line tools or other agents, pass env variables down rather than writing to disk config files. If a configuration file is strictly required by the tool (e.g. auth.json or .env file), write it dynamically to a secure directory (or temporary RAM disk if supported) and delete it immediately upon tool exit via traps.
# Example wrapper with exit trap for temporary configs
run_tool_with_secrets() {
local temp_env
temp_env=$(mktemp /tmp/tool-env.XXXXXX)
trap 'rm -f "$temp_env"' EXIT
# Populate temp config from environment variables loaded via secrets-load
cat > "$temp_env" <<EOF
API_KEY="${ZENMUX_API_KEY}"
EOF
command-tool --config "$temp_env" "$@"
}
Pattern 5: Headless & CI/CD Pipelines
In headless environments where macOS Keychain is unavailable, use standard platform secrets injection (e.g., GitHub Secrets, Kubernetes Secrets, or Docker environment flags) passed dynamically from standard input.
# Injecting secrets directly via stdin to avoid write-to-disk
docker run -e ZENMUX_API_KEY=$(bin/pqc-secrets export | grep ZENMUX_API_KEY | cut -d= -f2) my-image
§4 Algorithm Reference
PQC algorithm suite for secrets and signing operations. All algorithms are FIPS-compliant (final since August 2024) and approved for NSA CNSA 2.0 use. Audit any code that uses a different algorithm for secrets/signatures — it is a PQC violation.
| Algorithm | Standard | Type | Status | Sizes (bytes) | Use |
|---|---|---|---|---|---|
| ML-KEM-768 | FIPS 203 | KEM | Final Aug 2024 | pk 1184 / sk 2400 / ct 1088 | Primary bundle wrap |
| ML-KEM-1024 | FIPS 203 | KEM | Final Aug 2024 | pk 1568 / sk 3168 / ct 1568 | Higher-security wrap |
| ML-DSA-65 | FIPS 204 | Signature | Final Aug 2024 | pk 1952 / sig 3309 | Identity / signing |
| ML-DSA-87 | FIPS 204 | Signature | Final Aug 2024 | pk 2592 / sig 4627 | Higher-security signing |
| SLH-DSA-SHA2-128s | FIPS 205 | Hash-based sig | Final Aug 2024 | pk 32 / sig 7856 | Backup / long-term |
| AES-256-GCM | SP 800-38D | Symmetric | Standard | key 32 / IV 12 / tag 16 | Bundle data at rest |
| Argon2id | OWASP 2025 | KDF | Standard | 32+ output, t=3 m=64MB p=4 | Password-based KDF |
Bold = used in the default pqc-secrets configuration. Other
algorithms are available via explicit flags for higher-security
deployments.
References:
- NSA CNSA 2.0 (Commercial National Security Algorithm Suite 2.0)
- NIST FIPS 203/204/205 (August 2024 — final)
- NIST SP 800-38D (AES-GCM)
Forbidden algorithms for secrets/signatures (audit contexts excepted):
RSA, DSA, ECDSA, ECDH, Ed25519, MD5, SHA-1, DES, 3DES, Blowfish,
AES-CBC, AES-ECB, RC4, pycrypto, unauthenticated openssl use.
Standard cryptography (TLS 1.3, SSH, GPG, platform TLS) is fine for transport and non-secrets operations. The line is simple: if it protects an API key or private user datum, it uses PQC.
§5 CLI Command Reference
pqc-secrets <command> [args] — exit codes, arguments, and examples
for every command.
5.1 pqc-secrets keygen
Generate an ML-KEM-768 keypair.
Synopsis: pqc-secrets keygen [--recipient-out PATH]
Behavior:
- Generates a fresh ML-KEM-768 keypair.
- Private key written to the OS keychain (service:
pqc-secrets, account:ml-kem-768). - Public key written to
~/.config/pqc-secrets/recipient.pub(1.8 KB, safe to commit).
Exit codes:
0— success1— keychain unreachable2— recipient.pub already exists (refuses to overwrite; use--forceto overwrite)
Example:
$ pqc-secrets keygen
Wrote public key to /Users/nbiish/.config/pqc-secrets/recipient.pub
Wrote private key to macOS keychain (service: pqc-secrets)
5.2 pqc-secrets pack
Encrypt KEY=VAL lines and write a fresh bundle.
Synopsis: pqc-secrets pack [--in PATH] [--bundle PATH]
Behavior:
- Reads
KEY=VALlines from stdin (or--in PATH). - Generates a fresh 256-bit AES data key, encrypts the plaintext via AES-256-GCM.
- Encapsulates the data key against
recipient.pubusing ML-KEM-768. - Writes the bundle to
~/.config/pqc-secrets/secrets.bundle.json(or--bundle PATH).
Exit codes:
0— success1— bundle write failed2— recipient.pub missing (runkeygenfirst)
Example:
$ pqc-secrets pack --in <(printf 'STRIPE_SECRET=sk-live-...\nGH_TOKEN=ghp_...\n')
Wrote 2 keys to /Users/nbiish/.config/pqc-secrets/secrets.bundle.json (4 KB)
5.3 pqc-secrets export
Decrypt the bundle and emit shell export lines to stdout.
Synopsis: pqc-secrets export [--bundle PATH]
Behavior:
- Reads the bundle, decapsulates the data key using the keychain private key, decrypts the data via AES-256-GCM.
- Emits
export KEY=VALUElines to stdout. Values are quoted with double-quote escaping; multiline values are base64-encoded and prefixed with a warning comment.
Exit codes:
0— success1— bundle corrupt2— keychain entry missing (cannot decapsulate)
Example:
$ eval "$(pqc-secrets export)"
$ echo "$STRIPE_SECRET" | head -c 12
sk-live-AbCd...
5.4 pqc-secrets rotate
Re-encapsulate the bundle against a fresh ephemeral ML-KEM keypair. Data-key only — the long-term identity key in the keychain is NOT changed. See §10.2 for full identity rotation.
Synopsis: pqc-secrets rotate [--bundle PATH]
Behavior:
- Decapsulates the existing data key using the current keychain entry.
- Generates a fresh ephemeral ML-KEM keypair in a temp directory (not the keychain).
- Re-encapsulates the data key against the new pubkey.
- Backs up the old bundle to
secrets.bundle.json.bak.<UTC>. - Atomically renames the new bundle over the old.
- Emits a single audit log event:
rotate keysAffected=N.
Exit codes:
0— success1— bundle corrupt or write failed2— keychain entry missing
Example:
$ pqc-secrets rotate
Backed up to secrets.bundle.json.bak.2026-06-09T15-00-00Z
Re-encapsulated 12 keys against fresh ephemeral KEM keypair
Wrote secrets.bundle.json (4 KB)
Audit: rotate keysAffected=12
5.5 pqc-secrets status
Output machine-readable JSON describing the bundle state.
Synopsis: pqc-secrets status
Output (stdout, JSON):
{
"keychainOk": true,
"recipientFp": "sha3:19df3b3f86de13a983abe68801f3b6512e21310cfda70cf89b5b3dcc68b1a433",
"bundleFp": "sha3:...",
"nKeys": 15,
"createdUtc": "2026-06-07T18:47:58.715239Z"
}
Exit codes: 0 always (status never fails the call — use the
fields to detect problems).
5.6 pqc-secrets audit
Append a custom event to the audit log.
Synopsis: pqc-secrets audit --event <name> [--key k=v]...
Behavior: Emits one line to ~/.config/pqc-secrets/audit.log
(mode 0o600). Useful for non-MCP callers (shell scripts, CI) to
record secret operations.
Example:
$ pqc-secrets audit --event shell_export --key user=$USER --key n_keys=12
Audit: shell_export user=nbiish n_keys=12
§6 Bundle JSON Schema
The bundle at ~/.config/pqc-secrets/secrets.bundle.json is the
canonical encrypted store. Safe to commit — every value is
AES-256-GCM ciphertext wrapped by ML-KEM-768.
Schema verified 2026-06-09 against the live bundle at
~/.config/pqc-secrets/secrets.bundle.json(engine:rust-fips203). Field names are the actual production names, not the long-form names used in earlier drafts. Code that parses bundles must use these exact names.
6.1 Top-level structure
{
"version": 1,
"alg": "ML-KEM-768",
"engine": "rust-fips203",
"created_utc": "2026-06-07T18:47:58.715239Z",
"recipient": { ... },
"kem": { ... },
"keywrap": { ... },
"data": { ... }
}
| Field | Type | Required | Meaning |
|---|---|---|---|
version |
integer | yes | Bundle format version. Currently 1. |
alg |
string | yes | Algorithm descriptor. ML-KEM-768 (top-level — also see keywrap.kdf and data.aad). |
engine |
string | yes | Engine version, e.g. rust-fips203. |
created_utc |
string | yes | ISO 8601 UTC timestamp with microseconds. |
recipient |
object | yes | Public key fingerprint (see below). |
kem |
object | yes | ML-KEM-768 encapsulation of the wrapped key. |
keywrap |
object | yes | AES-256-GCM-wrapped data key, derived via SHA3-256 KDF. |
data |
object | yes | AES-256-GCM ciphertext of the secret bundle, with AAD. |
Field name conventions (verified against the live bundle):
alg— single string, NOTalgorithmcreated_utc— NOTcreatedAtrecipient,kem,keywrap,data— all lowercase, no separators- Sub-fields use
_b64suffix for base64-encoded values
6.2 recipient object
{
"public_key_sha3_256": "19df3b3f86de13a983abe68801f3b6512e21310cfda70cf89b5b3dcc68b1a433"
}
| Field | Type | Meaning |
|---|---|---|
public_key_sha3_256 |
string | SHA3-256 hex of the public key (64 hex chars = 32 B digest). NOT the public key bytes themselves. |
The
recipientblock does NOT contain the public key — only its SHA3-256 fingerprint. To recover the public key, look at~/.config/pqc-secrets/recipient.pubon disk. The fingerprint in the bundle lets the verifier check that the on-diskrecipient.pubmatches the bundle's intent (defense-in-depth againstrecipient.pubsubstitution).
6.3 kem object
{
"ciphertext_b64": "b14Q16NAByG+rLOib05Mwj2N9NMFeZcX..."
}
| Field | Type | Meaning |
|---|---|---|
ciphertext_b64 |
string | Base64-encoded ML-KEM-768 KEM ciphertext (1088 B raw, ~1452 B encoded). |
6.4 keywrap object
{
"kdf": "SHA3-256",
"aad": "pqc-secrets:v1:keywrap",
"nonce_b64": "i1WOwoxZRKzr/sw2",
"ciphertext_b64": "j0KPhYjZz9TwTHYgIMxvI+4VeNIkR9qOkTnqrSwlBrx8BKOXWUQWK97OiQG+dLms"
}
| Field | Type | Meaning |
|---|---|---|
kdf |
string | KDF used to derive the data key from the KEM shared secret. SHA3-256 for v1. |
aad |
string | Additional authenticated data. pqc-secrets:v1:keywrap for v1. |
nonce_b64 |
string | Base64-encoded 96-bit AES-GCM nonce (12 B raw, ~16 B encoded). |
ciphertext_b64 |
string | Base64-encoded wrapped data key ciphertext WITH the 16-byte GCM auth tag appended (no separate tag field). |
6.5 data object
{
"aad": "pqc-secrets:v1:data",
"nonce_b64": "zoq1...",
"ciphertext_b64": "..."
}
| Field | Type | Meaning |
|---|---|---|
aad |
string | Additional authenticated data. pqc-secrets:v1:data for v1. |
nonce_b64 |
string | Base64-encoded 96-bit AES-GCM nonce (12 B raw). |
ciphertext_b64 |
string | Base64-encoded encrypted secret bundle WITH the 16-byte GCM auth tag appended (no separate tag field). |
The GCM auth tag is appended to the ciphertext, not stored in a separate
tagfield. To extract:tag = ciphertext[-16:],ciphertext = ciphertext[:-16].
6.6 Complete example (with realistic sizes)
{
"version": 1,
"alg": "ML-KEM-768",
"engine": "rust-fips203",
"created_utc": "2026-06-07T18:47:58.715239Z",
"recipient": {
"public_key_sha3_256": "19df3b3f86de13a983abe68801f3b6512e21310cfda70cf89b5b3dcc68b1a433"
},
"kem": {
"ciphertext_b64": "b14Q16NAByG+rLOib05Mwj2N9NMFeZcX..."
},
"keywrap": {
"kdf": "SHA3-256",
"aad": "pqc-secrets:v1:keywrap",
"nonce_b64": "i1WOwoxZRKzr/sw2",
"ciphertext_b64": "j0KPhYjZz9TwTHYgIMxvI+4VeNIkR9qOkTnqrSwlBrx8BKOXWUQWK97OiQG+dLms"
},
"data": {
"aad": "pqc-secrets:v1:data",
"nonce_b64": "zoq1...",
"ciphertext_b64": "..."
}
}
6.7 Size reference (approximate)
| Field | Encoded size (B) | Raw size (B) |
|---|---|---|
recipient.public_key_sha3_256 |
64 | 32 (digest) |
kem.ciphertext_b64 |
~1452 | 1088 |
keywrap.ciphertext_b64 |
~64 | 48 (32 data + 16 tag) |
keywrap.nonce_b64 |
~16 | 12 |
data.ciphertext_b64 |
variable | N×~100 + 16 |
data.nonce_b64 |
~16 | 12 |
A bundle with ~12 keys of ~100 B each typically weighs ~4 KB on disk (verified: live bundle is 4,097 B with ~15 keys).
6.8 Validation
Run the bundle verifier to confirm structural integrity:
$ python3 .agents/skills/pqc-secrets/scripts/verify-bundle.py
OK: bundle validates, recipient.fp=sha3:19df3b3f..., ~15 keys, 0 plaintext leaks
$ echo $?
0
The verifier checks: required fields (top-level + per-block),
kem.ciphertext_b64 length == 1088 B (raw, ML-KEM-768),
data.nonce_b64 length == 12 B (raw, AES-GCM),
data.ciphertext_b64 length >= 16 B (GCM tag present),
recipient.public_key_sha3_256 is 64 hex chars of valid hex, and
scans for plaintext secret patterns (sk-live, sk-test,
whsec_, AKIA, ghp_).
§7 Agent Integration Recipes
7.1 Hermes MCP (betterbrowsermcp)
The @nbiish/betterbrowsermcp MCP server exposes 9 PQC secrets tools
to any Hermes agent:
| Tool | Purpose |
|---|---|
browser_secrets_status |
Check keychain + bundle health. Returns JSON. |
browser_secrets_list |
List secret names (no values). |
browser_secrets_get |
Read one secret value. Optional mode: 'plain'|'redact'. |
browser_secrets_load |
Bulk-export bundle into the agent's process env. |
browser_secrets_add |
Add a new secret. Optional dry_run: true. |
browser_secrets_add_from_clipboard |
Pull a value from the page's clipboard write. |
browser_secrets_unlock_agent |
Cache one secret value in agent memory for fast reads. |
browser_secrets_lock_agent |
Clear a cached secret (or wipe all). |
browser_secrets_copy_to_page |
Paste a secret into a focused form field. |
Hermes config (~/.hermes/config.yaml):
mcp_servers:
betterbrowsermcp:
command: node
args:
- /path/to/betterbrowsermcp/dist/index.js
env:
BROWSER_MCP_AGENT_ID: hermes
BROWSER_MCP_PORT: '9109'
Add betterbrowsermcp to platform_toolsets.cli and /reload-mcp.
The LLM uses mcp_betterbrowsermcp_browser_secrets_* tools directly.
The audit log at ~/.config/pqc-secrets/audit.log records every call.
7.2 Claude Code (settings.json env-block trap)
Claude Code reads ~/.claude/settings.json and ~/.claude/projects/*/settings.json.
These files are committed-friendly JSON, not encrypted.
WRONG — violates PQC:
{
"env": {
"ANTHROPIC_API_KEY": "sk-ant-...",
"OPENAI_API_KEY": "sk-proj-..."
}
}
The key sits in a plaintext file that may sync to cloud backup, get committed to a public dotfiles repo, or be read by any process with file permissions.
RIGHT — empty in settings, injected at runtime:
{
"env": {
"ANTHROPIC_BASE_URL": "https://zenmux.ai/api/anthropic",
"ANTHROPIC_API_KEY": "",
"OPENAI_API_KEY": ""
}
}
Then in ~/.zshrc (sourced before claude is launched):
secrets-load() {
eval "$(pqc-secrets export)"
}
secrets-load injects the values into the current shell's env, which
claude inherits. The settings file has empty strings; the real
values live in the encrypted bundle and the keychain.
7.3 VS Code / Cursor
Same trap in .vscode/settings.json and .vscode/launch.json:
// .vscode/launch.json — WRONG
{
"configurations": [{
"env": {
"API_KEY": "sk-..." // NEVER do this
}
}]
}
Use a launch-time shell substitution or a task that calls
pqc-secrets export and sources the result before launching the
debug target.
7.4 Ainish-coder / generic shell wrapper
The secrets-load shell function (in ~/.zshrc):
# Load PQC secrets into the current shell's environment
secrets-load() {
local line
while IFS= read -r line; do
[[ "$line" =~ ^export ]] || continue
eval "$line"
done < <(pqc-secrets export)
}
Use it before launching any tool that needs secrets:
$ secrets-load
$ claude # inherits $ANTHROPIC_API_KEY
$ cursor # inherits $ANTHROPIC_API_KEY
The values are in process memory (volatile), not in any file.
§8 Anti-pattern Catalog
Concrete violations found in real codebases, with their fixes.
8.1 .env files in repo
# .env (NEVER commit)
STRIPE_SECRET=sk-live-AbCd1234
Fix: Delete the .env. Add secrets.bundle.json reference to
the project README's "Setup" section. Add a pqc-secrets pack step
to the setup script.
8.2 settings.json env blocks
{"env": {"API_KEY": "sk-..."}}
Fix: empty string in settings, keychain-injected at launch (see §7.2).
8.3 .vscode/launch.json env blocks
Same trap. Use ${env:API_KEY} with a launch task that runs
secrets-load first.
8.4 env_file: in docker-compose
services:
app:
env_file: ./secrets.env # plaintext on disk
Fix: use Docker secrets (mounted from a PQC-backed volume) or
pass at runtime via docker run -e KEY=$(...).
8.5 Hardcoded API keys in source
// src/config.ts
export const STRIPE_KEY = "sk-live-AbCd1234";
Fix: export const STRIPE_KEY = process.env.STRIPE_KEY!; and
ensure process.env.STRIPE_KEY is populated by secrets-load before
the process starts.
8.6 Logging secrets
console.log("Got API key:", apiKey);
Fix: never log secret values. Log metadata only:
console.log("Loaded API key, length:", apiKey.length);
8.7 printenv > .env debug
printenv | grep -i key > .env # NEVER
Fix: use printenv | grep -i key to stdout (visible) but never
redirect to a file. Or use pqc-secrets status to inspect the bundle
state without revealing values.
8.8 GitHub Actions plaintext secrets
# .github/workflows/deploy.yml — acceptable for CI
env:
API_KEY: ${{ secrets.API_KEY }}
Acceptable for CI runtimes — GitHub Actions secrets are
encrypted at rest by GitHub. But: every developer with repo
access can see and modify secrets.API_KEY in the GitHub UI.
For higher-security deployments, use an external secrets manager
(HashiCorp Vault, AWS Secrets Manager) that the CI calls at runtime.
8.9 Screen recordings / terminal scrollback
If you record your terminal (e.g., for a tutorial), redact the
output of pqc-secrets export before recording. Or use
pqc-secrets status (no values) for demos.
§9 Audit Log Format
9.1 Spec
The audit log is an append-only file at
~/.config/pqc-secrets/audit.log, mode 0o600 (owner read+write
only). One event per line:
<ISO8601-UTC with millisecond precision> TAB <actor> TAB <action> TAB name=<NAME> TAB mode=<MODE> TAB tab=<TAB> TAB <detail>
- ISO8601-UTC — e.g.
2026-06-10T15:24:49.572Z(with millisecond precision, UTC) - actor —
hermes(the betterbrowsermcp MCP server). Reserved for multi-actor future; no other actor writes today. - action — one of:
get | list | add | add_from_clipboard | rotate | load | unlock | lock | copy_to_page | status | fail(note: real action names areunlockandlock, not the longerunlock_agent/lock_agentthat the v0.7.0 specs used) - name/mode/tab — see Field meanings table
- detail — free-form
key=valuepairs, semicolon-separated for compound values
9.2 Example lines (TAB-separated, real audit.log output)
2026-06-10T15:24:49.572Z hermes add name=BBMCP_TEST_KEY mode=- tab=- merge=true; total=14; value-fp=sha3:549ab3b879785c99
2026-06-10T15:25:12.001Z hermes get name=BBMCP_TEST_KEY mode=plain tab=- value-fp=sha3:549ab3b879785c99
2026-06-10T15:25:13.402Z hermes get name=BBMCP_TEST_KEY mode=redact tab=-
2026-06-10T15:25:30.118Z hermes unlock name=BBMCP_TEST_KEY mode=- tab=42 value-fp=sha3:549ab3b879785c99
2026-06-10T15:25:35.224Z hermes lock name=BBMCP_TEST_KEY mode=- tab=42
2026-06-10T15:27:18.033Z hermes rotate name=- mode=- tab=- old=sha3:61b547a65b7c806a...; new=sha3:6de4314e19c83b75...; count=15; backup=/Users/nbiish/.config/pqc-secrets/secrets.bundle.json.bak.2026-06-10T15-27-18-012Z
Field separator is TAB, not space. Always use -F'\t' with
awk or grep -P '\t' for tab-aware parsing. Plain grep and
awk '{print $2}' work because fields contain no spaces within
themselves, but tab-aware parsing is more correct.
9.3 Field meanings
actor—hermes(the betterbrowsermcp MCP server). Reserved for multi-actor future; no other actor writes today.action— the operation (see §9.1)name=<secret_name>— name of the secret touched, or-mode=plain|redact— only ongetevents, or-tab=<id>— bound tab id (as string), or-value-fp=sha3:<16hex>— SHA3-256 fingerprint, first 16 hex chars, NEVER the value. Present on get, add, unlock, copy_to_page.merge=true|false,total=<n>— bundle diff stats onaddold=<sha3:...>; new=<sha3:...>; count=<n>; backup=<path>— rotate eventref=<eN>— snapshot ref oncopy_to_pageeventserror=<text>— present oncopy_to_pagefailuresdetail=<text>— present onlock(all) asall-unlocked-cleared
9.4 Retention
- Keep forever in the current file (
audit.log). - Monthly archive to
audit.log.YYYY-MMwhen file size exceeds 10 MB. Archive is amv(no rewrite). - Total retention is unlimited. Audit log is small per event (~80 bytes); 10 MB holds ~125,000 events.
- The user controls retention. The system does NOT auto-delete.
9.5 Verification use cases
- "Did my agent read STRIPE_SECRET in the last hour?"
→
grep 'name=STRIPE_SECRET' ~/.config/pqc-secrets/audit.log | tail -20 - "What keys were added today?"
→
grep -E '^[0-9-]+\thermes\tadd' ~/.config/pqc-secrets/audit.log | grep $(date -u +%Y-%m-%d) - "When was the last rotation?"
→
grep '\thermes\trotate\t' ~/.config/pqc-secrets/audit.log | tail -1 - "Did anyone read ANTHROPIC_API_KEY today?"
→
grep 'name=ANTHROPIC_API_KEY' ~/.config/pqc-secrets/audit.log | grep $(date -u +%Y-%m-%d) - "Verify a value matches what's in the bundle"
→
echo -n '<value>' | shasum -a 256 - | cut -c1-16and compare againstvalue-fp=sha3:...in the log entry.
9.6 Implementation reference
The audit writer lives in
betterbrowsermcp/src/audit.ts and exposes two functions:
import { logAuditEvent, valueFingerprint } from "../audit";
// Append one event (non-blocking, best-effort).
logAuditEvent({
action: "get",
mode: "plain",
name: "STRIPE_SECRET",
detail: `value-fp=${valueFingerprint(value)}`,
});
// Compute a SHA3-256 fingerprint, first 16 hex chars.
const fp = valueFingerprint("sk-live-AbCd...");
console.log(fp); // → sha3:549ab3b879785c99
§10 Rotation Runbook
§10.1 Routine data-key rotation (default — via browser_secrets_rotate MCP tool)
This is the recommended path for agents and humans alike. The
browser_secrets_rotate tool (betterbrowsermcp v0.8.0+) does the
full rotate op atomically: backup → re-encrypt → report.
The recommended path (MCP):
LLM: "Rotate the PQC bundle."
→ browser_secrets_rotate
← "Rotated PQC bundle.
Old fingerprint: sha3:4d96075ada91fa0b...
New fingerprint: sha3:0ae9fa5052c82b65...
Previous bundle backed up to
/Users/nbiish/.config/pqc-secrets/secrets.bundle.json.bak.<UTC>
(retain for 7 days, then delete with: rm <path>).
N secret(s) re-encrypted with a fresh data key and a
fresh ML-KEM-768 shared secret. The identity keypair in
the keychain is unchanged."
Audit log entry (one event, real format):
2026-06-10T15:27:18.033Z hermes rotate name=- mode=- tab=- old=sha3:61b5...; new=sha3:6de4...; count=15; backup=...
The manual path (CLI, for non-MCP contexts):
This is the routine operation. Re-encapsulates the AES data key against a fresh ephemeral KEM keypair. The long-term identity key in the keychain is unchanged.
Steps:
- Backup current bundle:
cp ~/.config/pqc-secrets/secrets.bundle.json \ ~/.config/pqc-secrets/secrets.bundle.json.bak.$(date -u +%Y%m%dT%H%M%SZ) - Decapsulate the data key using the existing keychain entry.
Every key is read once (audit log emits one
getevent per key withmode=plain— this is internal to the rotate op). - Generate fresh ephemeral ML-KEM keypair in a temp directory
(e.g.
/tmp/pqc-rotate-XXXXXX). Not stored in keychain. - Re-encapsulate the data key against the new pubkey.
- Re-pack: write new bundle with new
kem.ciphertextto the temp directory. - Atomic rename over the old bundle (
fs.renameafterfsync). - Verify with
verify-bundle.py(.agents/skills/pqc-secrets/scripts/):
Expect exit 0.python3 .agents/skills/pqc-secrets/scripts/verify-bundle.py - Audit log emits a single
rotate keysAffected=Nevent.
Frequency: monthly for high-value deployments, quarterly for typical.
§10.2 Full identity rotation (out-of-band ceremony)
Required when the long-term ML-KEM-768 key in the keychain is compromised or scheduled for rotation (annually, after a security incident, or when an employee with keychain access leaves).
Steps:
- Generate new keypair (do NOT overwrite the existing one yet):
pqc-secrets keygen --recipient-out /tmp/pqc-new-recipient.pub - Decapsulate the existing bundle using the current keychain
entry (one
get mode=plainper key in the audit log). - Write the new keypair to the keychain (overwriting the
current entry):
pqc-secrets keygen --in-place # writes to keychain, recipient.pub - Re-pack with the new recipient:
pqc-secrets pack --bundle ~/.config/pqc-secrets/secrets.bundle.json - Distribute
recipient.pubto every consumer (every agent config that points at this bundle — Hermes MCP, shell wrappers, CI runners, etc.). - Verify with
verify-bundle.py. - Old keychain entry is kept for a 7-day grace period in
case distribution is partial (some consumers still need it). After
7 days, delete:
security delete-generic-password -s pqc-secrets -a ml-kem-768 - Audit log emits two events:
rotate_identity keysAffected=Nandrevoke_old_key grace_until=<UTC+7d>.
§10.3 Disaster recovery — Lost keychain entry
If the keychain is wiped (Time Machine restore failure, accidental
security delete-generic-password, etc.), the data in
secrets.bundle.json is unrecoverable. PQC keys are not
escrowed; this is intentional.
Recovery requires:
- A working keychain entry (the only one)
- OR a backup of the bundle's plaintext (which should never exist on disk per the threat model)
Mitigations:
- Keep the keychain entry on a Time Machine backup volume. Verify
monthly that the backup includes the
pqc-secretsservice. - Maintain N=5 generations of bundle backups (the rotate op naturally produces one; copy older generations from your existing bundle history).
- Critical: never write the bundle plaintext to disk. If you
must export a value for a one-time use, pipe it directly:
pqc-secrets export | grep STRIPE_SECRET | cut -d= -f2- | tr -d '"' | my-tool(no intermediate file).
§11 Threat Model Statement
Primary risk: plaintext on disk
The dominant threat vector is plaintext on disk — secrets ending up in:
.envfiles (committed or uncommitted)env:blocks insettings.json,launch.json, etc.- Hardcoded API keys in source code
- Accidental
print()/console.log()of secret values - Debug log files
- Terminal scrollback / history
- Screen recordings
- Backups (unencrypted Time Machine volumes, cloud sync of unencrypted dotfiles)
printenv > .envdebugging
Every one of these is a PQC violation — the secret is in plaintext, not in the bundle, not protected by ML-KEM-768 + AES-256-GCM.
In-memory reads are trusted
Reads by the user's own LLM (or shell, or CI runner) are not gated. The LLM is operating on the user's behalf, with the user's authority, on the user's machine. The audit log captures every read so the user can verify "did I really read X at Y time?"
This maximizes availability and usability for the single-user
local agent. We do not add an auth gate, token check, or rate limit
on secrets_get — those would slow down the user's workflow
without adding real security (the LLM has the same access the user
has).
Cross-tab leakage prevention
Cross-tab leakage is prevented structurally: destructive tools
(secrets_copy_to_page, secrets_add_from_clipboard,
secrets_unlock_agent, secrets_add) require an explicit tabId.
The MCP server refuses to dispatch a paste to "the active tab" —
the LLM must name the bound tab. This prevents an attacker who
controls one tab from triggering a paste into a different tab.
The audit log is the verification surface
The audit log answers: "What did my agent do with my secrets?"
It is append-only (no in-place edits), mode 0o600 (only the user
can read it), and structured (one event per line, machine-parseable).
A user who suspects unauthorized access can grep the log for
unfamiliar agent IDs, unexpected tab IDs, or operations at unusual
hours.
Out of scope
- Multi-user secrets — single-user model. Per-user bundles live
at
~/.config/pqc-secrets/. Multi-user deployments need per-user bundles and a shared unlock key (TBD). - Hardware-bound key attestation beyond the OS keychain — T2/M-series Macs have hardware-backed keychain, but we don't verify this attestation at decrypt time.
- Network-exposed secrets — everything is local-only. The bundle is on disk; the keychain is local; the audit log is local. There is no remote API.
- Classical crypto fallback — if ML-KEM-768 is unavailable, the bundle cannot be read. There is no fallback to RSA, ECDH, or any classical KEM.