This post covers the internal architecture and security properties of the pop-pay credential vault — encrypted storage of payment credentials at rest and during the unlock / inject window. It focuses on the cryptographic implementation, process isolation of secrets, and the passive failure modes that motivate the vault’s existence. For the broader agentic-commerce layer (guardrails, TOCTOU, prompt injection at the payment-intent level), see the agent-commerce threat model.
1. Architecture summary
- TS implementation: TypeScript wrapper
src/vault.tsorchestrating a native Rustnapi-rslayernative/src/lib.rsfor scrypt key derivation with compiled-salt hardening. AES-256-GCM via Node’scrypto.createCipheriv. - Python implementation: Python wrapper
pop_pay/vault.pyplus compiled Cython enginepop_pay/engine/_vault_core.pyx→.so. Byte-identical blob format with TS (verified bytests/vault-interop.test.ts). - KDF (machine mode):
scryptwith N=2^14 (16384), r=8, p=1, dkLen=32. Password =machine_id + ":" + username. - KDF (passphrase mode):
PBKDF2-HMAC-SHA256with 600,000 iterations, salt =machine_id. - Storage: Encrypted blob at
~/.config/pop-pay/vault.enc, written atomically (tmp + fsync + rename) with0o600permissions. - Blob format:
nonce(12) || ciphertext || tag(16)(AES-256-GCM). - Salt hardening (hardened builds): Salt is XOR-split into two compiled byte arrays
A1andB2embedded in the Rust.node(or Cython.so). Reconstructed in-memory viaa1 ⊕ b2, used once, then zeroed with thezeroizecrate. - Downgrade defense:
.vault_modemarker file recordshardened/ossat init.loadVault()refuses to proceed if the marker sayshardenedbut the native module is missing or non-hardened.
2. Active attacks
2.1 vault.enc file theft (cold copy)
Attacker with filesystem read access copies vault.enc to another machine for offline cracking. AES-256-GCM authenticated encryption + machine-bound scrypt KDF defends — decryption fails on another machine because machine_id (and/or username) differ.
2.2 Memory dump during decryption
Attacker dumps the Node.js / Python process memory while the vault is unlocked. The Rust layer wipes the reconstructed salt buffer and password buffer via the zeroize crate immediately after scrypt; atomic writes clear tmp files promptly.
2.3 Native binary reverse engineering
Attacker reverse-engineers the compiled native module (Ghidra, IDA Pro) to extract the two XORed salt halves and reconstruct the salt offline. Salt is stored as two static byte arrays; reconstruction happens only inside derive_key at runtime. Variable names obfuscated. Compiled release builds are stripped.
2.4 KDF brute force on passphrase
In passphrase mode, attacker brute-forces a weak user passphrase. PBKDF2-HMAC-SHA256 with 600,000 iterations (OWASP 2023 floor) raises per-guess cost substantially over the default 100k.
2.5 Side-channel: timing on decrypt path
Attacker measures decryption latency to distinguish valid vs invalid keys. AES-GCM verifies the tag in constant time in Node’s OpenSSL binding (and RustCrypto’s aes-gcm on the native path); decryption short-circuits on tag mismatch without leaking key-comparison timing.
2.6 Side-channel: cache attacks on key material
Co-resident process uses FLUSH+RELOAD or similar cache-timing attack to extract AES round keys. On x86-64 / arm64 with AES-NI / ARMv8 crypto extensions, the AES rounds are hardware-backed and cache-resistant.
2.7 Salt recovery via strings / static scan
strings native/pop-pay-native.node | grep ... extracts the salt directly. Defense: salt is never present as a contiguous byte sequence in the binary — only the two XOR halves exist, and neither individually is meaningful.
3. Passive failure mode (product-existential)
Passive failure is the greatest existential threat to pop-pay, and the reason the vault architecture exists. An agent that never tries to attack the vault can still leak real card data — through its own reasoning traces, tool outputs, logs, or screenshots — simply by being present at the moment of unlock. The core product promise is “the agent process never possesses raw credentials.” The scenarios below represent ways that promise can break with no adversarial intent.
3.1 Agent reads console.log / debug output capturing unlocked card
Defended by structural isolation at the MCP tool boundary. Tool returns to the agent are masked (****-4242); raw credentials flow from decryptCredentials directly to the CDP injector, never crossing the MCP response layer.
3.2 Agent reads screenshot / screen recording during checkout
The pop-pay dashboard displays only masked values. Injection into the checkout DOM happens at the last moment before submit, minimizing the unmasked window. Stripe Elements’ cross-origin iframe is the structural defense on compatible checkouts.
3.3 Error message leaks vault path or partial key material
decryptCredentials explicitly throws Error("Failed to decrypt vault — wrong key...") with no payload. Errors are sanitized before reaching stderr.
3.4 Agent curious-read of ~/.config/pop-pay/vault.enc
Encryption at rest. The file is an AES-256-GCM ciphertext blob — no identifiable plaintext remains. Without the machine-bound key, the bytes are computationally indistinguishable from random.
3.5 Tmp / swap / clipboard leakage post-unlock
Atomic rename with fsync + 0o600 permissions for vault.enc tmp.
3.6 Metadata leak via file timestamps
Agent monitors stat ~/.config/pop-pay/vault.enc over time, correlating mtime with observed transactions. Behavioral leak only — no card data is exposed.
3.7 LLM provider receives unlock data via tool-response chain
Current MCP tool surface is strictly masked-only — no tool returns decrypted fields. Type-system enforcement (branded MaskedCard type) is planned for v0.2.
4. Code-path defense map
| Defense area | TS path | Python path |
|---|---|---|
| Encryption-at-rest | src/vault.ts (encryptCredentials) | pop_pay/vault.py (encrypt_credentials) |
| Decryption + auth-tag check | src/vault.ts (decryptCredentials) | pop_pay/vault.py (decrypt_credentials) |
| KDF (machine mode) | native/src/lib.rs (derive_key) | pop_pay/engine/_vault_core.pyx |
| KDF (passphrase mode) | src/vault.ts (derivePassphraseKey) | pop_pay/vault.py (derive_from_passphrase) |
| Salt isolation (XOR halves) | native/src/lib.rs (A1, B2) | pop_pay/engine/_vault_core.pyx |
| Salt / password zeroization | native/src/lib.rs (zeroize) | Cython mirror |
| Atomic vault write | src/vault.ts (saveVault) | pop_pay/vault.py (save_vault) |
| Downgrade defense | src/vault.ts (loadVault) | pop_pay/vault.py (vault_mode check) |
| Error sanitization | src/vault.ts (raise blocks) | pop_pay/vault.py (raise blocks) |
| MCP masked-only surface | src/mcp-server.ts | pop_pay/mcp_server.py |
Source: docs/VAULT_THREAT_MODEL.md in the canonical repo. Mirror Python implementation in pop-pay-python.