๐Ÿฆž
PQSafe · Benchmark

PQSafe benchmark.

Honest numbers. ML-DSA-65 over Ed25519 costs about +0.03 ms on native implementations. Total PQSafe overhead is about ~1.1 ms per envelope — roughly 97% of which is policy + audit + revoke, not the post-quantum crypto.

All numbers below are order-of-magnitude. The script at the bottom reproduces them in your environment.

Operation latency (pure-JS, single core)

@noble/curves + @noble/post-quantum, Node 22, Apple M2 Pro, single core. JS-impl — native libs are 5–10× faster.

Operation ECDSA P-256 (ms) Ed25519 (ms) ML-DSA-65 (ms)
keygen0.50.2~3.0
sign0.60.3~3.0
verify1.00.6~1.0
Native references (post-quantum.org, Cloudflare): ML-DSA-65 sign ~0.10 ms, verify ~0.04 ms on modern x86_64. The +0.03 ms claim refers to native-vs-native ML-DSA-65 over Ed25519. PQSafe deployments hit native paths in CF Workers, Node native bindings, and the Rust/Go/Java/.NET verifier matrix.

Signature & key sizes

Scheme Public key Private key Signature
ECDSA P-256 (compressed)33 B32 B~71 B DER
Ed2551932 B32 B64 B
ML-DSA-65 (FIPS 204)1,952 B4,032 B3,309 B

Storage at 1M envelopes/year × 7-year audit retention: ~23 GB total (envelope + dual signature + canonical bytes). Negligible against the audit value.

End-to-end latency budget per envelope

A signed, verified, policy-checked, audit-logged, replay-checked SpendEnvelope: ~1.1 ms in steady state. Visualized:

ML-DSA delta vs Ed25519
0.03 ms
JCS canonicalization
0.20 ms
SHA-256 fingerprint
0.10 ms
Policy check
0.40 ms
Audit log append
0.20 ms
Revoke / nonce check
0.17 ms
Total
~1.10 ms

Crypto is the smallest share of the budget. The trust layer (policy + audit + revoke) dominates — and that overhead exists with any signing scheme, post-quantum or not.

What this means

Reproduce these numbers

Save as bench.mjs, install three dependencies, run with Node 20+.

npm i @noble/curves @noble/post-quantum canonicalize
// bench.mjs — reproducible PQSafe operation benchmark
import { ml_dsa65 } from '@noble/post-quantum/ml-dsa.js';
import { p256 } from '@noble/curves/p256';
import canonicalize from 'canonicalize';
import { webcrypto } from 'node:crypto';

const ITERATIONS = 200;

function time(fn) {
  // warm-up
  for (let i = 0; i < 10; i++) fn();
  const t0 = performance.now();
  for (let i = 0; i < ITERATIONS; i++) fn();
  return (performance.now() - t0) / ITERATIONS;
}

async function timeAsync(fn) {
  for (let i = 0; i < 10; i++) await fn();
  const t0 = performance.now();
  for (let i = 0; i < ITERATIONS; i++) await fn();
  return (performance.now() - t0) / ITERATIONS;
}

const seed = new Uint8Array(32);
webcrypto.getRandomValues(seed);

const ecPriv = p256.utils.randomPrivateKey();
const ecPub = p256.getPublicKey(ecPriv, true);
const mlKeys = ml_dsa65.keygen(seed);

const mandate = {
  agent_id: 'did:web:bench.example.com:agent-1',
  amount: '50.00',
  currency: 'USD',
  nonce: 'ab'.repeat(16),
  recipient: 'did:web:merchant.example.com:payee'
};
const canon = canonicalize(mandate);
const canonBytes = new TextEncoder().encode(canon);
const fp = new Uint8Array(await webcrypto.subtle.digest('SHA-256', canonBytes));

const ecSig = p256.sign(fp, ecPriv).toDERRawBytes();
const mlSig = ml_dsa65.sign(mlKeys.secretKey, fp);

const results = {
  'canonicalize':       time(() => canonicalize(mandate)),
  'sha256 fingerprint': await timeAsync(async () => webcrypto.subtle.digest('SHA-256', canonBytes)),
  'ecdsa sign':         time(() => p256.sign(fp, ecPriv)),
  'ecdsa verify':       time(() => p256.verify(p256.Signature.fromDER(ecSig), fp, ecPub)),
  'mldsa sign':         time(() => ml_dsa65.sign(mlKeys.secretKey, fp)),
  'mldsa verify':       time(() => ml_dsa65.verify(mlSig, fp, mlKeys.publicKey)),
};

for (const [k, v] of Object.entries(results)) {
  console.log(`${k.padEnd(22)} ${v.toFixed(3)} ms`);
}

Pure-JS, single core. Native implementations (Rust verifier, CF Worker C bindings) cut these by 5–10×.