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.
17 KiB
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:
- They cannot access the vault, keys, or detokenization.
- Tokens have no value for PAN recovery.
- 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:
- Length: 12-19 digits per ISO/IEC 7812.
- Luhn (mod-10): rejects typos. Doesn't prove legitimacy but catches accidental garbage.
- 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:
NotFoundandAuthFailedare 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.ExpiredandAlreadyConsumedare 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 fromNotFoundbecause 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.1live documentation:Aes256GcmSiv::generate_key(&mut OsRng)returnsKey<Aes256GcmSiv>;generate_nonce(&mut OsRng)returnsNonce<Aes256GcmSiv>(12 bytes);cipher.encrypt(&nonce, plaintext)returnsVec<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
HashSetaccepts 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
-
A2aKeywas not re-exported fromop-core. Phase 5 added 9+ call sites usingop_core::A2aKey::*, but the re-export atop-core/src/lib.rsonly listedPaymentMethod, Token, VaultRef. Phases 4-5 would have failedcargo buildwith "unresolved import"; the sandbox has no Rust toolchain so the gap was invisible. AddedA2aKeyto the existing re-export line. Phase-7 deliverable that retroactively fixes Phase 5. -
RawPanwas crate-private toop-core. Thepcimodule ispub(crate) mod pci, which preventedop-vaultfrom namingop_core::method::pci::RawPan. Added a feature-gated re-export#[cfg(feature = "pci-scope")] pub use method::pci::RawPan;at theop-corecrate root. -
Insufficient accessors on
RawPan. Onlylast_fourwas public. Addedfirst_six(BIN, PCI §3.4.1 allowed),pan_bytes(for vault-side encryption),exp_month, andexp_year. All only available whenpci-scopeis enabled. -
GenericArray<u8, U12>→[u8; 12]conversion ambiguity. Initially used.into()which has multiple candidate trait impls acrossgeneric-arrayversions and may not be inferrable. Switched to explicitcopy_from_slice(nonce.as_slice())which is unambiguous. -
Unused
subtledependency. 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. TheVaulttrait 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
VaultRefstrings; 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
Vaulttrait is the natural seam; we'll add awith_auditdecorator in Phase 7.1 that wraps an arbitraryVaultimpl 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
Vaultimpl. → 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.