BANKON_VAULT.md · 17.4 KB

BANKON Vault — canonical reference

The encrypted credential store that holds every API key and agent private key the running mindX service depends on. This doc is the durable understanding — what it is, how it works, where to look. For the operator ceremony, see BANKON_VAULT_HANDOFF.md. For the plan to retire the older vault_manager / encrypted_vault_manager modules, see LEGACY_VAULT_MIGRATION.md. For the audit that drove the 2026-04-28 remediation pass, see baking-wilkinson.md">/home/hacker/.claude/plans/jolly-baking-wilkinson.md.

What it is

mindx_backend_service/bankon_vault/ is the canonical encrypted credential store. It is overseer-aware: the same vault file works under three custody modes — Machine, Human, or DAIO — with the unlock root-of-trust swapping per mode while the encrypted entries stay byte-stable.

core code    → mindx_backend_service/bankon_vault/{vault,overseer,credential_provider,routes}.py
on-disk      → mindx_backend_service/vault_bankon/
operator CLI → manage_credentials.py (store/list/delete/providers/load)
ceremony CLI → manage_custody.py (preflight, challenge, dry-run, commit, smoke-test, …)
airgap tool  → scripts/vault/airgap_sign.py (single file, no mindX imports)
test suite   → make test-vault → tests/bankon_vault/ (10 tests, ~1.2 s)
audit log    → data/governance/overseer_history.jsonl (append-only, fsync'd)

Crypto stack

Layered HKDF over a single 32-byte salt that never rotates. The IKM source swaps per overseer; the final HKDF that produces the vault key is identical for all three modes.

                                    +--- (Machine) raw 64B from .master.key  --+
unlock IKM source ------------------+--- (Human)   raw 64B = HKDF(             |
(swappable per overseer)            |       ikm=65B EIP-191 sig,               |
                                    |       salt=vault_salt,                   |
                                    |       info=b"bankon-overseer-human-v1:"  |
                                    |            + addr_20)                    |
                                    +--- (DAIO)    raw 64B = HKDF(             |
                                            ikm=on-chain attestation digest,   |
                                            salt=vault_salt,                   |
                                            info=b"bankon-overseer-daio-v1:"   |
                                                 + registry + chain_id)        |
                                                                               |
                              vvv  uniform stage-2 (vault.py:235-242, 245-255) v
                              vault_key (32B) = HKDF-SHA512(
                                  ikm=raw_64B,
                                  salt=vault_salt,
                                  info=b"bankon-vault-master-key",
                                  length=32)

per-entry key (32B) = HKDF-SHA512( ikm=vault_key, salt=vault_salt, info="bankon-entry:<entry_id>:<context>", length=32)

ciphertext = AES-256-GCM( key=per-entry key, iv=12B random per write, aad=entry_id, pt=value-utf8)

On-disk layout

mindx_backend_service/vault_bankon/ (mode 0700)

FileModeContentsLifecycle
entries.json0600{version, cipher, kdf, pbkdf2_iterations, entries:[…]} — every entry's {id, ciphertext_hex, iv_hex, context, created_at, updated_at, access_count}Mutated on every store/delete/rotate_overseer
.salt060032 random bytesOne-shot, never rotated
.master.key040064 random bytesPresent in machine custody; deleted (zeroized first) on Human/DAIO handoff
.human_overseer_active0600{since, overseer_kind, overseer_identity}Sentinel — present after non-machine handoff. Blocks unlock_with_key_file (vault.py:191-197)
.overseer_proof.json0600{kind, address, signature, message, ts} for human overseerLets the service re-unlock without re-signing (overseer.py:250-275)
.rotation.lock0600PID + timestampHeld during _rotate_overseer_locked, exclusive fcntl.flock (vault.py:298-328)
.rotation.ok0600{candidate_sha, ts, new_overseer_fingerprint, entries_count}Two-phase commit marker; required <300 s old at commit (vault.py:480-507)
entries.json.candidate0600Re-encrypted entries pending commitAtomically swapped via os.replace

The audit log lives outside the vault dir at data/governance/overseer_history.jsonl — append-only, fsync'd, one row per rotation.

Three custody modes

Implemented as three classes satisfying one Protocol (overseer.py:30-50):

OverseerkindidentityIKM sourceStatus
MachineOverseer"machine"filesystem path.master.key 64 random bytesActive default
HumanOverseer"human"0x… EOA65-byte EIP-191 signature over a challenge textImplemented + tested end-to-end
DAIOOverseer"daio"daio:<chain_id>:<governor_addr>On-chain Governor proposal attestation digestStubNotImplementedError for produce_raw_key and verify_evidence (overseer.py:232-244)

The Protocol lets vault.rotate_overseer(new_overseer, …) be polymorphic — same atomic swap, same scratch verify, same audit log row, for any pair of source/target overseers. Going Human → DAIO later is one call.

The two-stage HKDF (per-overseer _INFO_PREFIX for IKM, then unified b"bankon-vault-master-key" for the vault key) is what makes this clean. Same salt, same entries.json, same per-entry derivation path — every overseer ends up at the same 32-byte vault key from a different root of trust.

Lifecycle

Startup — machine mode (today)

  1. FastAPI @app.on_event("startup") (main_service.py:4613-4630).
  2. cred_provider = CredentialProvider()BankonVault() constructor reads .salt, loads encrypted entries.json into RAM, stays locked.
  3. cred_provider.load_from_vault()unlock_with_key_file(None).
  4. unlock_with_key_file (vault.py:182-211): checks for sentinel; if absent, reads .master.key, HKDF→32B vault key, sets _locked=False.
  5. CredentialProvider iterates PROVIDER_ENV_MAP (currently 27 entries, including shadow_overlord_address/shadow_jwt_secret/mindx_admin_addresses/wordpress_publisher_addresses — see credential_provider.py), retrieves each, injects into os.environ.
  6. vault.lock() zeroizes _vault_key. Vault back to locked. Total ~50 ms.

Startup — sentinel present (after Human handoff)

  1. Steps 1–3 unchanged.
  2. Step 4 raises RuntimeError("Vault is under HumanOverseer custody…").
  3. FastAPI startup catches it, logs warning, service continues with no credentials in os.environ. Agents fail-open hours later when they hit their first LLM call.
  4. Recovery has two paths:
- Automatic (post-handoff design): the service is supposed to call load_human_from_proof(.overseer_proof.json, salt)unlock_with_overseer(...) at startup. Today this only happens via the manage_custody.py CLI, not in main_service.py's startup hook — gap noted as a follow-up. - Operator-mediated: SSH and run python manage_custody.py preflight then unlock via the proof file, OR POST /vault/credentials/reunlock (admin-gated; replays the proof file by default — routes.py:reunlock).

Request-time

Lock + zeroize

HTTP surface

Mounted via app.include_router(bankon_vault_router) at main_service.py:4607.

PathMethodAuthReturns
/vault/credentials/statusGETpublicVault info (vault_dir stripped)
/vault/credentials/providersGETpublicProvider IDs + env-var mappings
/vault/credentials/listGETadmin (require_admin_access)Metadata only — no plaintexts
/vault/credentials/storePOSTadmin{status, cipher}
/vault/credentials/deleteDELETEadmin{status} or 404
/vault/credentials/reunlockPOSTadmin{status, fingerprint, providers_loaded, env_vars, source} — Flow A: replay .overseer_proof.json; Flow B: {address, signature, message} body

The two /admin/vault/{keys,migrate} endpoints are admin-gated since be45f113 (the global auth middleware was already enforcing 401 for anonymous callers; the per-route gate adds wallet ∈ admin_addresses).

context as a namespace

store(entry_id, value, context=…) derives the per-entry key from HKDF(vault_key, info="bankon-entry:<entry_id>:<context>") — so context is a real cryptographic namespace, not a label. Compromising one entry's key does not disclose another's, and entries sharing a context are HKDF-isolated as a group from entries in any other context.

Known contexts (informational):

ContextUsed byExamples
providerPROVIDER_ENV_MAP startup loader → os.environgemini_api_key, openrouter_api_key, shadow_overlord_address, shadow_jwt_secret, mindx_admin_addresses, …
cabinet_provision / cabinet_publicbankon_vault/cabinet.py (executive cabinet wallets)company:…:cabinet:…:pk, …:address
wordpress.agent.keyswordpress-agent service, decrypt-on-demand per /publish (see WORDPRESS_PUBLISHING.md)wordpress.agent:pk, wordpress.agent:address, wordpress.agent:wp_app_password, wordpress.agent:wp_base_url, wordpress.agent:wp_user — none in PROVIDER_ENV_MAP, never decrypted into os.environ at startup
defaultfallback when no context is passed

Public wallet-authorized publish (related route family)

PathMethodAuthReturns
/publish/rage/challengePOSTpublic{nonce, message, expires_at} for scope=wordpress.publish, bound to {wallet, title, content_sha256}
/publish/rage/authorizePOSTEIP-191 sig (recovers to caller's wallet_address) + WORDPRESS_PUBLISHER_ADDRESSES allowlistpublishes via AuthorAgent → wordpress-agent (which decrypts the wordpress.agent.keys namespace on demand)

Rate limit: 30 req/min/client, all /vault/ share one bucket (security_middleware.py:49). No HTTP path can call unlock_with_key_file or return plaintext entry values.

Tests

make test-vault (Makefile target) — 10 tests in tests/bankon_vault/, ~1.2 s:

The defense-in-depth checks for stale .rotation.ok (>300 s) and candidate SHA drift between dry-run and commit (vault.py:506-512) are intentionally not tested — the locked single-call _rotate_overseer_locked always re-runs the dry-run path before checking those guards, so they're unreachable from outside without white-box mocking that ossifies internal call structure.

Reading order for a new contributor

  1. This doc — what + how.
  2. mindx_backend_service/bankon_vault/vault.py — implementation. Most of the cryptographic decisions live here. Read top to bottom.
  3. mindx_backend_service/bankon_vault/overseer.py — the Protocol + three implementations.
  4. tests/bankon_vault/test_rotate_overseer.py — the rotation contract test. Best single file to understand what the vault guarantees.
  5. manage_custody.py — the connected-side CLI. Each subcommand is a self-contained ceremony step.
  6. BANKON_VAULT_HANDOFF.md — operator runbook for the airgapped Machine → Human ceremony with threat model and recovery flows.
  7. LEGACY_VAULT_MIGRATION.md — sequenced plan to retire vault_manager and encrypted_vault_manager. The Phase 1 retirement of encrypted_vault_manager is the real audit blocker before any Human handoff is meaningful — its parallel Fernet vault still has its own machine-mode .master.key that the BANKON handoff doesn't touch.

When to use what

You want to …Path
Store a new API keypython manage_credentials.py store <provider_id> <value>
Store a credential in an isolated namespacepython manage_credentials.py store <id> <value> --context <namespace>
Provision wordpress.agent (wallet + WP API key, isolated wordpress.agent.keys namespace, decrypt-on-demand — see WORDPRESS_PUBLISHING.md)python scripts/vault/provision_wordpress_agent.py --wp-base-url … --wp-user …
List stored entries (no plaintexts)python manage_credentials.py list
Provision the deployer key for the iNFT campaignpython manage_credentials.py store zerog_deployer_pk 0x...
Provision the x402-AVM (Algorand) buyer wallet — see X402.mdpython manage_credentials.py store algorand_mnemonic "<25-word>" ; also algorand_recipient_address, algorand_usdc_asa_id, x402_avm_facilitator_url
Run the airgapped handoff ceremonyFollow BANKON_VAULT_HANDOFF.md
Recover after a service restart that came up with empty os.environPOST /vault/credentials/reunlock (admin) or python manage_custody.py (SSH)
Verify the rotation contract still holds before a deploymake test-vault
Read who currently owns the vaultcat data/governance/overseer_history.jsonl (append-only)
Plan moving Stage 1 (Human) → Stage 2 (DAIO)The DAIO migration section of this doc + overseer.py:200-244 (DAIOOverseer stub)

What is not in BANKON Vault (and why)

Reference (one-liners)

WhatWhere
Core implementationmindx_backend_service/bankon_vault/vault.py (~720 LOC)
Overseer protocol + three classesmindx_backend_service/bankon_vault/overseer.py (~275 LOC)
Bridge to os.environ at startupmindx_backend_service/bankon_vault/credential_provider.py
FastAPI routermindx_backend_service/bankon_vault/routes.py
Connected-side CLImanage_credentials.py, manage_custody.py
Airgap signerscripts/vault/airgap_sign.py
Operator runbookdocs/BANKON_VAULT_HANDOFF.md
Migration plandocs/LEGACY_VAULT_MIGRATION.md
Audit (2026-04-28)/home/hacker/.claude/plans/jolly-baking-wilkinson.md
Original overseer-design plan/home/hacker/.claude/plans/glimmering-growing-scroll.md
Teststests/bankon_vault/ — 10 via make test-vault
Append-only audit logdata/governance/overseer_history.jsonl
Live vault dirmindx_backend_service/vault_bankon/
Production endpointhttps://mindx.pythai.net/vault/credentials/status

Referenced in this document
BANKON_VAULT_HANDOFFWORDPRESS_PUBLISHING

All DocumentsDocument IndexThe Book of mindXImprovement JournalAPI Reference