2026-04-15

Vault cryptography

AES-256-GCM at rest, scrypt with compiled-salt hardening, downgrade defense, and the active-attack matrix for the credential vault.

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

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 areaTS pathPython path
Encryption-at-restsrc/vault.ts (encryptCredentials)pop_pay/vault.py (encrypt_credentials)
Decryption + auth-tag checksrc/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 zeroizationnative/src/lib.rs (zeroize)Cython mirror
Atomic vault writesrc/vault.ts (saveVault)pop_pay/vault.py (save_vault)
Downgrade defensesrc/vault.ts (loadVault)pop_pay/vault.py (vault_mode check)
Error sanitizationsrc/vault.ts (raise blocks)pop_pay/vault.py (raise blocks)
MCP masked-only surfacesrc/mcp-server.tspop_pay/mcp_server.py

Source: docs/VAULT_THREAT_MODEL.md in the canonical repo. Mirror Python implementation in pop-pay-python.