Guide
Security
What we defend against, how keys are held today, and the claims we deliberately do not make.
The model
- Bounded. The allowance certificate fixes a cap (funded plus credit), a per-payment limit, a maximum number of payments and an expiry. Country packs and KYC tiers keep those numbers low.
- Detected. Every payment carries a sequence number, a running total and the hash of the previous payment. Two payments that claim the same place in the chain are a fork, and settlement flags it.
- Recovered. A detected fraud revokes the allowance, opens a fraud case and emits
fraud.detected. Any shortfall is taken from the holder's wallet before the risk pool.
Threat model
| Threat | Mitigation |
|---|---|
| Same allowance spent twice at different merchants | Low caps; fork detection at settlement; revocation; recovery from the holder, then the risk pool |
| Replay to the same merchant | One-time request nonce, and the merchant rejects payments it already holds |
| Replay to a different merchant | Every payment is signed over the merchant ID |
| Counter rollback on the device | The hash chain turns a rollback into a fork |
| Payment forged by a merchant | Needs the payer's device private key |
| Forged allowance | Needs the partner's issuer private key; merchants check it against cached issuer keys |
| Stolen unlocked phone | PIN before signing; limited remaining allowance; the allowance can be revoked |
| Revoked or expired allowance | Checked offline against the last synced list and the certificate's expiry. Risk inside the sync window is accepted and bounded |
| Device clock set wrong | Device clocks are not trusted; merchants allow 5 minutes of skew, and payments are ordered by sequence number, not time |
What a merchant checks offline
verifyBundle runs on the merchant's device with no network. It rejects a payment if any of these fail:
- The issuer key is known and the allowance signature is valid.
- The allowance is inside its validity window and not on the cached revocation list.
- The payer's signature is valid for the device key named in the allowance.
- The payment names this merchant and answers this request (nonce, amount, currency).
- The amount is within the per-payment limit, the running total within the cap, and the count within the maximum.
- The payment links correctly to the ones this merchant already holds from the same allowance, with no fork.
A merchant only sees its own payments. A double-spend across two merchants passes both offline checks and is caught at settlement. That is the bounded risk.
How keys are held
| Key | Where it lives today |
|---|---|
| Payer device key (ECDSA P-256) | Generated in the browser as a non-extractable WebCrypto key and stored in IndexedDB. It can sign but cannot be exported |
| App PIN | Never stored. PBKDF2-SHA-256 with 150,000 iterations and a random salt. Five wrong tries lock signing for five minutes |
| Partner issuer keys (ECDSA P-256) | On the server, encrypted at rest with AES-256-GCM under PV_MASTER_KEY |
| Partner API keys | Shown once as pv_<prefix>_<secret>. Only a SHA-256 hash of the secret is stored, compared in constant time |
| Webhook secret | Used to sign each delivery with HMAC-SHA-256 |
See Webhooks for how to verify signatures.
Known limits of the reference implementation
The reference wallet is a web app. That is useful for pilots and has limits a native SDK will remove:
- A browser does not guarantee that a non-extractable key is backed by secure hardware, and it offers no device attestation. Code running in the wallet's origin can ask the key to sign.
- The PIN gates the wallet's signing flow. It is not bound into the key itself.
- The offline queue in IndexedDB is not encrypted beyond what the operating system provides.
- Issuer keys are sealed with a single master key. Production deployments should move them to a KMS or HSM.
- Development shortcuts (email sign-in without Supabase, a fixed master key) are refused when
NODE_ENVisproduction. - No independent security audit has been completed yet.