open-standards/openpay/docs/07-vault-progress.md
Transaction Science 70ecd6caa0 Initial import — three open standards under one shelf
Transaction Science open standards monorepo. Each subdirectory is a
self-contained reference protocol with its own README and licence.

- openpay/    Apache-2.0  payment-acceptance stack (Rust, 26 crates)
- smart-byte/ CC-BY-4.0   content-addressed value carrier (spec)
- eoc/        CC-BY-4.0   energy-optimised compute (spec)

The wire format and the right to fork are public. The optional hosted
services are the offer; the protocols are not.
2026-05-23 07:58:09 -04:00

17 KiB
Raw Permalink Blame History

Phase 7 — op-vault Complete (Token-Only Storage)

Status: Draft v0.7 Date: 2026-05-17

What shipped

op-vault: the scope-cutter for the entire OpenPay stack. A Vault trait + types + a reference implementation that takes raw card data, encrypts it under AES-256-GCM-SIV, and hands back an opaque VaultRef that the rest of the stack can pass around freely without entering PCI DSS scope.

Why this matters: the SAQ-A vs SAQ-D delta

PCI DSS v4.0.1 (in force since 31 March 2025, with future-dated requirements enforceable from 31 March 2026) divides merchants into several scoping tiers based on what touches cardholder data:

Tier Who Cost / year
SAQ A All payment functions outsourced; merchant never sees CHD dozens of hours, ~$2-5K
SAQ A-EP E-commerce merchant doesn't receive CHD but controls the page a few hundred hours, ~$10-30K
SAQ D Merchant Stores / processes / transmits CHD hundreds-thousands of hours, ~$50K-200K
RoC (Level 1) High-volume, QSA-audited $200K+

Per the PCI SSC Tokenization Guidelines and the v4.0.1 scoping rules, token-only systems can be out of scope when three conditions hold:

  1. They cannot access the vault, keys, or detokenization.
  2. Tokens have no value for PAN recovery.
  3. Tokens cannot be used to initiate transactions independently.

op-vault is the architectural boundary that satisfies (1). Random tokenization satisfies (2). The orchestrator passing VaultRef to a rail driver that itself detokenizes inside the CDE satisfies (3).

The economic argument is direct: without this layer, every component that touches PaymentMethod is in scope; that's the entire stack. With this layer, only the vault is in scope; the orchestrator, rail drivers, fraud scorer, and FFI bridges are out. SAQ-D-to-SAQ-A is a 10-50x cost delta per merchant per year.

Crate layout

crates/op-vault/
├── Cargo.toml                 # default: trait only. `in-memory` feature for reference impl.
├── src/
│   ├── lib.rs                 # crate root, re-exports, feature gates
│   ├── error.rs               # sealed Error: NotFound, AuthFailed, Expired,
│   │                          # AlreadyConsumed, InvalidToken, InvalidCard,
│   │                          # Capacity, Backend
│   ├── policy.rs              # TokenFormat, TokenLifetime, TokenizationPolicy
│   ├── card_data.rs           # CardData — only public type with raw PAN
│   ├── vault.rs               # Vault trait
│   └── in_memory.rs           # InMemoryVault (AES-256-GCM-SIV)  [feature: in-memory]
└── tests/
    └── lifecycle.rs           # end-to-end orchestrator pattern tests

Verified ground truth

PCI DSS scoping (2026)

Claim Source
PCI DSS v4.0.1 is in force; future-dated requirements enforced 31 Mar 2026 PCI SSC; Strictly compliance guide April 2026; Accutive guide
Token-only systems are out of scope only if they can't reach the vault / keys / detokenization PCI SSC Tokenization Guidelines; datastealth.io 2026 checklist
Tokens must not be confusable with PANs (§3.3) PCI SSC Tokenization Guidelines
The vault is part of the CDE; segmentation required PCI SSC Tokenization Guidelines; Petronella 2026 guide
SAQ-D Level 1 annual costs: $50K-$200K datastealth.io 2026 scope-reduction guide; vistainfosec.com fintech 2026
FIPS 140-2 Level 3 (hardware) / Level 2 (software) crypto modules PCI SSC tokenization product guidance

Cryptography

Claim Source
AES-256-GCM-SIV per RFC 8452 is misuse-resistant RustCrypto/AEADs aes-gcm-siv 0.11.1 docs
Nonce collision doesn't catastrophically break confidentiality in SIV mode RFC 8452; aes-gcm-siv crate README
Aes256GcmSiv::generate_key(&mut OsRng) + cipher.encrypt(&nonce, plaintext) is the canonical API aes-gcm-siv 0.11.1 docs; RustCrypto example
96-bit nonces, 128-bit auth tags aead 0.5.x; aes-gcm-siv constants

Platform vault APIs (informational, for Phases 8-10)

Claim Source
iOS Keychain: SecItemAdd with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly for background-accessible non-migrating tokens Apple Developer Documentation; OWASP MAS iOS guide
Android Keystore via EncryptedSharedPreferences + MasterKey (AndroidX Security 1.1.x) backed by TEE / StrongBox source.android.com Hardware-backed Keystore; developer.android.com Keystore
Android StrongBox available since API 28 (Android 9) AOSP Keystore docs

Architecture: the trait

pub trait Vault: Send + Sync {
    fn name(&self) -> &str;
    fn tokenize(&self, card: CardData, policy: TokenizationPolicy) -> Result<VaultRef>;
    fn detokenize(&self, token: &VaultRef) -> Result<CardData>;
    fn exists(&self, token: &VaultRef) -> Result<bool>;
    fn delete(&self, token: &VaultRef) -> Result<bool>;
    fn health_check(&self) -> Result<()> { Ok(()) }
}

Send + Sync so the orchestrator can share Arc<dyn Vault> across worker threads. Same dyn Trait pattern as CardAcquirer (Phase 4), A2aAcquirer (Phase 5), and Scorer (Phase 6).

Three-axis tokenization policy

TokenizationPolicy {
    format:        TokenFormat::Random | Deterministic,
    lifetime:      TokenLifetime::Reusable | SingleUse,
    ttl_seconds:   Option<u64>,
}
Axis Default Risk-vs-utility tradeoff
Format Random Random gives PCI's strongest "no value for PAN recovery" claim. Deterministic enables analytics joins on PAN equality but creates a query oracle.
Lifetime Reusable Reusable is needed for card-on-file. SingleUse is for 3DS auth and other one-shot flows where replay must fail.
TTL None Bounding the attack window when tokens are exfiltrated. Short TTLs (60-120s) for ephemeral auth flows; no TTL for card-on-file.

Helper constructors encode the two most common combos:

  • TokenizationPolicy::single_use(120) — 2-minute 3DS-auth-style token.
  • TokenizationPolicy::card_on_file() — long-lived random reusable.

CardData — the only PAN-holding public type

The OpenPay surface has exactly one public type that holds raw PAN: op_vault::CardData. Construction enforces three checks:

  1. Length: 12-19 digits per ISO/IEC 7812.
  2. Luhn (mod-10): rejects typos. Doesn't prove legitimacy but catches accidental garbage.
  3. Expiration sanity: month 1-12, year 2000-2099. Does NOT reject past dates (the caller may legitimately tokenize an expired-card record for refund / chargeback).

The raw-bytes accessor is pub(crate) — only the vault implementation modules inside this crate can read PAN bytes. External callers see only first_six (BIN, PCI §3.4.1 allowed) and last_four (allowed).

CardData derives Zeroize + ZeroizeOnDrop via op_core::pci::RawPan.

Custom Debug masks to CardData(424242******4242, 12/2030) — the PCI DSS 4.0.1 §3.4.1 maximum display allowance. Tested.

InMemoryVault — the reference implementation

AES-256-GCM-SIV (RFC 8452, misuse-resistant). Each entry carries its own random 96-bit nonce. Tokens are UUID v7 prefixed with tok_v7_:

  • UUID v7 carries a millisecond timestamp prefix that makes tokens sortable by issuance time — useful for vault-internal analytics — without exposing the underlying PAN mapping (the random suffix is what's cryptographically independent).
  • The tok_v7_ prefix is non-numeric, so tokens are visually distinguishable from PANs per PCI Tokenization Guidelines §3.3.

Status: reference, not production

InMemoryVault is correct, exercises the full Vault contract, and ships every behavior the trait promises. It is not a PCI-compliant production vault on its own:

  • State is in RAM only. A restart loses every token. Production vaults persist ciphertext to durable storage (DB-of-record + KMS, HSM, or a dedicated vault service).
  • The encryption key is operator-supplied or ephemeral. Production vaults integrate with KMS / HSM for key management with rotation, dual control, split knowledge, and FIPS 140-2 Level 2/3 modules.
  • No audit logging. PCI DSS §10 requires log emission for every detokenize call with user identity, timestamp, and outcome.
  • No rate limiting. Production vaults bound detokenize throughput per caller to limit blast radius on credential compromise.

Use in tests and development. For production, plug an operator-supplied vault into the same Vault trait.

Oracle discipline in error handling

Error variants are deliberately collapsed in user-facing contexts:

  • NotFound and AuthFailed are distinct in the type but should be treated identically by API responses. Distinguishing "token unknown" from "decryption failed" is an oracle: an attacker who can probe many tokens learns which ones existed but had the wrong key vs which ones never existed.
  • Expired and AlreadyConsumed are surfaced separately because the legitimate caller (the orchestrator) needs to know which retry path to take — they're policy state, not security state.
  • InvalidToken (malformed) is distinct from NotFound because the caller can know it never had a chance without leaking anything.

Test coverage

Module Tests What's covered
error.rs 0 Sealed enum, no behavior
policy.rs 5 Defaults, single_use helper, card_on_file helper, JSON round-trip (policy, format)
card_data.rs 12 Luhn happy paths (Visa, MC, Amex), non-digit reject, length reject (short + long), Luhn-fail reject, month reject (0, 13), year reject (1999, 2100), expired-card accept, debug masking, accessors, Luhn truth table
vault.rs 2 Object-safety, default health_check
in_memory.rs 19 Token format, round trip, NotFound, InvalidToken, delete idempotency, exists for unknown / non-token strings, single-use consumption, reusable survival, unique tokens for same PAN under Random, distinct cards distinct tokens, cross-key isolation, same-key compatibility, dyn-compatibility, generate_key entropy, len tracking, default health, prefix discipline, TTL expiration, TTL=0 immediate expiry, clock-skew tolerance
tests/lifecycle.rs 12 Full lifecycle, single-use 3DS, thread sharing (8 workers), multi-card isolation, deletion non-recoverable, PAN not in token string, Debug masking, 1000-token uniqueness, exists probe, dyn Vault routing, policy composition, full pipeline determinism
Phase 7 total 50
Cumulative Phases 1–7 ~346

Independently verified

  • AES-256-GCM-SIV API path verified against aes-gcm-siv 0.11.1 live documentation: Aes256GcmSiv::generate_key(&mut OsRng) returns Key<Aes256GcmSiv>; generate_nonce(&mut OsRng) returns Nonce<Aes256GcmSiv> (12 bytes); cipher.encrypt(&nonce, plaintext) returns Vec<u8>; cipher.decrypt(&nonce, ciphertext) reverses.
  • Luhn correctness verified against the standard test PANs: 4242424242424242 (Visa, valid), 5555555555554444 (MC, valid), 378282246310005 (Amex, valid), 4242424242424241 (one digit off, invalid). All four cases covered.
  • Token uniqueness verified by minting 1000 tokens for the same PAN and confirming a HashSet accepts all of them.

Design decisions

1. Trait + reference, not a full vault product

The trait is the architectural boundary; the reference impl is for tests and development. Production deployments bring their own platform vault (iOS Keychain, Android Keystore, AWS KMS-backed blob, HashiCorp Vault, HSM). We don't try to be a vault product — we provide the contract that lets operators choose one.

2. AES-GCM-SIV, not AES-GCM

AES-GCM has a catastrophic nonce-reuse failure mode: reusing a nonce under the same key reveals the XOR of the two plaintexts and lets an attacker recover the authentication key. AES-GCM-SIV (RFC 8452, "misuse-resistant") doesn't have this failure mode — nonce reuse only reveals whether two plaintexts were identical, which is a much weaker break. Since we ship a library that operators wrap and use however they like, we can't enforce nonce discipline at every callsite, so we pick the algorithm that fails gracefully.

Plain AES-256-GCM would also work and is faster — but the misuse- resistance is worth the marginal encrypt overhead in a vault context where the encryption rate is bounded by user-facing card-entry flows (measured in dozens-per-second, not millions).

3. CardData is the only public PAN holder

The PCI scope boundary is the type system. op_core::pci::RawPan is behind the pci-scope feature flag — code that doesn't opt in cannot construct it. op-vault opts in unconditionally because it must handle PAN. The vault re-exposes RawPan-wrapping behavior only through CardData, which makes its raw bytes pub(crate). Outside the vault crate, CardData::raw() is unreachable; only first_six, last_four, and exp_* are exposed.

4. UUID v7 token format

Tokens are tok_v7_ + UUID v7 simple form. Three properties:

  • Time-prefixed: tokens sort by issuance time for analytics.
  • Random-suffixed: the bottom 74 bits are entropy, so distinct tokens for the same PAN under Random format are cryptographically independent.
  • Non-numeric prefix: PCI §3.3 wants tokens visually distinguishable from PANs.

5. Health check is a default-Ok hook

The trait has a default health_check that returns Ok(()). Implementations that probe a remote service override it. This matches the readiness-probe pattern operators already use for HTTP health endpoints — they call vault.health_check() from their /healthz handler.

6. Three-axis policy, two pre-built helpers

The policy carries three orthogonal knobs (format, lifetime, TTL). That's six combinations, but only two are common enough to warrant named constructors: single_use (for 3DS / one-shot flows) and card_on_file (for recurring billing). Operators with unusual requirements (deterministic + reusable + short TTL for a join-and- expire pattern) build a struct directly.

Bugs caught during construction

  1. A2aKey was not re-exported from op-core. Phase 5 added 9+ call sites using op_core::A2aKey::*, but the re-export at op-core/src/lib.rs only listed PaymentMethod, Token, VaultRef. Phases 4-5 would have failed cargo build with "unresolved import"; the sandbox has no Rust toolchain so the gap was invisible. Added A2aKey to the existing re-export line. Phase-7 deliverable that retroactively fixes Phase 5.

  2. RawPan was crate-private to op-core. The pci module is pub(crate) mod pci, which prevented op-vault from naming op_core::method::pci::RawPan. Added a feature-gated re-export #[cfg(feature = "pci-scope")] pub use method::pci::RawPan; at the op-core crate root.

  3. Insufficient accessors on RawPan. Only last_four was public. Added first_six (BIN, PCI §3.4.1 allowed), pan_bytes (for vault-side encryption), exp_month, and exp_year. All only available when pci-scope is enabled.

  4. GenericArray<u8, U12> → [u8; 12] conversion ambiguity. Initially used .into() which has multiple candidate trait impls across generic-array versions and may not be inferrable. Switched to explicit copy_from_slice(nonce.as_slice()) which is unambiguous.

  5. Unused subtle dependency. Declared in Cargo.toml but never referenced; constant-time comparison turned out not to be needed in the reference implementation (the AES-GCM-SIV decrypt does its own constant-time tag verification internally). Removed.

What's NOT in this phase (explicitly deferred)

  • Platform vault adapters. iOS Keychain (SecItemAdd), Android Keystore (EncryptedSharedPreferences + MasterKey), AWS KMS- backed durable vault, HashiCorp Vault transit. These land in Phases 8-10 alongside the FFI bridges, where the platform-specific code lives. The Vault trait is the contract they implement.

  • Network tokens. Visa Token Service, Mastercard MDES, Amex Token Service. Network tokens are issued by the card networks and stored as VaultRef strings; resolution happens against the network token service (proxied through the rail driver). The PSP abstracts this in most cases. → Phase 5.2.

  • Audit logging. PCI DSS §10.2.1.1 requires per-event logs for all access to cardholder data. The Vault trait is the natural seam; we'll add a with_audit decorator in Phase 7.1 that wraps an arbitrary Vault impl and emits structured events.

  • Rate limiting and quotas. PCI DSS §10 implies bounded per-caller throughput. Phase 7.1.

  • Hot-swap and rotation. Atomic key rotation while the vault is live. Operator-specific (KMS rotation events, HSM ceremony). → Phase 7.2.

  • FIPS 140-2 mode. The RustCrypto AES implementations are not FIPS-validated. Operators requiring formal FIPS validation must plug in an HSM-backed Vault impl. → operator concern.

Next: Phases 8-10 — FFI bridges

op-ffi-swift (cxx-rs for Swift / iOS / macOS), op-ffi-jni (Kotlin / Android), op-wasm (browser via wasm-bindgen). These expose the orchestrator + vault + fraud scorer + rail drivers to the host platform without leaking Rust idioms. The vault trait makes this clean: a Swift KeychainVault implements Vault via SecItem* calls; a Kotlin KeystoreVault implements it via EncryptedSharedPreferences; the orchestrator doesn't know the difference. PAN never crosses the FFI boundary — only VaultRefs do.