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.
What PQC actually costs (measured)
Section titled “What PQC actually costs (measured)”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)”| Algorithm | Crate | raw .wasm | brotli |
|---|---|---|---|
| ML-KEM-768 (encryption) | dcrypt | 410 KB | 77 KB |
| ML-KEM-768 (encryption) | RustCrypto | 390 KB | 71 KB |
| ML-DSA-65 (signatures) | either | ~400 KB | ~70–90 KB (est.) |
| classical Ed25519 / X25519 / AES-GCM | — | — | 0 (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 key | secret key | ciphertext / signature | shared secret | |
|---|---|---|---|---|
| ML-KEM-768 | 1184 B | 64 B (RustCrypto, seed) / 2400 B (dcrypt, expanded) | ct 1088 B | 32 B |
| ML-DSA-65 | 1952 B | ~4032 B | sig 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)”| keygen | op (encaps / sign) | op (decaps / verify) | |
|---|---|---|---|
| ML-KEM-768 | 159 µs | 130 µs | 143 µs |
| ML-DSA-65 | 278 µs | 184 µs | 91 µs |
Speed is never the bottleneck (µs range, server-side). Size and wire-overhead are the costs that matter.
The decisions, with options
Section titled “The decisions, with options”Decision 1 — where PQC runs (architectural; settles everything else)
Section titled “Decision 1 — where PQC runs (architectural; settles everything else)”| Option | Cost | When |
|---|---|---|
| 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 action | only 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 brotli | 77 KB | 71 KB (smaller) |
| naming / standard | ”Kyber” — no FIPS-203 claim ⚠ interop risk | FIPS-203 / FIPS-204 named ✅ |
| family fit | standalone | same as phaze-crypto’s p256 / aes-gcm ✅ |
| secret-key storage | 2400 B (expanded) | 64 B (seed) ✅ at-rest |
| maturity / churn | stable 1.x, rand 0.8 ✅ | newer (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.
When to use A or B — real scenarios
Section titled “When to use A or B — real scenarios”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)”| Scenario | Why A | Field |
|---|---|---|
| EEG / PHI session blob in R2 | An 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 log | A 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 relay | Media/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 secrets | rust-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)”| Scenario | Why 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 decrypt | The 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.
PQC flexibility — the field is the knob
Section titled “PQC flexibility — the field is the knob”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-Sigalgo 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.
The built primitives
Section titled “The built primitives”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 DEMlet (pk, sk) = ml_kem::generate()?;let (ct, ss) = pk.encapsulate()?; // 32-byte shared secret = AES-256 keylet 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) — signatureslet (vk, sgk) = ml_dsa::generate()?;let sig = sgk.sign(b"long-lived manifest")?;vk.verify(b"long-lived manifest", &sig)?; // Ok(()) iff validBacked 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 OsRng — zero 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 Rustsign = "ed25519+ml-dsa"attribute (featurepqc-sig). It shares the classicalSignBodypath via a commonSignPartsextraction +finish_with_sig; only the header string differs (one algo vs two). IfPHAZE_ML_DSA_KEYis absent it degrades to ed25519-only. Proven at/api/signed-pq. - Verify:
ml_dsa::verify_response_sigparses the multi-algoX-Phaze-Sig, pulls theml-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 Rustencrypt = "ml-kem-768+aes-gcm"attribute (featurepqc-kem) — it readsPUBLIC_PHAZE_KEM_KEY(viactx.var), seals the body viaEncryptBody(reusing theSignPartsextraction), 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-shippedml_kem::open_sealed. Proven at/api/encrypted-pq(worker-build links clean); 7ml_kemtests. - 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 detachedX-Phaze-Sigpreserved. The reader decrypts, then verifies over the recovered bytes (ml_dsa::verify_response_sigfor the ml-dsa half). At-rest confidentiality and authenticity in one fence. The ed25519+ml-dsa composition reuses the sharedhybrid_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_KEYis 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.
Status
Section titled “Status”- 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-inpqcfeature. The fullml-kem-768+aes-gcmhybrid 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 Rustsign = "ed25519+ml-dsa"attribute (featurepqc-sig) writes the dualX-Phaze-Sig: ed25519:<b64>, ml-dsa:<b64>, sharing the classicalSignBodypath (only the header string differs); degrades to ed25519-only ifPHAZE_ML_DSA_KEYis absent (/api/signed-pq). Verify —ml_dsa::verify_response_sigparses 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): theencrypt = "ml-kem-768+aes-gcm"attribute (featurepqc-kem) seals the response body toPUBLIC_PHAZE_KEM_KEY(viactx.var) and emits the{kem,e,iv}envelope viaEncryptBody; the reader isml_kem::open_sealed. Backed byml_kemserialization +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+signcompose): sign-then-encrypt on the Rust arm — the body is signed (X-Phaze-Sig) then sealed; the reader decrypts, then verifies. Reuseshybrid_sig(no duplicated composition). Proven at/api/sealed-signed-pq. - Live e2e — GREEN (worker side):
build/pqc.test.mjs(modelssession.test.mjs) boots the rust-api worker and drives the fields at runtime:signed-pqemits the dualX-Phaze-Sigand the browser verifies the ed25519 half (native, 0 WASM),encrypted-pqemits the{kem,e,iv}envelope (1088-byte ML-KEM ct), primitives round-trip. Provisioning note (verified):PHAZE_SIGN_KEY/PHAZE_ML_DSA_KEYare secrets (.dev.vars/wrangler secret put),PUBLIC_PHAZE_KEM_KEYis a var (ctx.var≠ctx.secret). The 4032-byte ml-dsa secret (≈7.9 KB hex) loads under localwrangler 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 andopen_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’ssignfield 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, soml_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
Signaturetrait is impl’d but keygen returnsNotImplemented, 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_core0.6,sha2/sha3,zeroize) — zero browser weight, cleanwasm32build:- SLH-DSA-SHA2-128s / 192s / 256s (FIPS 205) —
phaze::crypto::slh_dsa/slh_dsa_192s/slh_dsa_256s, viafips205, featurepqc-slh. Hash-based (no lattice assumption). - Falcon-512 / -1024 (FN-DSA) —
phaze::crypto::falcon/falcon_1024, viafn-dsa, featurepqc-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, viaclassic-mceliece-rust, featurepqc-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.
- SLH-DSA-SHA2-128s / 192s / 256s (FIPS 205) —
- 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-gcmkeys stay node one-liners; the PQC keys are dcrypt-minted.
Generating the keys (deploy-time)
Section titled “Generating the keys (deploy-time)”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.