open-standards/openpay/examples/pci-zero/src/main.rs
Transaction Science a437feb8ab Parallel ingest: 9 issues' worth of work across openpay / smart-byte / eoc
openpay/
  compliance/                     PCI-DSS readiness package (scope-map,
                                  pci-zero-architecture, hsm-kms-guidance,
                                  qsa-checklist), citing PCI-DSS v4.0.1
                                  requirements. (#1)
  examples/pci-zero/              Runnable example demonstrating a deployment
                                  where merchant code never touches a PAN. (#1)
  crates/op-core/                 Tokenized<Card> typestate + NetworkToken
                                  + CardNetwork + Cryptogram. (#2)
  crates/op-rails-card/network_token/
                                  NetworkTokenProvider trait + VTS adapter
                                  + MDES adapter, with deterministic stubs
                                  behind a 'live' feature flag. (#2)
  crates/op-driver-sdk/conformance/network_token.rs
                                  Conformance harness for third-party
                                  NetworkTokenProvider implementations. (#2)
  crates/op-cli/src/ledger/       'openpay ledger as-of' subcommand for
                                  bi-temporal queries. (#3)
  crates/op-orchestrator/src/telemetry.rs
                                  OpenTelemetry span emission per
                                  Payment<S> transition with semantic-
                                  conventions tags. (#3)
  docs/observability/             Grafana dashboard JSON + wiring guide
                                  for the bi-temporal ledger lens. (#3)

smart-byte/
  smart-byte-rs/                  Rust reference implementation: envelope
                                  encode/decode (canonical CBOR + BLAKE3
                                  content addressing), Ed25519 sign +
                                  verify, lockstep BFT commit MVP, Iroh
                                  transport scaffold, CLI. (#4)
  spec/identity_and_key_rotation.md
                                  Adopts KERI SAIDs + key-event log
                                  format; explicitly does not adopt ACDC
                                  chaining wholesale. (#5)
  conformance/v1/                 21 test vectors covering canonicalisation,
                                  SAID computation, signature verification,
                                  lockstep commit, gossip propagation.
                                  CI gate enforces spec/vector coupling. (#6)

eoc/
  eoc-rs/                         Rust + WASM reference implementation:
                                  4-stage cascade (cache -> kv -> graph ->
                                  neural), joule counters via RAPL / NVML /
                                  powermetrics, CLI, wasm-bindgen surface.
                                  18 tests pass. (#7)
  eoc-bench/                      Benchmark harness, MT-Bench and Router
                                  case sets, 'joules per MT-Bench point'
                                  metric, 'eoc-bench run | compare' CLI. (#8)
  standards/sci-for-ai-submission.md
                                  Submission proposal mapping EOC's
                                  receipt schema to GSF SCI-for-AI. (#9)
  cookbook/                       HuggingFace-style cookbook (markdown +
                                  runnable Python) demonstrating EOC's
                                  cascade with HF AI Energy Score. (#9)
  docs/bench-v0.md                Reproducibility doc for eoc-bench.

Workspace state:
- openpay clippy -- -D warnings: clean across all touched crates
- smart-byte-rs cargo test --workspace + clippy: clean
- eoc-rs cargo test --workspace + clippy: clean (18 tests)
- eoc-bench cargo check --workspace + clippy + 15 tests: clean

Rust 1.95.0 edition 2024 pinned at the open-standards root via
rust-toolchain.toml; every workspace inherits.

Closes #1 #2 #3 #4 #5 #6 #7 #8 #9.
2026-05-23 13:24:05 -04:00

326 lines
15 KiB
Rust

//! # `pci-zero` — Code-form proof of the PCI-zero topology
//!
//! Single-process Rust binary that demonstrates the same data flow the
//! `compliance/pci-zero-architecture.md` document describes. The
//! components are wired in-process here so the flow is auditable as
//! one program, but the *deployment* topology splits them across hosts
//! and network segments — see the companion document for that.
//!
//! ## What this binary proves
//!
//! 1. The merchant code path (the contents of `merchant::checkout`)
//! holds nothing but `op_core::VaultRef` opaque tokens, `Money`,
//! and `Payment<S>` typestate values. There is no `RawPan` import,
//! no `CardData` value, and no PAN-bearing string anywhere in
//! merchant-owned code.
//! 2. The vault layer (the only caller of `op_vault::CardData::new`)
//! is the sole holder of plaintext PAN. The browser-side
//! `collection_iframe::submit` function is the architectural
//! stand-in for the iframe served from the vault's domain; in
//! deployment it runs on the customer's device and submits over
//! TLS to a vault host on a separate network segment.
//! 3. The rail driver (`mock_card::PciZeroCardAcquirer`) calls
//! `Vault::detokenize` exactly once at acquirer-submit time, posts
//! the (mock) acquirer request, and drops the recovered `CardData`
//! immediately so `ZeroizeOnDrop` clears the PAN bytes.
//!
//! ## What this binary does NOT prove
//!
//! - That AES-256-GCM-SIV is correctly implemented. The reference
//! `InMemoryVault` is for tests and development only; see
//! `crates/op-vault/src/in_memory.rs` for the cryptographic
//! acknowledgements and `compliance/hsm-kms-guidance.md` for the
//! production HSM / KMS shape.
//! - That the network boundary is enforced. In this binary,
//! "boundary" is module visibility (`pub(crate)`) — in deployment,
//! it's a firewall, mTLS, and a different VPC / namespace.
//!
//! ## Running
//!
//! ```bash
//! cargo run -p pci-zero
//! ```
//!
//! Expected output: a trace of the four boundary crossings (browser →
//! vault, app server → orchestrator → card rail, card rail → vault for
//! detokenize, card rail → acquirer) and an `APPROVED` terminal status.
#![forbid(unsafe_code)]
use std::sync::Arc;
// ─────────────────────────────────────────────────────────────────────────
// Merchant code path. This is the surface that compiles into the merchant
// application server in deployment. It must hold only opaque tokens —
// VaultRef, Money, Payment<S>. No raw PAN.
// ─────────────────────────────────────────────────────────────────────────
mod merchant {
use op_core::{Currency, Money, PaymentMethod, VaultRef};
use op_fraud::HeuristicScorer;
use op_orchestrator::{
CardAdapter, IdempotencyKey, Orchestrator, OrchestrationOutcome, PaymentIntent,
PolicyRouter, Result, TerminalStatus,
};
use op_rails_card::CardAcquirer;
use std::sync::Arc;
/// The merchant application server. Holds an `Orchestrator`
/// wired with a single card driver. No vault handle here —
/// the *driver* owns the vault reference, because the driver
/// is the only place that legitimately calls `detokenize`.
pub struct Merchant {
orchestrator: Orchestrator,
}
impl Merchant {
pub fn new(acquirer: Arc<dyn CardAcquirer>) -> Self {
let mut orchestrator = Orchestrator::new()
.with_scorer(Box::new(HeuristicScorer::new()))
.with_router(Box::new(PolicyRouter::new(
vec!["pci-zero-card".into()],
vec![], // no A2A in this demo
)));
orchestrator.register_adapter(Arc::new(CardAdapter::new(
"pci-zero-card",
acquirer,
)));
Self { orchestrator }
}
/// Run a checkout. Inputs are all out-of-scope: an order id,
/// an amount in minor units, and a `VaultRef`. The merchant
/// code path *cannot* construct a `RawPan` even if it wanted
/// to — `op_core::method::pci::RawPan` is gated behind the
/// `pci-scope` feature and the merchant module deliberately
/// does not depend on `op-vault`.
pub fn checkout(
&self,
order_id: &str,
amount_minor: i64,
token: VaultRef,
) -> Result<OrchestrationOutcome> {
let intent = PaymentIntent::new(
IdempotencyKey::new(order_id),
Money::from_minor(amount_minor, Currency::USD),
PaymentMethod::Vault(token),
)
.with_metadata("order_id", order_id);
self.orchestrator.run(&intent)
}
}
/// Stringify a terminal status for the demo printout.
pub fn status_label(s: TerminalStatus) -> &'static str {
match s {
TerminalStatus::Approved => "APPROVED",
TerminalStatus::RequiresCustomerAction => "ACTION REQUIRED",
TerminalStatus::Declined => "DECLINED",
}
}
}
// ─────────────────────────────────────────────────────────────────────────
// Customer browser. In deployment this is JavaScript in a TLS-isolated
// iframe served from the vault's own domain — the merchant's outer page
// cannot read it (Same-Origin Policy). Here it's a Rust module whose
// `submit` function is the only caller in the program that constructs
// CardData (i.e. the only place that holds raw PAN).
// ─────────────────────────────────────────────────────────────────────────
mod collection_iframe {
use op_core::VaultRef;
use op_vault::{CardData, TokenizationPolicy, Vault};
/// "Browser" side: the customer types a PAN into a TLS-isolated
/// iframe. The iframe's JS calls the vault directly (TLS 1.3,
/// cert-pinned) and returns the resulting `VaultRef` to the
/// outer merchant page via `window.postMessage`.
///
/// The function takes `&dyn Vault` rather than a concrete vault
/// type to match the deployment shape: the iframe is talking to
/// the *vault service*, not to an in-process vault.
pub fn submit(
vault: &dyn Vault,
pan: &str,
exp_month: u8,
exp_year: u16,
) -> Result<VaultRef, Box<dyn std::error::Error>> {
// CardData construction validates length, Luhn, and
// expiration. The PAN string lives in `card_data` until
// `tokenize` consumes it; ZeroizeOnDrop clears the bytes
// when this function returns.
let card_data = CardData::new(pan, exp_month, exp_year)?;
let token = vault.tokenize(card_data, TokenizationPolicy::default())?;
Ok(token)
}
}
// ─────────────────────────────────────────────────────────────────────────
// Card rail driver. The ONLY component (other than the vault itself)
// that holds CardData, and only for the brief window between
// `detokenize` and the acquirer submit. In deployment this driver lives
// on the merchant app server but holds CardData for milliseconds at most.
// ─────────────────────────────────────────────────────────────────────────
mod mock_card {
use op_core::PaymentMethod;
use op_rails_card::{
AuthDecision, AuthRequest, CaptureRequest, CardAcquirer, Error, RefundRequest, Result,
VoidRequest,
acquirer::AuthStatus,
};
use op_vault::Vault;
use std::sync::Arc;
/// A `CardAcquirer` that detokenizes from a vault and posts to a
/// (mock) acquirer. Models the real Hyperswitch / Stripe / Adyen
/// drivers, which do the same thing.
pub struct PciZeroCardAcquirer {
vault: Arc<dyn Vault>,
}
impl PciZeroCardAcquirer {
pub fn new(vault: Arc<dyn Vault>) -> Self {
Self { vault }
}
}
impl CardAcquirer for PciZeroCardAcquirer {
fn name(&self) -> &'static str {
"pci-zero-card"
}
fn supports(&self, method: &PaymentMethod) -> bool {
matches!(method, PaymentMethod::Vault(_))
}
fn authorize(&self, req: &AuthRequest) -> Result<AuthDecision> {
// The driver receives only a VaultRef. It performs the
// single privileged operation in the entire flow:
// detokenize, post to the acquirer, drop the CardData.
let token = match &req.method {
PaymentMethod::Vault(v) => v.clone(),
_ => return Err(Error::UnsupportedMethod),
};
// Detokenize. In deployment this is an mTLS call to the
// vault service; here it's a direct trait dispatch.
let card_data = self
.vault
.detokenize(&token)
.map_err(|e| Error::DriverValidation(format!("vault detokenize: {e}")))?;
// Post to the (mock) acquirer. In a real driver this
// would be a ureq POST to the PSP's authorize endpoint
// with the PAN in the JSON body. We extract the
// last-four for receipt purposes; the full PAN never
// leaves this scope.
let last_four = card_data.last_four().to_string();
let raw_status = format!(
"mock-acquirer-approved card={}******{}",
card_data.first_six(),
last_four
);
// `card_data` is dropped at end of scope; ZeroizeOnDrop
// wipes the PAN bytes before this function returns.
drop(card_data);
Ok(AuthDecision {
psp_payment_id: format!("acq_{}", req.idempotency_key),
status: AuthStatus::Settled,
raw_status,
authorized_amount: Some(req.amount),
redirect_url: None,
error_code: None,
error_message: None,
})
}
fn capture(&self, _req: &CaptureRequest) -> Result<AuthDecision> {
Err(Error::DriverValidation(
"mock driver: auto-capture only".into(),
))
}
fn void(&self, _req: &VoidRequest) -> Result<AuthDecision> {
Err(Error::DriverValidation("mock driver: no void".into()))
}
fn refund(&self, _req: &RefundRequest) -> Result<AuthDecision> {
Err(Error::DriverValidation("mock driver: no refund".into()))
}
}
}
fn main() {
eprintln!("=== OpenPay pci-zero topology demo ===");
eprintln!("Boundary crossings in this run:");
eprintln!(" [B1] browser -> vault (tokenize, raw PAN)");
eprintln!(" [B2] merchant -> orchestrator (VaultRef only)");
eprintln!(" [B3] driver -> vault (detokenize, scoped)");
eprintln!(" [B4] driver -> acquirer (raw PAN, mock)\n");
// The vault is the CDE. In deployment, this is a different process
// on a different host in a different VPC. The `Arc<dyn Vault>` is
// the architectural stand-in for the mTLS endpoint exposed by the
// vault service.
let vault: Arc<dyn op_vault::Vault> =
Arc::new(op_vault::InMemoryVault::ephemeral("demo-vault"));
// The card rail driver is the only consumer of the vault that
// legitimately calls `detokenize`. The merchant code path never
// touches the vault directly.
let acquirer = Arc::new(mock_card::PciZeroCardAcquirer::new(Arc::clone(&vault)));
// The merchant application server. Notice the constructor receives
// a `dyn CardAcquirer` — it doesn't even know there's a vault.
let merchant = merchant::Merchant::new(acquirer);
// ─────────────────────────────────────────────────────────────
// [B1] Browser submits PAN to the vault.
// ─────────────────────────────────────────────────────────────
eprintln!("[B1] Customer types card into vault-served iframe.");
let token = collection_iframe::submit(
vault.as_ref(),
"4242424242424242", // Stripe / OpenPay shared test card
12,
2030,
)
.expect("vault tokenize");
eprintln!(" vault returned VaultRef = {}", token.as_str());
// ─────────────────────────────────────────────────────────────
// [B2] Merchant runs the orchestrator with a VaultRef only.
// ─────────────────────────────────────────────────────────────
eprintln!("\n[B2] Merchant runs orchestrator with VaultRef.");
let outcome = merchant
.checkout("ORDER-PCI-ZERO-001", 4299, token)
.expect("checkout");
// ─────────────────────────────────────────────────────────────
// Print the result. The orchestrator already drove [B3] (the
// driver's detokenize call) and [B4] (the driver's acquirer
// submit) during checkout().
// ─────────────────────────────────────────────────────────────
eprintln!("\n[B3/B4] driver detokenized + posted to mock acquirer.");
eprintln!(
" terminal status: {}",
merchant::status_label(outcome.terminal_status)
);
eprintln!(" attempts: {}", outcome.attempts.len());
if let Some(id) = &outcome.psp_payment_id {
eprintln!(" psp_payment_id: {id}");
}
for (i, a) in outcome.attempts.iter().enumerate() {
eprintln!(
" [{i}] rail={:?} driver={} outcome={:?}",
a.rail, a.driver, a.outcome
);
}
eprintln!("\n=== Done. Merchant code path held VaultRef only. ===");
}