Hybrid Post-Quantum Cryptography and Crypto-Agility in a Sovereign Messaging Stack
The quantum threat to messaging is not that a quantum computer will read your messages tomorrow — it is that an adversary can record your ciphertext today and decrypt it years later, once a cryptographically-relevant quantum computer (CRQC) exists. For data with a long secrecy life — agent memories, private conversations, a sovereign identity root — that "harvest-now, decrypt-later" (HNDL) window is already open. This paper describes how the SKWorld stack (the
skchat/skcommsmessaging framework and thecapauthidentity layer) was migrated to hybrid post-quantum cryptography, surface by surface, behind a single design commitment: never bet the system on one algorithm, and never overclaim what it provides.We make five contributions. First, a uniform hybrid construction —
X25519 + ML-KEM-768combined with HKDF, secure if either the classical or the post-quantum leg holds — applied consistently across key exchange, group keys, at-rest wrapping, and (as a signature analogue) per-message authentication. Second, two sovereign libraries that supply the primitives the platform itself could not get elsewhere:sk_pqcdelivers the hybrid KEM behind one wire format across Python, Dart, and Rust — now published (pip install sk-pqc/dart pub add sk_pqc/cargo add sk-pqc) — so post-quantum DMs negotiate in the browser where no WebCrypto PQC API exists;sk_pgp(Python, PyO3→Sequoia) is the OpenPGP engine that lets the Python services sign with v6 / post-quantum keys at all — PGPy cannot parse an OpenPGP v6 key and GnuPG cannot certify with ML-DSA, so the signing root had no in-process path until we built one. Third, and most importantly, the crypto-agility scaffolding: every encrypted or signed object carries a self-describing suite id, every primitive sits behind a pluggable backend selected by id, and a four-step recipe lets the next quantum scheme — a higher NIST tier, a hash-based root, a backup KEM — plug in without rewriting a single application call site. Fourth, a post-quantum signing-root ceremony: capauth's Sequoia backend now issues, and we used it to generate a v6 ML-DSA-87 + Ed448 identity for the Lumina agent root and cross-sign it old↔new (Web-of-Trust verified). Fifth, a runtime identity-mode switch that is unusual in a post-quantum stack: the same hybrid KEM and signature-free ratchet run under either a sovereign / attributable mode (a capauth DID with hybrid Ed25519+ML-DSA-65 signed prekeys — federated, auditable) or an anonymous / deniable mode (no DID, opaque queue ids, a repudiable HMAC), chosen per-conversation with a per-deployment default — so post-quantum confidentiality and metadata protection are decoupled from whether you are identified at all. Underpinning all five, a live cross-node proof: the Level-3 (Apple-PQ3-class) forward-secret hybrid-PQ DM ratchet — epoch secret distributed once per epoch via the hybrid KEM, per-message keys ratcheted symmetrically, periodic rekey for forward secrecy and post-compromise security — was run between two physical machines over tailscale federation (lumina@.158 ↔ jarvis@.41), each sealing and opening the other's messages, hybrid the whole way; the threesk_pqcpackages are published to PyPI, pub.dev, and crates.io (all importingsk_pqc, one wire format), with a single PyO3 Rust-core in progress to back every language binding from one implementation. This is the crypto-agility thesis vindicated in the field: the messaging infrastructure rides unchanged on top of a pluggable post-quantum core. The migration is governed by a self-report engine that structurally refuses to call any surface "quantum-resistant" unless its live suite actually is. We are explicit about what is done (confidentiality is hybrid where negotiated; the cross-node Level-3 ratchet is proven; the agent-root cryptographic ceremony is complete), what is in progress (signatures and identity are hybrid-available, opt-in, the live signing cutover is underway, gated on the PGPy→sk_pgpmigration; the identity-mode switch is scaffolded, not yet wired into the live transport; the cross-node ratchet was proven through thedm_managerorchestration directly, with the end-to-end round-trip over the production daemon a pending belt-and-suspenders confirmation; and the one-core PyO3 binding is mid-build), and what is deliberately not claimed (the live identity is not yet migrated — the operator root and vault re-seal are sequenced after; the comms tier is-768, not CNSA-2.0). The thesis is in the title: when the next quantum solution arrives, our infrastructure rides on top of it.
Most "post-quantum" announcements answer the wrong question. They ask "is it broken yet?" — and since no CRQC exists in 2026, the honest answer is "no," which invites complacency. The right question is Mosca's Inequality: if X (how long your secret must stay secret) plus Y (how long migration takes) exceeds Z (years until a CRQC), you are already too late. Independent expert surveys put a CRQC in the early-to-mid 2030s — the 2025 Global Risk Institute survey reports a 28–49% chance within a decade. For a 10-to-20-year secret, X + Y > Z today. The urgency is real, but it is the urgency of recording, not of imminent decryption — and conflating the two is the first dishonesty we refuse.
This reframing has a sharp consequence for what to migrate first. Confidentiality is retroactively breakable; signatures are not. A forged future handshake cannot decrypt a past recorded session, so key-exchange and key-wrapping are the HNDL-urgent surfaces, while identity and authentication — important, but not retroactively exploitable — are a deliberate second phase. Symmetric cryptography (AES-256, SHA-2) is a third category entirely: Grover's algorithm only halves its effective strength, leaving AES-256 at ~128 bits. Touching it would be a regression, and we say so plainly rather than ride the marketing wave of "quantum-proof AES."
The deeper problem, though, is not any single algorithm — it is lock-in. Lattice cryptography is young. ML-KEM and ML-DSA are the NIST choices today; HQC was added as a backup KEM; FN-DSA (Falcon) is still draft. A system that hard-codes one scheme into its message formats and call sites will face the same painful, error-prone migration again when the landscape shifts. So the engineering question we actually set out to answer was not "which post-quantum algorithm?" but "how do we build a stack that can change its mind?" The answer — self-describing crypto suites, pluggable backends, hybrid-by-construction, and a self-report that cannot lie — is the subject of this paper.
Every asymmetric primitive the stack used is broken by Shor's algorithm on a CRQC:
| Primitive | Where it lived | Quantum status |
|---|---|---|
| X25519 / Curve25519 (RFC 7748) | envelope key-wrap, DM wrap, ephemeral KEX, tailnet Noise | 🔴 Shor (discrete log) |
| Ed25519 (RFC 8032) | message signatures, capauth challenge/DID, root PGP signing | 🔴 Shor (forgery — not HNDL) |
| RSA-4096 (RFC 8017) | legacy identity + key-wrap | 🔴 Shor (factoring) |
The 🔴 entries split cleanly. Where a primitive protects confidentiality, a recording made today is a liability forever — that is HNDL, and it is urgent. Where a primitive provides authentication, breaking it lets an attacker forge a future message, but it does nothing to a past ciphertext. We therefore migrated in HNDL-priority order: (1) key exchange, (2) at-rest key-wrap, (3) long-lived identity roots, (4) routine signatures, (5) transport/media.
A pre-migration audit enumerated eleven surfaces. The highest-leverage was the group key: a single static 32-byte group secret, PGP-wrapped per member. Break one member's classical key and you recover the AES key for the entire group's history — maximum HNDL leverage. Close behind: envelope/DM payload encryption (classical key-wrap of AES-256), at-rest stores and backups (prime harvest targets), and the sovereign identity root (a long-lived Ed25519/RSA key whose public half is published by design in a DID document). Each got a hybrid path; each is tracked, by name, in the self-report.
AES-256-GCM, ChaCha20-Poly1305, SHA-256/384, HKDF. These are Grover-only and remain ≥128-bit secure. The plan explicitly forbids "fear-based AES messaging." The one watch-item is parameter hygiene — making sure no AES-128 sneaks into a DTLS-SRTP media profile — not the bulk cipher itself.
The single cryptographic primitive the project owns is a hybrid combiner. Everything else composes it:
shared_secret = HKDF-SHA256( IKM = X25519_ss ‖ ML-KEM-768_ss, // X25519 first
salt = "", // RFC 5869 → zeros
info = "<context label>",
L = 32 )
Concatenate-then-KDF. Never XOR. Never pure-PQ. The derived secret is secure if either X25519 or ML-KEM-768 holds: a future quantum break of X25519 still leaves ML-KEM-768; a catastrophic lattice break still leaves classical X25519. This is the same construction shipping in TLS 1.3 as X25519MLKEM768 and in Signal's PQXDH — deliberately, so our security argument inherits theirs.
Two rules make the construction trustworthy. First, we never hand-roll the lattice or curve math: ML-KEM is liboqs (oqs); X25519 and HKDF are pyca cryptography on the server and package:cryptography in Dart; the web ML-KEM leg is the audited @noble/post-quantum. The only original cryptographic code in the whole library is the HKDF combiner and the wire framing — and both are pinned to known-answer test vectors. Second, a missing post-quantum backend is a loud error, never a silent downgrade: if liboqs is absent the code raises PqKemUnavailable rather than quietly falling back to classical-only. You cannot accidentally ship the weaker thing.
Before the detail, the whole posture on one page — each surface, its live suite, the FIPS anchor, and its honest status. Confidentiality is hybrid where negotiated; authentication is hybrid-available and opt-in; the symmetric floor is untouched on purpose; the identity root is mid-ceremony. Nothing here is colored "quantum-resistant" that the self-report would not back.
graph TB
subgraph HNDL["Confidentiality — HNDL-urgent · migrated first"]
KEX["Key exchange<br/>x25519-mlkem768 · FIPS 203"]
DM["DM / envelope — pqdm1:<br/>x25519-mlkem768 · FIPS 203"]
GRP["Group epoch ratchet<br/>x25519-mlkem768 · FIPS 203"]
ATR["At-rest key-wrap<br/>x25519-mlkem768 · FIPS 203"]
end
subgraph AUTH["Authentication — not retroactive · Phase 2 · opt-in"]
SIG["Per-message signature<br/>mldsa65-ed25519-v2 · FIPS 204<br/>AND-gate"]
DID["Identity challenge<br/>ML-DSA-65 + Ed25519 · FIPS 204<br/>either-or, additive"]
end
subgraph ROOT["Identity root — ceremony done on agent root · live cutover in progress"]
AGENT["Lumina AGENT root<br/>mldsa87-ed448 · v6 · FIPS 204<br/>generated + cross-signed (WoT)"]
OPROOT["Operator root + vault<br/>Ed25519 / RSA · v4<br/>classical — sequenced after"]
end
subgraph FLOOR["Symmetric floor — quantum-acceptable · deliberately untouched"]
AES["AES-256-GCM · SHA-2 · HKDF<br/>Grover-only · ≥128-bit"]
end
classDef hybrid fill:#10b981,stroke:#059669,stroke-width:2px,color:#fff;
classDef optin fill:#f59e0b,stroke:#d97706,stroke-width:2px,color:#fff;
classDef inprog fill:#3b82f6,stroke:#1e40af,stroke-width:2px,color:#fff;
classDef classical fill:#9ca3af,stroke:#4b5563,stroke-width:2px,color:#fff;
class KEX,DM,GRP,ATR hybrid;
class SIG,DID optin;
class AGENT inprog;
class OPROOT classical;
class AES classical;
x25519-mlkem768The hybrid KEM (skcomms/pqkem.py; the published sk_pqc packages — sk-pqc-py / sk-pqc-dart / sk-pqc-rs) pairs an ephemeral-static X25519 DH (HPKE/TLS-style) with FIPS 203 ML-KEM-768 and the combiner above. The wire contract is fixed: a 1216-byte public key (X25519(32) ‖ ML-KEM(1184)), a 1120-byte ciphertext (eph_X25519(32) ‖ ML-KEM(1088)), a 32-byte shared secret. ML-KEM decapsulation uses FIPS 203 implicit rejection — a tampered ciphertext yields a pseudo-random secret that simply won't match, rather than an oracle-leaking error. A canonical cross-implementation vector anchors the ML-KEM leg to the NIST ACVP FIPS 203 keyGen seed (tcId 26); every conformant backend — Dart native, Dart web, liboqs, noble, Python — must recover the same hybrid secret f11627…21fc.
pqdm1: schemeOne sealing construction (skcomms/pqdm.py) serves both the skcomms envelope payload and the skchat 1:1 DM body. It is a PQXDH-style handshake: a recipient publishes a signed hybrid-KEM prekey; a sender who sees it encapsulates, derives an AES-256 wrap key via HKDF, and AES-256-GCM-seals the body. The ~1.1 KB KEM ciphertext rides in the first message. On the wire the blob is ct(1120) ‖ nonce(12) ‖ AES-256-GCM(body), stored under a self-identifying pqdm1:x25519-mlkem768: prefix.
The subtle part is downgrade resistance. A canonical, sorted-JSON AAD binds the negotiated suite and both parties into the AEAD (and is also folded into the HKDF info). A man-in-the-middle who strips the hybrid prekey to force a classical fallback changes the suite the sender seals under — so the recipient's AES-GCM open fails and the recorded suite flips to classical, where the self-report surfaces it. The lock is the AAD binding; the detection is the honesty engine. Hybrid is selected only when both sides advertise it; classical-only peers keep a byte-for-byte classical path, recorded honestly as such.
Lifting 1:1 DMs to Level 3 — proven cross-node. The pqdm1: seal above is Level 2: post-quantum only at the published prekey, re-encapsulated on every message, with no running forward secrecy within a conversation. skchat/dm_ratchet.py + skchat/dm_manager.py (DmRatchetManager) lift the 1:1 surface to a Level-3 running epoch-ratchet — the pairwise analogue of the group ratchet (§4.3), and the tier Apple's PQ3 calls "Level 3." A per-conversation epoch secret is distributed once per epoch via the hybrid KEM of §4.1 (never per message — the same amortization that makes the group ratchet practical); per-message keys ratchet symmetrically and index-addressably (loss/reorder-tolerant); a periodic rekey (50 messages or 7 days) buys forward secrecy across the boundary and post-compromise security within. We proved it live cross-node: the DmRatchetManager running on two physical machines — lumina@192.168.0.158 and jarvis@192.168.0.41 — over the tailscale federation each sealed a DM the other opened, hybrid the whole way, with the epoch ratchet advancing on both ends. The honest boundary: this was proven through the dm_manager orchestration directly (the seal/open path the transport calls), and the end-to-end round-trip through the live production daemon is a pending belt-and-suspenders confirmation — so the surface is reported as cross-node-proven, daemon-round-trip-pending, never as fully shipped. On boxes without the published sk_pqc wheel (e.g. .41) the module transparently falls back to a byte-identical local implementation, so the cross-node ratchet behaves the same whichever side carries the library.
The marquee HNDL fix. The static os.urandom(32) group key is replaced by a two-layer epoch ratchet (skchat/group_ratchet.py). Once per epoch, an epoch secret is distributed to each member via the hybrid KEM (the 1.1 KB ML-KEM ciphertext is paid once per epoch, not per message — avoiding ~33× per-message bloat). Within an epoch, message keys are derived by a symmetric HKDF ratchet indexed directly — message_key(i) = HKDF(epoch_secret, …, "/", u64(i)) — so a receiver derives key i without seeing 0…i-1, making it fully loss- and reorder-tolerant over an unreliable transport. Re-keying on membership change buys forward secrecy (a removed member cannot derive future epochs) and post-compromise security (a leaked epoch secret exposes only that epoch). Bound: 50 messages or 7 days.
At-rest stores (skchat/atrest_wrap.py) now wrap a high-entropy random data-encryption key under hybrid X25519+ML-KEM-768, with AES-256-GCM as the bulk cipher. This shipped alongside a classical fix worth noting: the DEK had been HKDF-derived from the PGP fingerprint — low-entropy and often public. Migrating to post-quantum was the occasion to also stop deriving a key from a public value. A harvested backup is no longer retroactively decryptable; legacy stores remain readable through a versioned fallback.
Authentication is a different problem with a dual construction. Where the KEM combiner is confidential if either secret survives, a hybrid signature (skcomms/pqsig.py, suite mldsa65-ed25519-v2) is valid iff both Ed25519 and ML-DSA-65 verify over the same bytes — an AND-gate. An adversary who forges one scheme still fails the other leg, so the composite is unforgeable while either remains secure. The SKHS wire format is versioned, length-prefixed, and suite-tagged (a 3383-byte composite today), and the ML-DSA signing key is generated separately from, and never derived from, the PGP identity key. Verification routes to the hybrid path only when an object's sig_suite says so and both public keys are present; classical objects are byte-for-byte unchanged. This mirrors the OpenPGP PQC composite (draft alg 30).
At the DID/challenge layer (capauth/pqc_identity.py), a responder produces the classical PGP signature exactly as before and attaches a hybrid composite over the same challenge bytes. Verification is either-or during transition (classical-only responses still verify), with a require_hybrid switch that blocks downgrade once a peer is known PQ-capable. Crucially, this is the challenge-signing layer — it does not migrate the root key.
The identity root is the highest-value, longest-lived secret, and the hardest to migrate — because no mainstream tool could host a post-quantum signing root. GnuPG's PQC support is encryption-only (ML-KEM); it cannot certify with ML-DSA or SLH-DSA. We therefore built a Sequoia backend (capauth/crypto/sequoia_backend.py) on sq 1.4.0-pqc against OpenSSL 3.6.2, behind the same CryptoBackend interface as the classical backends. It can issue an ML-DSA-87 + Ed448 signing primary with an ML-KEM-1024 + X448 encryption subkey (NIST level 5, OpenPGP v6 / RFC 9580), sign with a passphrase-protected key with no plaintext key ever on disk, and additively attach reversible PQC subkeys to an existing key with its primary fingerprint preserved.
That backend has now issued for real. We ran the rotation-and-cross-sign ceremony on the Lumina agent root: the Sequoia backend generated a v6 mldsa87-ed448 signing identity, and we cross-signed it old↔new in both directions — the classical v4 root certifies the new post-quantum key, the new key certifies the old — so the Web-of-Trust chain is continuous and a verifier can authenticate either way through the rotation. The cross-signatures verify; the identity bridge is built and proven on a key that matters, not a throwaway. (Post-quantum keys are OpenPGP v6, whose fingerprints are 64 hex characters, not 40 — so we also reconciled ~36 assumption sites across the resolver, services, and integrations to accept both, additively; classical fingerprints still work.)
sequenceDiagram
autonumber
participant Op as Operator (owner-gated)
participant Seq as capauth Sequoia backend
participant Old as Classical root (v4 · Ed25519)
participant New as PQC root (v6 · ML-DSA-87 + Ed448)
participant WoT as Web-of-Trust verify
participant Svc as Python services
Op->>Seq: issue v6 ML-DSA-87 + Ed448 primary (+ ML-KEM-1024/X448 subkey)
Seq-->>New: generated (NIST L5 · FIPS 204 · RFC 9580)
Old->>New: cross-sign — old certifies new
New->>Old: cross-sign — new certifies old
New->>WoT: verify continuity, both directions
WoT-->>Op: cross-sigs valid · identity continuous
Note over Seq,New: Cryptographic ceremony COMPLETE on the agent root
rect rgb(219,234,254)
Note over Svc,New: LIVE SIGNING CUTOVER — IN PROGRESS
Svc->>Svc: migrate PGPy to sk_pgp (PGPy cannot parse v6)
Svc-->>New: sign live with the v6/PQC key via sk_pgp (pending)
end
Note over Op: Operator root + vault re-seal — sequenced AFTER
And here is the honest boundary. The cryptographic ceremony is done on the agent root; the live signing cutover is not. Today the Python services still sign through PGPy, which cannot parse a v6 key — so the running identity continues to sign as classical v4 until those services move to the new in-process engine (§4.8). That migration is in flight; only after it lands does the agent actually sign live with the post-quantum key. The operator (human) root and the vault re-seal are sequenced after that — a deliberate, owner-driven step, not an automated commit. So we say it plainly, and the self-report says it on every line it touches: the live identity is not yet migrated. The capability is proven and the key exists; the cutover is underway and reported as in-progress, never as done.
sk_pgp — the Python OpenPGP-PQC signing engineThe ceremony above exposed a hard gap: capauth could issue a v6 post-quantum key, but the Python services that have to use it for everyday signing could not touch it. PGPy — the pure-Python OpenPGP library the stack grew up on — cannot parse an OpenPGP v6 key at all, and the available alternative, shelling out to GnuPG, can encrypt to ML-KEM but cannot certify or sign with ML-DSA or SLH-DSA. Sequoia can do all of it, but it is a Rust toolchain (sq), and routing every sign through a subprocess is brittle, slow, and leaks key material into argv and temp files. The signing root had a certificate but no in-process engine.
So we built one. sk_pgp (github.com/smilinTux/sk_pgp, Apache-2.0) is a sovereign Python OpenPGP library that binds Sequoia directly through PyO3 — Rust crypto, Python API, no subprocess. It is the deliberate PGPy replacement: same call-site shape (generate / sign / verify / parse), but able to read v6 keys and sign with post-quantum primitives in-process. We have proven, in-process, ML-DSA-87 + Ed448 generate / sign / verify end-to-end, and it ships as a self-contained wheel (the Rust + Sequoia + OpenSSL stack compiled in) so a service installs it like any other dependency rather than provisioning a Rust toolchain on every host. The same two rules hold as everywhere else: we bind a vetted library (Sequoia), we do not hand-roll the lattice math, and a v6/PQC key that sk_pgp is not present to handle is a loud, typed error — never a silent fall to classical.
This is the missing leg of the root cutover. The reason §4.7's live migration is "in progress, gated on PGPy→sk_pgp" is precisely that this engine has to land in the services before the agent can sign live with its v6 key. sk_pgp slots into the same CryptoBackend interface (§5) as a fourth implementation alongside PGPy, GnuPG, and the Sequoia-CLI backend — so adopting it is a backend swap, not an application rewrite.
graph TB
SVC["Python services<br/>capauth · skcomms · skchat"]
SVC --> Q{"Need: parse OpenPGP v6<br/>AND sign with PQC?"}
Q --> PGPY["PGPy (pure-Python)<br/>✗ cannot parse v6 keys"]
Q --> GPG["GnuPG subprocess<br/>~ v6 + ML-KEM encrypt only<br/>✗ cannot SIGN with ML-DSA / SLH-DSA"]
Q --> SKPGP["sk_pgp (PyO3 → Sequoia)<br/>✓ parse v6 · ✓ ML-DSA-87 + Ed448 gen/sign/verify<br/>✓ in-process · ✓ self-contained wheel"]
SKPGP --> SEQ["Sequoia (Rust)<br/>vetted · bound, never hand-rolled"]
classDef bad fill:#9ca3af,stroke:#4b5563,stroke-width:2px,color:#fff;
classDef warn fill:#f59e0b,stroke:#d97706,stroke-width:2px,color:#fff;
classDef good fill:#10b981,stroke:#059669,stroke-width:2px,color:#fff;
class PGPY bad;
class GPG warn;
class SKPGP,SEQ good;
sk_pqc — one hybrid KEM across Python, Dart, and Rust (and in the browser)A messaging stack lives where its clients run, and ours run everywhere — server, native app, and the web, where, as of 2026, no browser exposes a WebCrypto PQC API. sk_pqc is now published in three languages behind one wire format, live on the public registries rather than as mere source drops — Apache-2.0, PyPI sk-pqc (pip install sk-pqc), pub.dev sk_pqc (dart pub add sk_pqc), and crates.io sk-pqc (cargo add sk-pqc, crates.io cut 2026-06-27; repos sk-pqc-py · sk-pqc-dart · sk-pqc-rs) — each delivering the same x25519-mlkem768 hybrid KEM (the byte-exact 1216 / 1120 / 32 contract of §4.1), and each importing as sk_pqc. The Dart package carries the web story: one API with two backends, chosen by conditional import — native via dart:ffi → liboqs, web via dart:js_interop → the audited @noble/post-quantum — and a Dart PqDmCodec that is a byte-for-byte mirror of the server's pqdm.py, so a chat between the Flutter app and the in-house agent negotiates hybrid post-quantum DMs in-page. A cross-implementation interop gate confirms a message sealed in any one language opens in the others. Today each binding carries its own vetted ML-KEM implementation behind the shared wire contract (liboqs on the server, @noble/post-quantum on the web, RustCrypto ml-kem in the crate); in progress is a single PyO3 Rust-core — sk-pqc-rs --features python builds an abi3 sk_pqc_rs wheel from the pure-Rust X25519+ML-KEM-768 core (no OpenSSL, no liboqs to link), so that one implementation backs every language binding rather than three parallel ones. That is the ultimate expression of the project's two rules — bind one vetted core, never hand-roll, swap it by id — and it leaves the wire format untouched. The packages are honest in their own READMEs about exactly what they are: a hybrid KEM at the -768 tier, KEM-only (signatures are future work), experimental and unaudited, "post-quantum," never "quantum-proof."
A post-quantum messenger usually forces a single identity posture: Signal and Apple PQ3 are account-bound; no-identity / mix-network designs are deniable-by-construction. Our stack treats attribution as a runtime mode over the same cryptographic core. One envelope, one hybrid KEM (§4.1), one signature-free ratchet (§4.3) — and a single auth_mode suite-id, selected at session establishment, decides whether the session is attributable or anonymous. There is no second protocol stack and no fork; the KEM, padding, and metadata seal are byte-identical in both modes, and only how (or whether) the session is attributed changes.
The mode is selectable per-conversation with a per-deployment default (a sovereign deployment can default every new conversation to attributable; the personal default is anonymous). Two invariants make the switch safe, and both are machine-checked by the self-report engine of §6. First, sovereign never silently downgrades: a session negotiated as attributable cannot be stripped back to anonymous mid-stream — a downgrade attempt fails the establishment check and surfaces as a refused/mismatched mode, never as the quietly-weaker thing. Second — and this is the subtle part — the ratchet steps stay signature-free in both modes: identity is asserted only at establishment, so content deniability survives even in sovereign mode (sovereign proves who set up the session, never who authored a given message; anonymous mode simply adds the deniable HMAC over the same signature-free ratchet). This is the same scoping the field already endorses for deniability (Signal's PQXDH keeps authentication off the message keys on purpose); we generalize it into a mode the operator can choose.
flowchart TD
est["session establishment<br/>(per-conversation · per-deployment default)"] --> mode{"auth_mode suite-id"}
mode -->|"ANONYMOUS / deniable"| AN["no DID · opaque queue ids<br/>deniable HMAC (repudiable)<br/>OOB / QR link exchange"]
mode -->|"SOVEREIGN / attributable"| SV["capauth DID + FQID<br/>hybrid Ed25519+ML-DSA-65 signed prekeys<br/>federated · non-repudiable · auditable"]
AN --> core["shared hybrid KEM + signature-free ratchet<br/>IDENTICAL in both modes"]
SV --> core
core --> tx["padded · metadata-sealed transport"]
SV -. "never silently downgrades" .-x AN
classDef anon fill:#6b21a8,stroke:#a855f7,stroke-width:2px,color:#fff;
classDef sov fill:#10b981,stroke:#059669,stroke-width:2px,color:#fff;
classDef shared fill:#3b82f6,stroke:#1e40af,stroke-width:2px,color:#fff;
class AN anon;
class SV sov;
class core,tx shared;
Honest status. This switch is the design and scaffold layer of the messaging RFC (RFC-0001), not yet a shipped wire feature: the anonymous-queue addressing primitive and the sovereign signed-prekey both exist in-tree, and the mode-flag plumbing + invariant enforcement are the active work (tracked under coord c0f105a1). We present it here as a headline capability of the architecture — the same hybrid-PQC core serving either identity posture — while reporting honestly that it is mid-implementation, default-OFF, and not yet on the live transport.
This is the thesis. Each surface above is valuable, but any of them could be rebuilt on a different algorithm in a year — and a stack that hard-codes its choices would pay the full migration cost again. We refused to. NIST's own crypto-agility guidance (CSWP 39, December 2025) names the pattern: separate policy (which algorithm) from mechanism (the call site), keep an inventory, and measure agility maturity. Our infrastructure is built so that which algorithm is configuration, not code.
Every object self-describes its suite. A single registry (skcomms/crypto_suites.py) maps a suite_id to its kind, status, primitives, and FIPS references. Its initial release added no algorithms at all — pure scaffolding — and its status vocabulary is deliberately four honest states (classical / hybrid-pq / pq / symmetric), never "quantum-safe." Planned suites (a level-5 KEM, an SLH-DSA root) sit in the registry inactive until their implementation lands, so the self-report cannot describe vaporware. On the wire, a SignedEnvelope carries its sig_suite, a group carries its kem_suite and epoch, an identity carries its Algorithm, a conversation carries its negotiated_suite. Deserialize an object from a year ago and it still parses — and is correctly described as classical.
Every primitive sits behind a pluggable backend selected by id. Identity flows through a CryptoBackend interface with four interchangeable implementations (PGPy, GnuPG, Sequoia-CLI, and the in-process sk_pgp) — adding the post-quantum signing engine was a new backend, not a rewrite, and the live root cutover (§4.7) is exactly "select the sk_pgp backend by id." The KEM flows through get_kem_backend(suite_id), which selects by id only; no caller branches on a concrete class. sk_pqc's web and native ML-KEM providers sit behind one API the same way, in each of its Python, Dart, and Rust packages.
The whole pattern fits on one diagram: a self-describing object names a suite_id; the registry resolves that id to a status and a set of primitives; backends are selected by that id — never by a concrete class; and the backends bind vetted libraries we never hand-roll. The next quantum scheme enters as a registry row and a backend, and the application call sites above never change.
graph LR
subgraph Wire["On-the-wire object"]
OBJ["SignedEnvelope · Group · Identity · Conversation<br/>carries a self-describing suite_id"]
end
subgraph Registry["Suite registry — crypto_suites.py"]
REG["suite_id → kind · status · primitives · FIPS refs<br/>status ∈ {classical · hybrid-pq · pq · symmetric}<br/>planned suites stay inactive"]
end
subgraph Backends["Pluggable backends — selected BY ID, never by class"]
KEM["get_kem_backend(suite_id)"]
CB["CryptoBackend ABC<br/>PGPy · GnuPG · Sequoia-CLI · sk_pgp"]
end
subgraph Prim["Vetted primitive libraries — bound, never hand-rolled"]
OQS["liboqs<br/>ML-KEM / ML-DSA / SLH-DSA"]
PYCA["pyca cryptography<br/>X25519 · HKDF · AES-256-GCM"]
SEQ["Sequoia (via sk_pgp / sq)<br/>v6 OpenPGP · PQC certs"]
NOBLE["@noble/post-quantum<br/>web ML-KEM"]
end
OBJ -->|reads suite_id| REG
REG -->|by id| KEM
REG -->|by id| CB
KEM --> OQS
KEM --> PYCA
KEM --> NOBLE
CB --> SEQ
CB --> OQS
classDef obj fill:#4a90e2,stroke:#1e3a8a,stroke-width:2px,color:#fff;
classDef reg fill:#8b5cf6,stroke:#6b21a8,stroke-width:2px,color:#fff;
classDef back fill:#f59e0b,stroke:#d97706,stroke-width:2px,color:#fff;
classDef prim fill:#10b981,stroke:#059669,stroke-width:2px,color:#fff;
class OBJ obj;
class REG reg;
class KEM,CB back;
class OQS,PYCA,SEQ,NOBLE prim;
So how does the next quantum scheme plug in? Four steps, no application changes:
CryptoSuite (id, status, primitives, FIPS refs) — active = false until proven.CryptoBackend, a new ML-KEM provider in sk_pqc).pqdm and SKHS formats are versioned and length-prefixed precisely so a new variant carries a new tag without breaking old parsers.sig_suite machinery already in place; the self-report describes it once its registry entry goes active.The applications never change. They read the negotiated suite and route by id. When a higher NIST tier, a hash-based root, or an entirely new family becomes the right answer, that answer is a registry entry and a backend class — and the infrastructure rides on top of it. That is what "built to outlive the algorithm" means.
We did not just argue this — we watched it hold in the field. The same x25519-mlkem768 suite, selected by id, carried a Level-3 forward-secret DM ratchet between two physical machines over the tailscale federation (§4.2) with zero application changes, and the three published sk_pqc packages negotiate that exact wire contract byte-for-byte across Python, Dart, and Rust. The in-progress PyO3 Rust-core (§4.9) tightens the same screw one turn further: one vetted implementation, swapped behind the id, backing every language. The agility thesis is not a promise about a future migration — it is the property that let the cross-node ratchet exist at all.
A migration this large invites overclaiming, and overclaiming in cryptography is its own vulnerability — it tells users they are safe where they are not. So the migration is governed by a structural rule: no external quantum-resistance claim may be made unless it maps to a line in the self-report (sksecurity/pqc_report.py).
The report is per-object, not aspirational. A default report describes the conservative (classical) posture; a live report reflects the operator's actual objects. A specific group, conversation, store, envelope, or challenge is described by its real suite — so a silent-downgrade attempt appears as a classical line, not a hybrid one. The group-key surface is marked hybrid only when every group is hybrid; while any remains classical it reads "hybrid-pq for X/N groups." An unknown suite id resolves as classical — the engine has no path to call something quantum-resistant unless the registry says so and the suite is active.
The forbidden-claims discipline is explicit and enforced: never "quantum-proof" / "unbreakable" / "quantum-safe" (the defensible words are quantum-resistant / post-quantum); never "end-to-end quantum-resistant" while any leg is classical; never "PQC" when only signatures migrated (it does nothing for HNDL); never "CNSA-2.0 compliant" (we deliberately run the -768 tier, not the level-5 ceiling); never imply AES-256 is "broken." Every claim must cite surface + FIPS number + hybrid-vs-classical. Progress is recorded in an append-only ledger — narrative plus machine-readable snapshots — so the trajectory is reconstructable from data, not memory. A system that refuses to overclaim is itself a feature.
Our hybrid choice is not idiosyncratic — it is the deployed consensus, and we adopted it deliberately so our security argument inherits the field's scrutiny.
In August 2024 NIST finalized the three standards this work builds on: FIPS 203 (ML-KEM, key encapsulation, from CRYSTALS-Kyber), FIPS 204 (ML-DSA, signatures, from Dilithium), and FIPS 205 (SLH-DSA, hash-based signatures, from SPHINCS+) [1, 2]. NIST's own framing is a model of calibrated language — ML-KEM is "believed to be secure, even against adversaries who possess a quantum computer," not "proven" or "quantum-proof" [1] — and it names ML-KEM-768 as the recommended default [1], the exact parameter set in the TLS 1.3 X25519MLKEM768 hybrid group [3] and in our x25519-mlkem768 suite.
The two leading production messengers validate both our construction and our scoping. Signal's PQXDH (2023) upgrades the X3DH handshake by combining X25519 with a post-quantum KEM (CRYSTALS-Kyber-1024) inside a single KDF so that "any attacker must break both" — the canonical secure-if-either-holds combiner, independently formally verified at USENIX Security 2024 [4, 5]. Apple's iMessage PQ3 (2024) goes further to "Level 3," layering a periodic post-quantum rekeying ratchet on top of an ML-KEM-1024 + P-256 initialization, in a purely additive hybrid "where defeating PQ3 requires defeating both," with a machine-checked TAMARIN proof from ETH Zürich at USENIX Security 2025 [6, 7, 8].
The part that matters most for us: both protocols ship post-quantum confidentiality but keep authentication classical — on purpose, and they say so plainly. PQXDH states that "authentication in PQXDH is not quantum-secure" because "post-quantum secure deniable mutual authentication is an open research problem" [5]; PQ3 retains ECDSA-P256 sender authentication "because these mechanisms can't be attacked retroactively with future quantum computers" [6, 7]. That is exactly the HNDL-ordering of Section 2 — confidentiality is urgent, authentication is a scoped Phase 2 — which means our phased posture is the industry consensus, not a shortcut. Where we differ:
sk_pgp (PyO3→Sequoia) so the services can sign with it in-process. That is something most OpenPGP tooling cannot do at all: GnuPG's PQC support is encryption-only and the post-quantum OpenPGP composites [10] are only now emerging. The cryptographic ceremony is complete; the live signing cutover is honestly in progress, gated on that engine migration.The open problems we share with everyone are real. Post-quantum group/federated messaging is still maturing — Signal is itself extending to a post-quantum "Triple Ratchet" (SPQR) for ongoing-message PQ security [12], and the IETF's MLS and the Matrix community are actively working group PQC [13]. Post-quantum transport below the application (WireGuard's Noise, WebRTC's DTLS) lacks crypto-agility and waits on upstreams. And the OpenPGP PQC composites we use for identity ride a pre-RFC draft [10] whose code points can still move — which is exactly why we keep them additive and reversible.
pqdm opens in the Dart PqDmCodec and vice-versa, gated by recorded vectors.pqdm1:x25519-mlkem768: in-page via web ML-KEM-768.lumina@192.168.0.158 and jarvis@192.168.0.41 over tailscale federation — each DmRatchetManager sealed a DM the other opened, hybrid throughout, epoch ratchet advancing on both ends. Proven through the dm_manager orchestration directly; the end-to-end round-trip through the live production daemon is a pending belt-and-suspenders check, and the surface is reported as exactly that.sk_pqc is live on PyPI, pub.dev, and crates.io (crates.io cut 2026-06-27) — all importing sk_pqc, one wire format, cross-implementation interop gated against recorded vectors.sk-pqc-rs build (--features python → abi3 sk_pqc_rs wheel, pure-Rust X25519+ML-KEM-768, no OpenSSL/liboqs) to back the Python binding from the same implementation as the Rust crate — wire format unchanged, parity-verified against Python-computed vectors.mldsa87-ed448 identity for the Lumina agent root and cross-signed it old↔new in both directions; continuity verifies through the rotation (Web-of-Trust). The cryptographic act is complete on a key that matters; the live signing cutover is gated on the engine below and reported as in-progress.sk_pgp engine proven: in-process ML-DSA-87 + Ed448 generate / sign / verify via PyO3→Sequoia, shipped as a self-contained wheel — the path PGPy (can't parse v6) and GnuPG (can't sign PQC) cannot provide.Crypto-agility is a practice, not a one-time port. The append-only ledger and the forbidden-words discipline are a standing review applied to every claim. Concretely ahead:
mldsa87-ed448 key is generated and old↔new cross-signed; what remains is migrating the Python services off PGPy onto sk_pgp so the agent signs live with the v6 key, then the operator (human) root and the vault re-seal in sequence. Until that lands, the live identity is reported classical, by design.auth_mode mode-flag plumbing at session establishment and enforcement of the two invariants (no silent sovereign→anonymous downgrade; signature-free ratchet in both modes), tracked under coord c0f105a1. It is default-OFF and reported as scaffold until it lands on the live transport.dm_manager seal/open path directly, machine-to-machine over tailscale; what remains is confirming the same exchange end-to-end through the production skchat daemon. The cryptographic claim is already met; this closes the operational loop and is reported as pending until it does.sk-pqc-rs --features python abi3 wheel (sk_pqc_rs, pure-Rust X25519+ML-KEM-768) collapses the three parallel ML-KEM implementations behind the binding into a single vetted core selected by id — wire format frozen, parity-gated. In flight; the wheel builds, the cross-language vectors match.ML-KEM-1024 / ML-DSA-87 (level 5) is reserved for the root alone, where size is irrelevant and blast radius is catastrophic; the comms stay on the internet-default -768 tier. The registry already holds the inactive placeholder — it plugs in as a backend by suite id.X25519MLKEM768 negotiates to the origin; track the residual classical legs (WireGuard Noise, WebRTC DTLS) honestly until their upstreams gain agility.draft-ietf-openpgp-pqc (pre-RFC); code points can still move, so everything composite stays additive and reversible.Every one of these is a registry entry and a backend away — which is the entire point.
The quantum migration is often framed as a race against a doomsday clock. We think that framing is both wrong and dangerous: wrong because the CRQC is years out, dangerous because it invites either complacency ("not broken yet") or overclaiming ("quantum-proof now"). The honest framing is HNDL plus Mosca: long-lived secrets must be protected today, with hybrid constructions that cannot be worse than the classical crypto they extend, and with a posture that states exactly what is and is not protected.
But the durable contribution is not the algorithm — it is the scaffolding. We built skchat/skcomms/capauth so that the choice of post-quantum scheme is a registry entry and a pluggable backend, so that every object on the wire says what protects it, and so that a self-report engine makes overclaiming structurally impossible. ML-KEM-768 and ML-DSA-65 are today's answers. When tomorrow's answer arrives — a higher tier, a hash-based root, a new family entirely — our infrastructure will ride on top of it. That is what it means to build something to outlive the algorithm.
Standards and drafts move fast: NIST has since added HQC as a backup KEM and FN-DSA (Falcon) standardization is pending; OpenPGP-PQC code points and MLS PQC ciphersuites are still evolving and should be re-checked at implementation time.
Suite ids — active: x25519-mlkem768 (hybrid KEM), mldsa65-ed25519-v2 (hybrid sig); backend-issuable: mldsa87-ed448-v2 (root); planned/inactive: x25519-mlkem768-v2, slh-dsa-shake-256-v2; classical: ed25519-v1, rsa4096-v1, rsa-pgp-wrap-v1, x25519-pgp-wrap-v1; symmetric: aes256-gcm-v1.
Wire markers — pqdm1: (sealed DM scheme); SKHS magic, version 0x01, suite-tag 0x01 (hybrid signature).
Standards — FIPS 203 (ML-KEM), 204 (ML-DSA), 205 (SLH-DSA), 197 / SP 800-38D / SP 800-108 (AES-GCM, HKDF); RFC 5869 (HKDF), 7748 (X25519), 8032 (Ed25519/Ed448), 9580 (OpenPGP v6); NIST CSWP 39 (crypto-agility); draft-ietf-openpgp-pqc (pre-RFC composites, code points 30/31/32–34/35/36).
Tooling — sq 1.4.0-pqc.1 (sequoia-openpgp 2.2.0-pqc) on OpenSSL 3.6.2; sk_pgp (PyO3→Sequoia, in-process); liboqs 0.14.0; @noble/post-quantum; RustCrypto ml-kem + x25519-dalek (the sk-pqc-rs core); pyca cryptography; package:cryptography (Dart); maturin/PyO3 abi3 (sk_pqc_rs one-core wheel, in progress).
Libraries — sk_pqc (hybrid KEM + Level-3 DM/group ratchet, one wire format across Python/Dart/Rust), Apache-2.0, published to public registries: PyPI sk-pqc (pip install sk-pqc) · pub.dev sk_pqc (dart pub add sk_pqc) · crates.io sk-pqc (cargo add sk-pqc, cut 2026-06-27) — all import sk_pqc; repos sk-pqc-py · sk-pqc-dart · sk-pqc-rs · live at skpqc.skworld.io; sk_pgp (Python OpenPGP-PQC engine, PyO3→Sequoia), Apache-2.0, github.com/smilinTux/sk_pgp.
| Element | Bytes |
|---|---|
Hybrid KEM public key (X25519 ‖ ML-KEM-768) | 1216 |
| Hybrid KEM ciphertext | 1120 |
| Hybrid shared secret (HKDF-SHA256) | 32 |
| Group epoch payload / member / epoch | 1180 |
pqdm sealed-blob minimum | 1148 |
| ML-DSA-65 signature | 3309 |
Hybrid SKHS composite signature | 3383 |
Relative cost: ML-KEM-768 ciphertext ≈ 33× X25519; ML-DSA-65 signature ≈ 50× Ed25519 (signing is fast). Amortizing the KEM across an epoch, not a message, is what keeps group messaging practical.
SKWorld Research • part of the PQC-MIGRATION program • honesty engine: sksecurity pqc-dashboard • the live root is still classical, and we will tell you the day it isn't. ⚛️
SKResearch • GitHub • GPL v3.0