Post-quantum (Hybrid ML-KEM-768) identity option #28

Closed
opened 2026-07-25 17:43:51 +00:00 by Cordy · 3 comments
Owner

age v1.3.0+ ships built-in hybrid ML-KEM-768 + X25519 post-quantum keys (age-keygen -pq). The Go library (which we already pin at v1.3.1) exports GenerateHybridIdentity(), ParseHybridIdentity(), ParseHybridRecipient(); HybridRecipient/HybridIdentity satisfy the same age.Recipient/age.Identity interfaces our KeyProvider returns — so this is a type swap, not a rewrite.

  • Config toggle so KeyProvider issues Hybrid identities.
  • Constraint: a HybridRecipient refuses to mix with classical recipients (emits a "postquantum" label) → the recovery recipient must also be Hybrid.
  • Costs: ~2000-char recipients (bigger profile attribute), ~2× keygen, negligible enc/dec.

Corrects our earlier factual error ("age is not post-quantum"). Relevant to public-sector PQC-readiness asks. Sources: age README / pq.go.

age v1.3.0+ ships built-in hybrid **ML-KEM-768 + X25519** post-quantum keys (`age-keygen -pq`). The Go library (which we already pin at v1.3.1) exports `GenerateHybridIdentity()`, `ParseHybridIdentity()`, `ParseHybridRecipient()`; `HybridRecipient`/`HybridIdentity` satisfy the same `age.Recipient`/`age.Identity` interfaces our `KeyProvider` returns — so this is a **type swap, not a rewrite**. - Config toggle so KeyProvider issues Hybrid identities. - **Constraint:** a `HybridRecipient` refuses to mix with classical recipients (emits a "postquantum" label) → the **recovery recipient must also be Hybrid**. - Costs: ~2000-char recipients (bigger profile attribute), ~2× keygen, negligible enc/dec. Corrects our earlier factual error ("age is not post-quantum"). Relevant to public-sector PQC-readiness asks. Sources: age README / `pq.go`.
Author
Owner

Scoping note (2026-07-29, discussion with Nikola) — why this is gated on decisions, not effort.

What it is. All current age identities (Keycloak-profile user keys, deployment key, recovery key) are classical X25519. The threat that matters for storage is harvest-now-decrypt-later: an adversary who copies an encrypted bucket today decrypts it in 10–15 years — exactly the untrusted-third-party-bucket scenario this feature exists for. The fix is age's hybrid ML-KEM-768 + X25519 identity type (config toggle in age 1.3+, per ENCRYPTION-COMPARISON.md). Hybrid = protected by both algorithms, never weaker than today even if ML-KEM turns out flawed. ML-KEM-768 is NIST-standardized (FIPS 203) and in the Go stdlib since 1.24 — mature enough to ship.

The toggle is ~a day of work for a fresh instance. What is actually undecided:

  • Migration. Every existing object is encrypted to X25519 recipients; every existing user identity is X25519. Flipping the toggle means either keeping both identities per user forever, or building re-encryption — and Cairn has no key-rotation machinery at all yet. PQ is really "key rotation, plus a new key type"; rotation is the hard 90%.
  • Recovery key. The deployment recovery identity is X25519 (possibly stored offline/printed). A PQ rollout needs a new recovery identity and a before/after policy.
  • Sequencing against #29/#30/#31. OpenBao custody, credential-derived custody, and domain separation decide where identities live and how many there are; the identity format is the foundation they sit on. Shipping PQ first and then moving custody = doing the migration dance twice. That interlock is why this is bundled into the Flint/Manuel review rather than treated as a quick win.
  • Minor, decidable in passing: hybrid ciphertext header is ~1KB larger per file (irrelevant except at millions of tiny files); default-on for new instances vs opt-in.

Honest middle path if ever wanted early: a greenfield-only toggle — enabled only on instances that have never written encrypted data (refuse otherwise), no migration story, existing deployments untouched. Genuinely shippable in days and would support a "post-quantum option available" claim. Current decision is to keep it gated: the encryption layer is formally a probe awaiting external sign-off, and locking in an identity format before the custody/rotation decisions land is exactly the double-work the review exists to prevent.

Short version: the toggle is easy; the commitment it represents isn't.

Scoping note (2026-07-29, discussion with Nikola) — **why this is gated on decisions, not effort.** **What it is.** All current age identities (Keycloak-profile user keys, deployment key, recovery key) are classical **X25519**. The threat that matters for storage is *harvest-now-decrypt-later*: an adversary who copies an encrypted bucket today decrypts it in 10–15 years — exactly the untrusted-third-party-bucket scenario this feature exists for. The fix is age's **hybrid ML-KEM-768 + X25519** identity type (config toggle in age 1.3+, per ENCRYPTION-COMPARISON.md). Hybrid = protected by both algorithms, never weaker than today even if ML-KEM turns out flawed. ML-KEM-768 is NIST-standardized (FIPS 203) and in the Go stdlib since 1.24 — mature enough to ship. **The toggle is ~a day of work for a fresh instance.** What is actually undecided: - **Migration.** Every existing object is encrypted to X25519 recipients; every existing user identity is X25519. Flipping the toggle means either keeping both identities per user forever, or building re-encryption — and Cairn has **no key-rotation machinery at all** yet. PQ is really "key rotation, plus a new key type"; rotation is the hard 90%. - **Recovery key.** The deployment recovery identity is X25519 (possibly stored offline/printed). A PQ rollout needs a new recovery identity and a before/after policy. - **Sequencing against #29/#30/#31.** OpenBao custody, credential-derived custody, and domain separation decide *where identities live and how many there are*; the identity **format** is the foundation they sit on. Shipping PQ first and then moving custody = doing the migration dance twice. That interlock is why this is bundled into the Flint/Manuel review rather than treated as a quick win. - Minor, decidable in passing: hybrid ciphertext header is ~1KB larger per file (irrelevant except at millions of tiny files); default-on for new instances vs opt-in. **Honest middle path if ever wanted early:** a *greenfield-only* toggle — enabled only on instances that have never written encrypted data (refuse otherwise), no migration story, existing deployments untouched. Genuinely shippable in days and would support a "post-quantum option available" claim. Current decision is to keep it gated: the encryption layer is formally a probe awaiting external sign-off, and locking in an identity format before the custody/rotation decisions land is exactly the double-work the review exists to prevent. Short version: the toggle is easy; the commitment it represents isn't.
Author
Owner

Decision — deferred (Manuel's review, relayed by Nikola 2026-07-31):

"We don't know yet how post-quantum encryption is realized with multiple recipients."

Stays open, now explicitly blocked on #18 (group/space multi-recipient): the PQ toggle only makes sense once the multi-recipient model exists, so the hybrid ML-KEM-768 + X25519 interplay can be designed against real recipient sets rather than hypothetically. Revisit when #18 lands.

**Decision — deferred (Manuel's review, relayed by Nikola 2026-07-31):** > "We don't know yet how post-quantum encryption is realized with multiple recipients." Stays open, now explicitly **blocked on #18** (group/space multi-recipient): the PQ toggle only makes sense once the multi-recipient model exists, so the hybrid ML-KEM-768 + X25519 interplay can be designed against real recipient sets rather than hypothetically. Revisit when #18 lands.
Author
Owner

Shipped in v0.3.29 (PR #107). Nikola's research confirmed the unblocking fact: age wraps one file key into an independent stanza per recipient, so multi-recipient PQ (user+recovery, group spaces #18) needed no redesign — and we already pinned age v1.3.1, so zero new dependencies.

What shipped

  • storage.encryption.postQuantum: true → all custody backends (keycloak-profile, openbao, deployment) mint hybrid ML-KEM-768+X25519 identities (AGE-SECRET-KEY-PQ-1…).
  • The no-mixing constraint is enforced at startup: recovery (and deployment) identity must match the flavor; recoveryIdentityLegacy / deploymentIdentityLegacy keep the old classical keys decrypt-only. A test proves age rejects a mixed recipient set.
  • Upgrade-on-touch migration: existing classical identities get a hybrid identity minted on first use, classical RETAINED (multi-value Keycloak attribute / OpenBao legacyIdentities). Verified: pre-PQ data decrypts, new data is unreadable by the classical key, hybrid recovery works, and postQuantum: false rolls back cleanly to the retained classical identity.
  • Known limit: instanceSeed (#31) + PQ deployment identity refused at startup — HKDF derivation is X25519-only. Follow-up if anyone actually needs both.

To flip the dogfood (.249) — your side, config secret edit:

  1. age-keygen -pq → new hybrid recovery keypair (password manager).
  2. In cairn-enc-config: "postQuantum": true, recoveryIdentity = new hybrid key, recoveryIdentityLegacy = current classical recovery key.
  3. Restart pod; upload a file; pull the raw Garage object and verify age -d with the hybrid recovery key opens it and the header shows mlkem768x25519 stanzas; confirm an old file still opens in the UI.

Closing as shipped.

Shipped in **v0.3.29** (PR #107). Nikola's research confirmed the unblocking fact: age wraps one file key into an independent stanza per recipient, so multi-recipient PQ (user+recovery, group spaces #18) needed no redesign — and we already pinned age v1.3.1, so zero new dependencies. **What shipped** - `storage.encryption.postQuantum: true` → all custody backends (keycloak-profile, openbao, deployment) mint hybrid ML-KEM-768+X25519 identities (`AGE-SECRET-KEY-PQ-1…`). - The no-mixing constraint is enforced at startup: recovery (and deployment) identity must match the flavor; `recoveryIdentityLegacy` / `deploymentIdentityLegacy` keep the old classical keys decrypt-only. A test proves age rejects a mixed recipient set. - **Upgrade-on-touch migration:** existing classical identities get a hybrid identity minted on first use, classical RETAINED (multi-value Keycloak attribute / OpenBao `legacyIdentities`). Verified: pre-PQ data decrypts, new data is unreadable by the classical key, hybrid recovery works, and `postQuantum: false` rolls back cleanly to the retained classical identity. - Known limit: `instanceSeed` (#31) + PQ deployment identity refused at startup — HKDF derivation is X25519-only. Follow-up if anyone actually needs both. **To flip the dogfood (.249) — your side, config secret edit:** 1. `age-keygen -pq` → new hybrid recovery keypair (password manager). 2. In `cairn-enc-config`: `"postQuantum": true`, `recoveryIdentity` = new hybrid key, `recoveryIdentityLegacy` = current classical recovery key. 3. Restart pod; upload a file; pull the raw Garage object and verify `age -d` with the hybrid recovery key opens it and the header shows `mlkem768x25519` stanzas; confirm an old file still opens in the UI. Closing as shipped.
Cordy closed this issue 2026-08-04 11:38:14 +00:00
Sign in to join this conversation.
No labels
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: Cordy/Cairn#28
No description provided.