draft-chau-ap2-pq-spend-envelope-00 · https://pqsafe.xyz/spec/ap2-pq-v1/
This document is the first published implementation profile of AP2 for post-quantum digital signatures.
It specifies the PQ-SignedSpendEnvelope wire format, the ML-DSA-65 signing and verification
protocol, and NIST ACVP-aligned conformance test vectors for autonomous AI agent payment authorization.
It is not a claim of ownership over the AP2 specification itself; AP2 is housed within the FIDO Alliance.
The Agent Protocol v2 (AP2) currently mandates ECDSA P-256 for SpendEnvelope signing. Shor's algorithm renders ECDSA vulnerable to a cryptographically relevant quantum computer (CRQC). This document specifies the AP2-PQ profile: an extension that registers ML-DSA-65 (NIST FIPS 204, Category 3) as the required post-quantum signature algorithm for AP2 spend envelopes.
The profile defines: the PQ-SignedSpendEnvelope JSON wire format and JSON Schema (Draft 2020-12);
JCS canonicalization per RFC 8785; the SHA-256 fingerprint construction; ML-DSA-65 signing and
7-check verification procedures; a three-phase ECDSA-to-PQ migration path; and six NIST ACVP-aligned
conformance test vectors (five positive, one negative). Implementations following this specification
achieve at least 128 bits of post-quantum security for all AP2 agent-to-agent payment authorization flows.
This specification has been proposed to the FIDO Alliance Agentic Authentication TWG (formed 2026-04-28) for consideration as an Informational extension to the AP2 protocol. Formal submission pending paid membership.
This document is an Informational specification published by PQSafe Inc. It is not an IETF Standard, nor is it a FIDO Alliance Recommendation at this time. It has been proposed to the FIDO Alliance Agentic Authentication Technical Working Group for consideration via open letter (May 2026).
Implementers are advised that this document is a working specification and may be updated. Any party
implementing the AP2-PQ profile should reference this URL
(https://pqsafe.xyz/spec/ap2-pq-v1/) to ensure they are using the canonical version.
The key words must, must not, required, shall, shall not, should, should not, recommended, not recommended, may, and optional in this document are to be interpreted as described in BCP 14 [RFC2119] [RFC8174] when, and only when, they appear in all capitals.
All byte sequences in this document are encoded as lowercase hexadecimal unless otherwise noted. Integer byte lengths are exact. Base64url refers to the URL-safe encoding of RFC 4648 §5 without padding.
signature object uses the ap2-mldsa65 algorithm identifier as defined in this document, with an ML-DSA-65 signature over the AP2 Fingerprint.signature member absent. This is the exact byte sequence that ML-DSA-65 signs and verifies.The AP2-PQ profile introduces a post-quantum cryptographic layer to the AP2 spend envelope lifecycle. The Issuer signs the envelope offline using their ML-DSA-65 key pair. The Agent carries the signed envelope and presents it at the payment rail gateway (Verifier). The Verifier performs signature verification and policy enforcement before executing the payment.
┌─────────────────────────────────────────────────────────────────────┐
│ AP2-PQ PROTOCOL FLOW │
└─────────────────────────────────────────────────────────────────────┘
┌──────────┐ ┌──────────────────────────────────────────────────┐
│ │ │ SpendEnvelope │
│ ISSUER │────▶│ { issuer, agent, max_amount, currency, │
│ (Human) │sign │ allowed_recipients, valid_from, valid_until, │
│ │ │ nonce, rail, │
└──────────┘ │ signature: { alg: "ap2-mldsa65", │
│ │ value: <ML-DSA-65 sig, 3309B>, │
│ │ dsaPublicKey: <pk, 1952B> } │
│ └─────────────────────────────────────────────────┘
│ │
│ ┌──────────────────▼─────────────────────────────┐
│ │ AGENT │
│ │ Carries signed envelope; presents to Verifier │
│ └──────────────────┬─────────────────────────────┘
│ │
│ ┌──────────────────▼─────────────────────────────┐
│ │ VERIFIER │
│ │ before_tool_call hook │
│ │ │
│ │ 1. Recompute fingerprint (JCS → SHA-256) │
│ │ 2. ml_dsa65.verify(sig, fingerprint, pubkey) │
│ │ 3–7. Policy checks (see §7) │
│ └──────────────────┬─────────────────────────────┘
│ │
│ ┌──────────────────▼─────────────────────────────┐
│ │ RAIL │
│ │ Airwallex · Wise · Stripe · USDC-Base · x402 │
│ └────────────────────────────────────────────────┘
ML-DSA-65 (NIST Category 3) is selected as the mandatory algorithm for the following reasons:
| Component | Raw bytes | Hex chars | Base64url chars |
|---|---|---|---|
ML-DSA-65 public key (dsaPublicKey) | 1,952 | 3,904 | ~2,603 |
| ML-DSA-65 secret key | 4,032 | 8,064 | ~5,376 |
ML-DSA-65 signature (value) | 3,309 | 6,618 | ~4,412 |
| ECDSA P-256 public key (legacy) | 64 | 128 | ~86 |
| ECDSA P-256 signature (legacy) | 64 | 128 | ~86 |
A PQ-SignedSpendEnvelope is a JSON object. The following JSON Schema (Draft 2020-12) is
the normative definition. Conformant implementations must produce and accept
objects that validate against this schema.
Note on encoding: in this profile, signature.value and dsaPublicKey are encoded
as lowercase hex strings (base16, RFC 4648), not Base64url. This matches the live test vectors at
ap2-pq-test-vectors-v1.json.
The signing procedure in §6 uses hex encoding throughout.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://pqsafe.xyz/schemas/pq-signed-spend-envelope-v1.json",
"title": "PQ-SignedSpendEnvelope",
"description": "AP2 SpendEnvelope with ML-DSA-65 post-quantum signature (AP2-PQ Profile v1)",
"type": "object",
"required": [
"issuer", "agent", "max_amount", "currency",
"allowed_recipients", "valid_from", "valid_until",
"nonce", "rail", "signature"
],
"additionalProperties": false,
"properties": {
"issuer": {
"type": "string",
"pattern": "^pq1[0-9a-f]{64}$",
"description": "Issuer identity string. Format: 'pq1' prefix + 64 lowercase hex chars
(Keccak-256 of the raw ML-DSA-65 public key)."
},
"agent": {
"type": "string",
"description": "Agent identity string. Identifies the authorized agent."
},
"max_amount": {
"type": "number",
"exclusiveMinimum": 0,
"description": "Maximum authorized transaction amount."
},
"currency": {
"type": "string",
"pattern": "^[A-Z]{3}$",
"description": "ISO 4217 three-letter currency code (e.g., 'USD', 'HKD')."
},
"allowed_recipients": {
"type": "array",
"items": { "type": "string" },
"minItems": 1,
"description": "Allowlist of permitted payment recipient identifiers.
The actual recipient MUST match one entry exactly."
},
"valid_from": {
"type": "integer",
"description": "Unix epoch timestamp (seconds) from which the envelope is valid."
},
"valid_until": {
"type": "integer",
"description": "Unix epoch timestamp (seconds) at which the envelope expires.
Verifiers MUST reject envelopes where now > valid_until."
},
"nonce": {
"type": "string",
"pattern": "^[0-9a-f]{64}$",
"description": "32 bytes of cryptographically random data, hex-encoded (64 lowercase
hex chars). MUST be unique per envelope. Used for replay protection."
},
"rail": {
"type": "string",
"enum": ["airwallex", "wise", "stripe", "usdc-base", "x402"],
"description": "Payment rail identifier. Only the specified rail may execute this envelope."
},
"signature": {
"type": "object",
"required": ["alg", "value", "dsaPublicKey"],
"additionalProperties": false,
"properties": {
"alg": {
"type": "string",
"const": "ML-DSA-65",
"description": "Algorithm identifier. MUST be 'ML-DSA-65' for this profile."
},
"value": {
"type": "string",
"pattern": "^[0-9a-f]{6618}$",
"description": "ML-DSA-65 signature over the AP2 Fingerprint, hex-encoded.
Raw length: exactly 3,309 bytes / 6,618 hex chars."
},
"dsaPublicKey": {
"type": "string",
"pattern": "^[0-9a-f]{3904}$",
"description": "ML-DSA-65 public key, hex-encoded.
Raw length: exactly 1,952 bytes / 3,904 hex chars."
}
}
}
}
}
pq1 prefix distinguishes AP2-PQ issuer IDs from other identity formats. The 64 hex chars are the Keccak-256 hash of the raw 1,952-byte ML-DSA-65 public key.max_amount.
The normative signing procedure for a PQ-SignedSpendEnvelope is:
signature member. All string values must be
valid UTF-8.
Number::toString for numeric values. The result is a deterministic UTF-8
byte sequence. The signature key must be absent from the
canonical form.
fingerprint = SHA-256( JCS( envelope_without_signature ) )
// 32 bytessigma = ML-DSA-65.Sign(secretKey, fingerprint, ctx="")
// Output: 3,309 bytes rawctx) must be the empty string.
Implementations should use the hedged variant (random rnd
from a CSPRNG) for defense against fault attacks. The deterministic variant
(rnd = 0x00 × 32) is also conformant.
signature_hex = hex(sigma) // 6,618 lowercase hex chars
pubkey_hex = hex(publicKey) // 3,904 lowercase hex chars"signature": {
"alg": "ML-DSA-65",
"value": "<6618 lowercase hex chars>",
"dsaPublicKey":"<3904 lowercase hex chars>"
}json-canonicalize (npm, RFC 8785 compliant),
jcs (PyPI), github.com/cyberphone/json-canonicalization (Go).
Verifiers must perform all seven checks in the order listed before executing any payment. A failure on any check must cause the envelope to be rejected. Verifiers must not apply spend policy or business logic to an unverified envelope.
signature removed), then call:
result = ml_dsa65.verify(
sig = hex_decode(envelope.signature.value),
message = fingerprint, // 32-byte SHA-256
publicKey = hex_decode(envelope.signature.dsaPublicKey)
)
// result must be trueresult is false or an exception is raised, reject with
AP2ERR_SIGNATURE_INVALID.
now < envelope.valid_until.
Reject with AP2ERR_EXPIRED if the envelope has passed its expiry timestamp.
now ≥ envelope.valid_from.
Reject with AP2ERR_NOT_YET_VALID if the envelope is presented before its validity window opens.
envelope.allowed_recipients.
Reject with AP2ERR_RECIPIENT_NOT_ALLOWED otherwise.
envelope.max_amount.
Reject with AP2ERR_AMOUNT_EXCEEDED otherwise.
nonce field against a persistent
seen-set scoped to the issuer. Reject with AP2ERR_NONCE_REPLAY if the nonce has
been seen before. On successful verification, add the nonce to the seen-set with TTL set to
valid_until.
dsaPublicKey is registered
in the issuer's key registry and has not been revoked. If a registry is configured, reject
with AP2ERR_KEY_NOT_REGISTERED or AP2ERR_KEY_REVOKED on failure.
In self-contained mode (no registry), skip this check but log a warning.
| Code | Check | HTTP status suggestion |
|---|---|---|
AP2ERR_SIGNATURE_INVALID | Check 1 | 401 |
AP2ERR_EXPIRED | Check 2 | 403 |
AP2ERR_NOT_YET_VALID | Check 3 | 403 |
AP2ERR_RECIPIENT_NOT_ALLOWED | Check 4 | 403 |
AP2ERR_AMOUNT_EXCEEDED | Check 5 | 403 |
AP2ERR_NONCE_REPLAY | Check 6 | 409 |
AP2ERR_KEY_NOT_REGISTERED | Check 7 | 401 |
AP2ERR_KEY_REVOKED | Check 7 | 401 |
The live, machine-readable test vector file is published at:
https://pqsafe.xyz/spec/ap2-pq-test-vectors-v1.json
The JSON file is the normative source. Implementations must verify
against the live file, not only the summaries below. The file contains six vectors across two
test suites (sign_verify and negative).
Five positive vectors each provide a complete PQ-SignedSpendEnvelope with
known issuer key material and expected verification result true. A conformant
implementation must:
signature.signature.value (6,618 hex chars → 3,309 bytes).signature.dsaPublicKey (3,904 hex chars → 1,952 bytes).ml_dsa65.verify(sig, fingerprint, pubkey) and assert true.TC-1 — Baseline positive PASS
Standard envelope: USD 100, Airwallex rail, 24-hour validity window. Tests the complete sign→fingerprint→verify pipeline.
TC-2 — Different amount + currency PASS
HKD 500 envelope on the Wise rail. Verifies currency field handling in JCS canonicalization.
TC-3 — Multiple allowed recipients PASS
Allowlist with three recipient identifiers. Verifies array serialization ordering in JCS canonical form.
TC-4 — Stripe rail PASS
EUR 250 on Stripe. Validates rail enum handling and cross-currency correctness.
TC-5 — x402 rail PASS
USDC 50 on x402 HTTP-native micropayment rail. Tests non-fiat currency envelope signing.
TC-N1 — Modified signature byte MUST REJECT
A valid envelope with one byte of signature.value flipped (XOR 0x01 at byte index 100).
A conformant implementation must return false from
ml_dsa65.verify(). This vector exposes a critical defect in
pqcrypto v0.4.0, which silently returned true for structurally invalid
ML-DSA-65 signatures due to incomplete polynomial coefficient validation.
Implementations using pqcrypto must upgrade to v0.4.1+
before claiming AP2-PQ conformance.
pqcrypto 0.4.0 silent-accept bug was discovered
during development of this test suite. The bug causes certain structurally malformed ML-DSA-65
signatures to pass verification. Any implementation that passes TC-N1 is vulnerable to signature
forgery attacks. See the test vector file for the exact tampered signature bytes.
Each SpendEnvelope contains a 64-hex-char nonce (32 bytes of CSPRNG output). Verifiers
must maintain a persistent seen-set of nonces, keyed by issuer identity,
with entries retained until valid_until. An envelope submitted a second time with the same
nonce must be rejected with AP2ERR_NONCE_REPLAY, even if all
other checks pass. Nonce storage should be durable across Verifier restarts.
Key revocation complements nonce protection for longer-term replay scenarios. Issuers should provide a revocation endpoint or publish a revocation list. Verifiers should check revocation status on each verification request rather than caching revocation state for more than 60 seconds.
Verifiers must reject envelopes where the current time exceeds
valid_until. Clock skew between Issuer and Verifier should
be addressed by issuing envelopes with a short validity window (e.g., 5–60 minutes for
single-transaction use, up to 24 hours for multi-transaction mandates) rather than by applying
a tolerance window at the Verifier.
Verifiers should maintain an append-only audit log of verified SpendEnvelope events. Each log entry should include the AP2 Fingerprint of the envelope and the SHA-256 hash of the previous entry, forming a hash chain that makes retroactive modification detectable. For higher assurance, the chain head may be anchored to an external immutable store (e.g., a public blockchain commitment).
ML-DSA-65 key pairs should be rotated at least annually for long-lived agent identities. During rotation:
| Phase | Start date | Sender requirement | Receiver requirement |
|---|---|---|---|
| Phase 1: Dual-Sign Optional | 2025-01 | MAY dual-sign (ECDSA + ML-DSA-65) | SHOULD accept PQ |
| Phase 2: Dual-Sign Mandatory | 2026-01 | MUST include ML-DSA-65 signature | MUST accept PQ |
| Phase 3: PQ-Only | 2027-01 | MUST use ML-DSA-65 only | MAY reject ECDSA-only |
Operators should treat 2030 as a hard deadline for completing migration to post-quantum signing across all production AP2 deployments — the lower bound of credible CRQC estimates from published intelligence and academic assessments.
Implementations must use a constant-time ML-DSA-65 library. Timing
variations in signing or verification can leak secret key material. Recommended libraries with
constant-time properties: @noble/post-quantum v0.2.x+ (TypeScript), Cloudflare CIRCL
mldsa65 (Go), RustCrypto ml-dsa crate (Rust), pyca/cryptography
ML-DSA via OpenSSL (Python — verify constant-time guarantee for the target platform).
AP2 agents must not reuse their ML-DSA-65 key pair for any purpose other than AP2 SpendEnvelope signing as defined in this document. The same key pair must not be used for encryption, key encapsulation, TLS certificates, JWT issuance, or document signing without explicit cross-protocol security analysis.
ML-DSA-65 signatures (3,309 bytes) and public keys (1,952 bytes) are substantially larger than ECDSA P-256 equivalents (~64 bytes each). Verifiers must enforce input length limits. The recommended maximum is 8,192 bytes per HTTP header field. Rate limiting on the authorization endpoint is recommended; ML-DSA-65 verification takes approximately 0.3 ms on modern hardware and unthrottled requests can exhaust CPU.
Adversaries at the nation-state level are documented to be capturing signed traffic today with intent to forge signatures once a CRQC is available. AP2 SpendEnvelope records may remain legally and financially significant for years; the signature algorithm must therefore remain unforgeable over that same timescale. Adopting ML-DSA-65 now — before CRQCs exist — is the correct engineering response.
Full ML-DSA-65 signature verification on an EVM chain is gas-prohibitive (estimated 10–50 M gas per call — impractical at any current L2 gas price). PQSafe therefore uses Pattern A — Hash-Witness:
api.pqsafe.xyz) performs
full ML-DSA-65 signature verification and all 7 policy checks. The Worker is the
authoritative source of truth for signature validity.
commit(envelopeId, sigFingerprint, …) on the
SpendEnvelopeRegistryV2_1 contract.
envelopeId = keccak256(envelopeJsonBytes);
sigFingerprint = bytes32(mlDsa65Signature[0:32]).
These two hashes anchor the envelope to an immutable, publicly auditable on-chain
record without transmitting the 3,309-byte signature to the chain.
The contract is an audit ledger, not a cryptographic verifier. Any external party can independently verify:
isCommitted(envelopeId)).isRevoked(envelopeId)).sigFingerprint = bytes32(sig[0:32]) locally and
comparing against getRecord(envelopeId).sigFingerprint).EnvelopeCommitted event.
The canonical production contract is
SpendEnvelopeRegistryV2_1 (Apache-2.0).
Source: pqsafe/evm/src/SpendEnvelopeRegistryV2_1.sol.
It extends SpendEnvelopeRegistryV2 (AccessControl + ReentrancyGuard)
with an OpenZeppelin Pausable circuit-breaker (OZ v5).
Key roles:
DEFAULT_ADMIN_ROLE — Gnosis Safe 2-of-3 multisig; can unpause and manage roles.ISSUER_ROLE — PQSafe Worker; calls commit() after off-chain verification.OPERATOR_ROLE — payment executor; calls markUsed() after settlement.REVOCATION_ROLE — compliance officer; calls revokeEnvelope().PAUSER_ROLE — Forta monitoring bot; triggers emergency pause.
Canonical address registry: spec/contracts.json
(updated post-deploy). CREATE2 salt
keccak256("PQSafe.SpendEnvelopeRegistryV2_1.v2.1.0.mainnet")
produces the same deterministic address on Base, Arbitrum One, and Optimism.
| Network | Chain ID | Address | Explorer |
|---|---|---|---|
| Base Mainnet | 8453 | TBD — mainnet deploy pending |
— |
| Arbitrum One | 42161 | TBD — mainnet deploy pending |
— |
| Optimism | 10 | TBD — mainnet deploy pending |
— |
| Arbitrum Sepolia (testnet) | 421614 | 0x142bA5626bf8B032EB0B59052421C42595417F5d |
Arbiscan Sepolia |
Deployment gas for SpendEnvelopeRegistryV2_1: approximately 1.3–1.5 M gas.
Per-call gas for the hot path:
commit(): ≈ 185,000 gas (first-time SSTORE, whenNotPaused overhead ~2,100 gas)markUsed(): ≈ 75,000 gasrevokeEnvelope(): ≈ 80,000 gasExpected deploy cost at typical L2 gas prices: ~$3–15 per chain (0.001–0.005 ETH, assuming ETH ≈ $2,500–$3,000 and L2 base fee 0.01–0.1 gwei). Raymond should ensure 0.01 ETH on each target chain before deploying.
This document has no IANA considerations. It is an Informational specification published by
PQSafe Inc. The algorithm identifier ML-DSA-65 used in the
signature.alg field is defined by this document for use within the AP2-PQ profile.
No new HTTP header fields are introduced; the x-ap2-alg, x-ap2-signature,
and x-ap2-pubkey HTTP headers referenced in this profile are existing AP2 headers.
PQSafe Inc.
Hong Kong, China
Email: [email protected]
URI: https://pqsafe.xyz
Raymond Chau Yik-Chun
PQSafe Inc.
Email: [email protected]
Copyright 2026 PQSafe Inc. Licensed under the
Apache License, Version 2.0.
You may reproduce this specification provided attribution is maintained and the canonical URL
(https://pqsafe.xyz/spec/ap2-pq-v1/) is cited.
This document does not claim ownership of the AP2 protocol, which is a FIDO Alliance specification originally contributed by Google. "AP2-PQ profile" refers solely to PQSafe's extension proposal for post-quantum signatures within the AP2 framework.