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

360 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
```rust
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
```rust
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 `VaultRef`s do.