Skip to content

Post-quantum crypto (PQC)

PQC in Phaze is landing primitive-first. The classical floor (sign: 'ed25519', encrypt: 'aes-gcm-256', both Web-Crypto-native, 0 shipped bytes) is what ships today; the ML-KEM-768 + ML-DSA-65 primitives are built (phaze::crypto::{ml_kem, ml_dsa}, behind the pqc feature, off by default), and the field wiring now ships on the Rust side: sign: 'ed25519+ml-dsa' is end-to-end (the sign = attribute writes the dual X-Phaze-Sig; ml_dsa::verify_response_sig checks the ml-dsa half) and the encrypt: 'ml-kem-768+aes-gcm' Rust arm seals the body to an ML-KEM archive key, and the two compose. This is runtime-proven — a live e2e (build/pqc.test.mjs) drives the fields against the worker (dual-sign + KEM-encrypt). The security-level variants (ML-KEM-512/1024, ML-DSA-44/87) ship too, DRY-macro-generated. Post-quantum slots into the same sign: / encrypt: field model. This page is the design note: what PQC costs, measured, and the three decisions those numbers force.

For the user-facing roadmap see Post-quantum & at-rest; this is the internals note behind it.

Numbers below are from a wasm32 build (opt-level = "z", LTO, strip) of an ML-KEM-768 / ML-DSA-65 round-trip, on dcrypt 1.2.3 and RustCrypto ml-kem 0.3.2 / ml-dsa 0.1.1. Representative, not a release benchmark.

Shipped size — the make-or-break (wasm brotli)

Section titled “Shipped size — the make-or-break (wasm brotli)”
AlgorithmCrateraw .wasmbrotli
ML-KEM-768 (encryption)dcrypt410 KB77 KB
ML-KEM-768 (encryption)RustCrypto390 KB71 KB
ML-DSA-65 (signatures)either~400 KB~70–90 KB (est.)
classical Ed25519 / X25519 / AES-GCM0 (Web Crypto native)

The one number to hold: PQC ≈ 70–80 KB brotli, classical = 0. That is ~25× the entire <3 KB phaze runtime. It is why the design keeps PQC off the browser. Lattice crypto floors at roughly this size regardless of crate or wasm-opt — it is not something you optimise away.

Wire sizes (FIPS standard — same for both crates)

Section titled “Wire sizes (FIPS standard — same for both crates)”
public keysecret keyciphertext / signatureshared secret
ML-KEM-7681184 B64 B (RustCrypto, seed) / 2400 B (dcrypt, expanded)ct 1088 B32 B
ML-DSA-651952 B~4032 Bsig 3309 B

For contrast: Ed25519 is a 32-byte key and a 64-byte signature. ML-DSA-65 signatures are ~50× larger.

Don’t conflate this with the 70–80 KB brotli number above — different axis, different decision. That number is a shipped-bytes cost (what a browser would have to download); it drives Decision 1 below (keep PQC verification off the browser entirely). This ~50× is a wire cost (bytes added to every signed HTTP response, regardless of who verifies it); it drives Decision 2 (when to reach for ML-DSA signing at all, vs staying on plain Ed25519 for live responses). Citing one number as the reason for the other’s decision is a category error — watch for it when explaining this from memory rather than re-reading the numbers.

Speed (native, size-optimised build — opt-3 would be ~3–5× faster)

Section titled “Speed (native, size-optimised build — opt-3 would be ~3–5× faster)”
keygenop (encaps / sign)op (decaps / verify)
ML-KEM-768159 µs130 µs143 µs
ML-DSA-65278 µs184 µs91 µs

Speed is never the bottleneck (µs range, server-side). Size and wire-overhead are the costs that matter.

Decision 1 — where PQC runs (architectural; settles everything else)

Section titled “Decision 1 — where PQC runs (architectural; settles everything else)”
OptionCostWhen
A. Server↔server + at-rest (Rust, in workerd / Container)~400 KB wasm in the Worker — fine (multi-MB budget); 0 browser bytes✅ the design’s home — EEG/PHI blobs in R2, harvest-now-decrypt-later. Start here.
B. Browser decapsulates (gated WASM)77 KB brotli shipped per PQC actiononly if a live browser session must PQC-decrypt — rare; avoid

→ Recommendation: A. The first test (/api/pqc-encrypt) is server-side; nothing ships to the browser. For a hybrid like sign: 'ed25519+ml-dsa', the browser still verifies only the classical half (Ed25519, native, 0 bytes) while the ML-DSA half is verified server-side — so even a hybrid-signed response ships 0 browser WASM.

Note: WASM-on-the-server isn’t being avoided here — it’s already constant. rust-api’s whole runtime (routing, D1/KV, classical Ed25519, and now PQC) compiles to one wasm32 binary via worker-build and runs inside Cloudflare’s workerd; ML-KEM/ML-DSA is incremental weight on an already-WASM server process, not new WASM being newly accepted for this feature. The phaze client bundle is a structurally separate build — TypeScript/JSX compiled by phaze-compile → Vite/Rollup, targeting the browser’s V8 — and has never included any Rust/WASM code, PQC or otherwise: different toolchain, different deploy artifact, talking to rust-api only over HTTP fetch/Response. So Option A’s “0 browser bytes” isn’t a targeted per-feature dodge of a near-miss; it’s the same structural default every other Rust capability in rust-api already gets, for free.

Decision 2 — KEM vs signatures (algorithm cost)

Section titled “Decision 2 — KEM vs signatures (algorithm cost)”
  • ML-KEM (encryption) is the cheaper half: ct 1088 B, ~71 KB wasm.
  • ML-DSA (signatures) is heavier: sig 3309 B per signature (vs Ed25519’s 64 B — ~50×). For sign: 'ed25519+ml-dsa', the ML-DSA half is a real wire cost on every signed response → reserve it for at-rest / long-lived artifacts, keep live responses on Ed25519.

Decision 3 — dcrypt vs RustCrypto (crate)

Section titled “Decision 3 — dcrypt vs RustCrypto (crate)”
dcrypt 1.2.3 (design pick)RustCrypto ml-kem 0.3 / ml-dsa 0.1
ML-KEM-768 brotli77 KB71 KB (smaller)
naming / standard”Kyber” — no FIPS-203 claim ⚠ interop riskFIPS-203 / FIPS-204 named ✅
family fitstandalonesame as phaze-crypto’s p256 / aes-gcm
secret-key storage2400 B (expanded)64 B (seed) ✅ at-rest
maturity / churnstable 1.x, rand 0.8newer (0.3 / 0.1), rand_core 0.10 + getrandom 0.3 wasm friction ⚠

Decision: dcrypt for the at-rest build — least friction (stable 1.x, rand 0.8 like phaze-crypto), and in a closed server↔server / at-rest system the Kyber-vs-FIPS-203 naming stays internal, so it doesn’t bite. Because the encrypt: / sign: field names the algorithm and not the crate, this stays swappable: revisit RustCrypto only if FIPS-203 interop with an outside party (a browser FIPS library, another service) ever becomes a requirement.

The decision is one question: does the browser itself have to decrypt, or hold the PQC secret key? If no, it’s A. If yes, it’s B. In practice almost everything is A.

Option A — server / at-rest (the default; ~all real cases)

Section titled “Option A — server / at-rest (the default; ~all real cases)”
ScenarioWhy AField
EEG / PHI session blob in R2An adversary copies the encrypted blob today and decrypts it in 2035 once quantum is cheap (harvest-now-decrypt-later). The browser never decrypts it — playback decrypts server-side.encrypt: 'ml-kem-768+aes-gcm' on the at-rest blob
Long-lived signed manifest / audit logA chunk hash-list or audit record must stay verifiable for years. The ML-DSA half is the durable assurance, verified server-side.sign: 'ed25519+ml-dsa' on the manifest
MoQ replay over an untrusted relayMedia/data crosses a public CDN/relay, is recorded, and replayed later — confidentiality + integrity must survive both the relay and the calendar. Produced and consumed by Rust runtimes.per-chunk sign: + encrypt: (PQC) over the track
Service-to-service secretsrust-api ↔ another backend exchanging long-lived key material — both ends are Rust.encrypt: 'ml-kem-768+aes-gcm'

In every A case the browser, if involved at all, only ever does the classical half of a hybrid (Ed25519 verify, native, 0 bytes). The ML-KEM / ML-DSA work runs on a Rust runtime where 400 KB of wasm is free.

Option B — browser decapsulates (rare; accept the 77 KB)

Section titled “Option B — browser decapsulates (rare; accept the 77 KB)”
ScenarioWhy it forces B
True end-to-end encryption to the device — a zero-knowledge note / password vault / private message where the server must not be able to decryptThe ML-KEM secret key lives in the browser; only it can decapsulate → the lattice WASM ships. There is no server-side party to do the PQC.
Client-held PQC identity — the browser signs with an ML-DSA key only it holds (post-quantum client auth)The signing key never leaves the device → the browser must run ML-DSA.

Not B (the trap): “encrypt the response so DevTools can’t read it” is not a B case — that’s symmetric AES-GCM (native, 0 bytes), the server holds the key, no browser PQC. Use encrypt: 'aes-gcm-256' (today) or A. Reach for B only when the requirement is literally “the server must not be able to decrypt this.”

Build status: Option B does not exist today — don’t read the A/B table above as “both are available, pick by scenario.” There is no gated-WASM client-verify or client-decapsulate path anywhere in phaze-compile; the compile-time action registry’s sign field (packages/compile/src/action-fb-registry.ts) accepts 'ed25519+ml-dsa', but the client emission only ever verifies the ed25519 half (native Web Crypto) — the ml-dsa half is Rust-produced/Rust-verified, never checked in the browser (still zero ml-dsa/ml-kem/gated-wasm code in packages/compile/src/). Choosing B isn’t flipping a config flag on a dormant feature — it’s a new compiler project: a per-algo WASM-loading emission path, roughly the client-side mirror of what the Rust sign =/encrypt = attributes already do server-side.

Phaze Transport doesn’t bake in one algorithm or one placement. The sign: / encrypt: field is a per-action algorithm selector, so PQC is a flexible declarative choice and the compiler emits the right code per algorithm, per party:

  • Per-action / per-stream / per-blob. Each fence picks its own crypto — encrypt: 'aes-gcm-256' on a workerd action, encrypt: 'ml-kem-768+aes-gcm' on an at-rest R2 blob, sign: 'ed25519' on a live response, sign: 'ed25519+ml-dsa' on a long-lived manifest. No global lock-in; you turn the knob where the data needs it.
  • Hybrid by composition (+). Classical + PQC in one field: sign: + = composite signature (both must verify); encrypt: + = KEM+DEM (ML-KEM establishes a key, AES-GCM encrypts the body with it). The browser checks the classical half native; the PQC half rides server-side / at-rest.
  • Security level is a parameter, not a rewrite. ml-kem-512 (L1) / ml-kem-768 (L3) / ml-kem-1024 (L5); ml-dsa-44/65/87. Same field, same code path, different params — pick per the data’s lifetime.
  • Placement-aware emission. The compiler knows the algorithm at build time, so it emits native-inline for classical (0 bytes) and gated-WASM only when the browser itself must do PQC. Server-side PQC ships 0 browser bytes (A); browser PQC is pay-per-use (B).
  • Backend-swappable. The field names the algorithm (ml-kem-768), not the crate. dcrypt today; RustCrypto if FIPS-203 interop is needed later. The wire contract (X-Phaze-Sig algo tag, { e, iv }) is runtime-neutral, so the provider is an implementation detail behind one field.

One declarative field — per-action, hybrid-capable, security-level-parameterised, placement-aware, backend-swappable. That is the PQC flexibility: a knob you turn where the data needs it, not a global commitment.

PQC’s cost is ~70–80 KB brotli + ~1–3 KB wire per object — trivial server-side, prohibitive browser-side, so it lives at rest. dcrypt clears the gate, and the ML-KEM-768 and ML-DSA-65 primitives plus the AES-GCM DEM are now in phaze-crypto behind the pqc feature — the full ml-kem-768+aes-gcm hybrid and ml-dsa sign/verify run server-side (proven at /api/pqc-encrypt and /api/pqc-sign, both linking clean in workerd). On top of the primitives, the field wiring ships on the Rust side: sign: 'ed25519+ml-dsa' is end-to-end — emit (/api/signed-pq) and verify (ml_dsa::verify_response_sig, /api/verify-pq) — the encrypt: 'ml-kem-768+aes-gcm' Rust arm seals the body to an ML-KEM archive key (/api/encrypted-pq), and the two compose (/api/sealed-signed-pq). All worker-build clean and runtime-proven by the live e2e (build/pqc.test.mjs). The security-level variants (ML-KEM-512/1024, ML-DSA-44/87) are wrapped too. What remains is only the consumer’s reader/vault service (at-rest decryption) — app-level, not phaze.

phaze::crypto::{ml_kem, ml_dsa, aes} (feature pqc, off by default — the lattice tree only compiles when the feature is on and something uses it):

use phaze::crypto::{ml_kem, ml_dsa, aes};
// ML-KEM-768 (FIPS 203) — encryption KEM + the AES-GCM DEM
let (pk, sk) = ml_kem::generate()?;
let (ct, ss) = pk.encapsulate()?; // 32-byte shared secret = AES-256 key
let ss2 = sk.decapsulate(&ct)?;
let (body, iv) = aes::seal(ss.as_bytes(), b"PHI blob")?;
let plain = aes::open(ss2.as_bytes(), &iv, &body)?; // == b"PHI blob"
// ML-DSA-65 (FIPS 204) — signatures
let (vk, sgk) = ml_dsa::generate()?;
let sig = sgk.sign(b"long-lived manifest")?;
vk.verify(b"long-lived manifest", &sig)?; // Ok(()) iff valid

Backed by dcrypt (Kyber768 / Dilithium3); the phaze surface uses the FIPS names (ml_kem / ml_dsa), dcrypt is the swappable backing. All native-tested + wasm32-clean; /api/pqc-encrypt and /api/pqc-sign link in workerd (worker 1.45 MB with both — opt-in, only when the pqc feature is on and an endpoint uses it). dcrypt’s rand 0.8 / rand_core 0.6 aligns with phaze-crypto’s OsRngzero version conflict (the RustCrypto path needed rand_core 0.10 + getrandom 0.3 gymnastics).

The transport-field design (Rust side shipped — sign: end-to-end + encrypt: arm)

Section titled “The transport-field design (Rust side shipped — sign: end-to-end + encrypt: arm)”

The PQC primitives run server-side today; the declarative encrypt: / sign: fence knobs on top of them are built on the Rust side — sign: 'ed25519+ml-dsa' is end-to-end (emit and verify), and the whole encrypt: Rust arm ships. What’s left is runtime deployment + the browser-verify variant nobody needs (Option B):

sign: 'ed25519+ml-dsa' — SHIPPED end-to-end (0 browser WASM). Emits a second value: X-Phaze-Sig: ed25519:<b64>, ml-dsa:<b64>. The browser verifies only the ed25519 half (native, 0 WASM); the ml-dsa half is the at-rest / relay assurance, verified by a Rust party holding the ml-dsa public key. Keys mirror today’s signing: the worker holds both secrets (PHAZE_SIGN_KEY + a new PHAZE_ML_DSA_KEY); ed25519 public → browser, ml-dsa public → the archive verifier.

  • Emit: ml_dsa::{to_bytes, from_bytes} + response_sig, and the second-sig emission — the Rust sign = "ed25519+ml-dsa" attribute (feature pqc-sig). It shares the classical SignBody path via a common SignParts extraction + finish_with_sig; only the header string differs (one algo vs two). If PHAZE_ML_DSA_KEY is absent it degrades to ed25519-only. Proven at /api/signed-pq.
  • Verify: ml_dsa::verify_response_sig parses the multi-algo X-Phaze-Sig, pulls the ml-dsa: component, and checks it over the body — the Rust archive/relay reader. This is what enforces the second signature (emission alone is unchecked). Proven at /api/verify-pq (tampered body / wrong key / missing-or-malformed component all rejected).

encrypt: 'ml-kem-768+aes-gcm' — an at-rest field SHIPPED (key-provenance is the crux). Encryption is to a recipient’s ML-KEM public key, not a shared symmetric key — so it’s server↔server / at-rest, never browser-facing (a browser recipient would need the secret + 77 KB WASM = Option B). This is also why it is a Rust-side encrypt= attribute, not the TS dispatcher’s job: Web Crypto has no ML-KEM, so classical encrypt: 'aes-gcm-256' stays in the dispatcher while the PQC KEM seal must run in Rust. The recipient is a configured archive keypair: the worker encapsulates to PUBLIC_PHAZE_KEM_KEY; the matching secret lives in a vault / the reader service, never in the worker. The wire envelope extends { e, iv }{ kem, e, iv } (kem = the ML-KEM ciphertext the reader decapsulates to recover the AES key).

  • Built: ml_kem::{to_bytes, from_bytes} + Envelope::to_wire, and the Rust encrypt = "ml-kem-768+aes-gcm" attribute (feature pqc-kem) — it reads PUBLIC_PHAZE_KEM_KEY (via ctx.var), seals the body via EncryptBody (reusing the SignParts extraction), and emits the {kem,e,iv} JSON envelope. A missing key errors (no silent plaintext — it’s a confidentiality field). The reader is the already-shipped ml_kem::open_sealed. Proven at /api/encrypted-pq (worker-build links clean); 7 ml_kem tests.
  • Composes with sign= (sign-then-encrypt): encrypt = "ml-kem-768+aes-gcm" + sign = "ed25519" / "ed25519+ml-dsa" signs the RAW body first, then seals it — emitting {kem,e,iv} with the detached X-Phaze-Sig preserved. The reader decrypts, then verifies over the recovered bytes (ml_dsa::verify_response_sig for the ml-dsa half). At-rest confidentiality and authenticity in one fence. The ed25519+ml-dsa composition reuses the shared hybrid_sig — no duplicated wire logic. Proven at /api/sealed-signed-pq.
  • Runtime-proven: the live e2e (build/pqc.test.mjs) emits + parses the {kem,e,iv} envelope against the worker (provisioning: PUBLIC_PHAZE_KEM_KEY is a var, not a secret). Remaining: only the reader/vault service that decapsulates the archived blob — app-level, not phaze. The crypto path is complete both ends (seal_to / open_sealed).

Shared enabling step: to_bytes / from_bytes on ml_kem / ml_dsa — to put keys, ciphertexts, and signatures on the wire and read configured public keys. Both done (committed): ml_dsa backs the sign: emit half, ml_kem (+ Envelope/to_wire) backs the encrypt: arm.

  • Shipped: the classical floor — sign: 'ed25519', encrypt: 'aes-gcm-256' (Web-Crypto-native, 0 bytes).
  • Shipped (PQC primitives, server-side): phaze::crypto::ml_kem (ML-KEM-768) + phaze::crypto::ml_dsa (ML-DSA-65) + phaze::crypto::aes (AES-256-GCM DEM), behind the opt-in pqc feature. The full ml-kem-768+aes-gcm hybrid and ml-dsa sign/verify run server-side (/api/pqc-encrypt, /api/pqc-sign) and link in workerd.
  • Shipped (sign: 'ed25519+ml-dsa' — end-to-end): emit — the Rust sign = "ed25519+ml-dsa" attribute (feature pqc-sig) writes the dual X-Phaze-Sig: ed25519:<b64>, ml-dsa:<b64>, sharing the classical SignBody path (only the header string differs); degrades to ed25519-only if PHAZE_ML_DSA_KEY is absent (/api/signed-pq). Verify — ml_dsa::verify_response_sig parses the multi-algo header and checks the ml-dsa half server-side (/api/verify-pq). The browser stays ed25519-only by design (0 WASM).
  • Shipped (encrypt: 'ml-kem-768+aes-gcm' — the Rust arm): the encrypt = "ml-kem-768+aes-gcm" attribute (feature pqc-kem) seals the response body to PUBLIC_PHAZE_KEM_KEY (via ctx.var) and emits the {kem,e,iv} envelope via EncryptBody; the reader is ml_kem::open_sealed. Backed by ml_kem serialization + Envelope::to_wire; proven at /api/encrypted-pq (worker-build links clean). At-rest / server↔server only — never browser-facing; a missing key errors.
  • Shipped (encrypt + sign compose): sign-then-encrypt on the Rust arm — the body is signed (X-Phaze-Sig) then sealed; the reader decrypts, then verifies. Reuses hybrid_sig (no duplicated composition). Proven at /api/sealed-signed-pq.
  • Live e2e — GREEN (worker side): build/pqc.test.mjs (models session.test.mjs) boots the rust-api worker and drives the fields at runtime: signed-pq emits the dual X-Phaze-Sig and the browser verifies the ed25519 half (native, 0 WASM), encrypted-pq emits the {kem,e,iv} envelope (1088-byte ML-KEM ct), primitives round-trip. Provisioning note (verified): PHAZE_SIGN_KEY/PHAZE_ML_DSA_KEY are secrets (.dev.vars / wrangler secret put), PUBLIC_PHAZE_KEM_KEY is a var (ctx.varctx.secret). The 4032-byte ml-dsa secret (≈7.9 KB hex) loads under local wrangler dev — but that’s over Cloudflare’s documented 5 KB per-binding limit, so “loads fine” is a local-dev datapoint (the dev runtime likely doesn’t enforce the deploy ceiling, or treats secrets more permissively than plaintext vars — unverified); confirm for a production deploy. See the key-provisioning table in integrations/rust.
  • Remaining for encrypt: end-to-end: only the reader/vault service — the party that holds the ml-kem secret and open_sealeds the archived blob (at-rest decryption). The worker-side seal + wire is proven; the reader is a separate deployment.
  • NOT built: Option B (browser-side PQC verify / decapsulate). Zero implementation in phaze-compile — no gated-WASM emission path; the action registry’s sign field accepts 'ed25519+ml-dsa', but the client emission verifies only the ed25519 half (the ml-dsa half is Rust-produced/Rust-verified, never checked in the browser). This is not a dormant path waiting on a flag; it would be new compiler work if a real requirement ever forced it (see Decision 1 / Option B above — avoid unless the browser itself must hold the PQC secret).
  • Shipped (variant primitives): ML-KEM-512/1024 (phaze::crypto::ml_kem_512 / ml_kem_1024) + ML-DSA-44/87 (ml_dsa_44 / ml_dsa_87), FIPS 203/204. DRY-macro-generated (kem_primitive! / dsa_primitive!) — one source per family, so ml_kem/ml_dsa (768/65) expand the same macro and stay behaviour-identical (their full suites + the live e2e pass). Primitive only (no field wiring); pick a level per the data’s lifetime.
  • Shipped (via dedicated providers — where dcrypt falls short): dcrypt 1.2.3 stubs SLH-DSA and Falcon (the Signature trait is impl’d but keygen returns NotImplemented, verified by test) and doesn’t export Classic McEliece. Rather than pin a divergent dcrypt, each is wrapped from a purpose-built pure-Rust crate chosen to reuse the dep tree already present (rand_core 0.6, sha2 / sha3, zeroize) — zero browser weight, clean wasm32 build:
    • SLH-DSA-SHA2-128s / 192s / 256s (FIPS 205) — phaze::crypto::slh_dsa / slh_dsa_192s / slh_dsa_256s, via fips205, feature pqc-slh. Hash-based (no lattice assumption).
    • Falcon-512 / -1024 (FN-DSA) — phaze::crypto::falcon / falcon_1024, via fn-dsa, feature pqc-falcon. Compact signatures (~666 B vs ML-DSA-65’s ~3.3 KB). The FN-DSA standard is unpublished (the crate implements Falcon and warns its output may not interop with the final standard), so treat this material as not-yet-standards-stable.
    • Classic McEliece 348864 (code-based KEM) — phaze::crypto::mceliece, via classic-mceliece-rust, feature pqc-mceliece. The conservative lattice-free hedge; ~255 KB public key (loads from R2/KV, not an env var), 96-byte ciphertexts, heap keygen to stay wasm-safe.
    • All primitive-only; DRY-macro-generated where there are levels (slh_primitive! / falcon_primitive!). Full suites pass native + wasm32. Now everything the field surface enumerates is wrapped — from dcrypt where it delivers (ML-KEM / ML-DSA), from a dedicated provider where it doesn’t.
  • Generating keys (deploy-time): mint with the shipped Rust primitives (below) — not OpenSSL / node. dcrypt’s Dilithium/Kyber wire format is not byte-compatible with OpenSSL’s FIPS ML-DSA/ML-KEM (verified: a node-generated FIPS signature fails dcrypt’s deserializer), so an external FIPS tool’s keys won’t load. The classical ed25519 / aes-gcm keys stay node one-liners; the PQC keys are dcrypt-minted.

The last piece before deploying the fields: mint the key material. A throwaway cargo run / #[test] against the shipped primitives prints everything as hex — no tool, no external dependency:

use phaze::crypto::{ed25519, ml_dsa, ml_kem};
// sign: 'ed25519+ml-dsa'
let ed = ed25519::PrivateKey::generate();
println!("PHAZE_SIGN_KEY={}", ed.to_hex()); // worker secret (signs)
println!("PUBLIC_PHAZE_SIGN_KEY={}", ed.public().to_hex()); // client (browser verifies, native)
let (vk, sk) = ml_dsa::generate()?;
println!("PHAZE_ML_DSA_KEY={}", sk.to_hex()); // worker secret (signs the PQC half)
println!("# ml-dsa public (archive verifier): {}", vk.to_hex());
// encrypt: 'ml-kem-768+aes-gcm'
let (pk, sk) = ml_kem::generate()?;
println!("PUBLIC_PHAZE_KEM_KEY={}", pk.to_hex()); // worker var (seals to it)
println!("# ml-kem secret (reader/vault, NEVER the worker): {}", sk.to_hex());

Worker-side values → wrangler secret put (secrets) / vars (PUBLIC_PHAZE_KEM_KEY); the PUBLIC_* public keys → the client .env. The ml-dsa/ml-kem secrets that decrypt/verify at rest live only in the reader/vault, never on the worker.