Domain separation (instanceSeed) for post-quantum deployment identities #108

Open
opened 2026-08-04 12:06:50 +00:00 by Cordy · 4 comments
Owner

Tracking issue for the gap shipped in v0.3.29 (#28): storage.encryption.instanceSeed (#31) combined with postQuantum: true under keyCustody: "deployment" is refused at startup with a clear error.

Why: #31's derivation is HKDF-SHA256(master, seed) → 32 bytes → X25519 identity — valid because any 32 uniform bytes ARE an X25519 key, and our bech32 encoder renders them as AGE-SECRET-KEY-1…. A hybrid identity adds ML-KEM-768 key material, and filippo.io/age exposes only GenerateHybridIdentity() (random) and ParseHybridIdentity() (string) — no deterministic from-seed constructor. ML-KEM keygen IS deterministic from a seed per FIPS 203, and age's PQ bech32 payload is likely seed-based, so this is probably implementable — but it would mean depending on age's internal identity serialization without a contract. Refusing loudly beat guessing inside the key layer.

Why it's not a blocker today:

  • Only affects keyCustody: "deployment" — keycloak-profile and openbao custody never use instanceSeed.
  • The affected scenario is an operator who reuses ONE master key across instances. Under PQ the same (stronger) separation is available today with no derivation: give each instance its own hybrid deploymentIdentity and park the old shared master in deploymentIdentityLegacy. A PQ migration is the natural moment to stop key-sharing anyway — this is the documented recommendation.

When to actually build it: a deployment-custody customer with shared master + seed who wants PQ and cannot split keys. Options then:

  1. Verify age's hybrid identity encoding is a stable seed encoding → extend DeriveInstanceIdentity (HKDF → seed bytes → bech32 AGE-SECRET-KEY-PQ-ParseHybridIdentity as oracle, same pattern as the X25519 path).
  2. Upstream feature request: a public from-seed constructor for HybridIdentity in filippo.io/age — the clean fix.
  3. Fallback: document per-instance identities as the only PQ separation mechanism.

No milestone — parked until demand exists. The startup error message points operators at the per-instance-identity workaround.

Tracking issue for the gap shipped in v0.3.29 (#28): `storage.encryption.instanceSeed` (#31) combined with `postQuantum: true` under `keyCustody: "deployment"` is **refused at startup** with a clear error. **Why:** #31's derivation is `HKDF-SHA256(master, seed) → 32 bytes → X25519 identity` — valid because any 32 uniform bytes ARE an X25519 key, and our bech32 encoder renders them as `AGE-SECRET-KEY-1…`. A hybrid identity adds ML-KEM-768 key material, and filippo.io/age exposes only `GenerateHybridIdentity()` (random) and `ParseHybridIdentity()` (string) — no deterministic from-seed constructor. ML-KEM keygen IS deterministic from a seed per FIPS 203, and age's PQ bech32 payload is likely seed-based, so this is probably implementable — but it would mean depending on age's internal identity serialization without a contract. Refusing loudly beat guessing inside the key layer. **Why it's not a blocker today:** - Only affects `keyCustody: "deployment"` — keycloak-profile and openbao custody never use instanceSeed. - The affected scenario is an operator who reuses ONE master key across instances. Under PQ the same (stronger) separation is available today with no derivation: give each instance its own hybrid `deploymentIdentity` and park the old shared master in `deploymentIdentityLegacy`. A PQ migration is the natural moment to stop key-sharing anyway — this is the documented recommendation. **When to actually build it:** a deployment-custody customer with shared master + seed who wants PQ and cannot split keys. Options then: 1. Verify age's hybrid identity encoding is a stable seed encoding → extend `DeriveInstanceIdentity` (HKDF → seed bytes → bech32 `AGE-SECRET-KEY-PQ-` → `ParseHybridIdentity` as oracle, same pattern as the X25519 path). 2. Upstream feature request: a public from-seed constructor for `HybridIdentity` in filippo.io/age — the clean fix. 3. Fallback: document per-instance identities as the only PQ separation mechanism. No milestone — parked until demand exists. The startup error message points operators at the per-instance-identity workaround.
Author
Owner

Triage: autonomous, but crypto — PR review before deploy. The config field storage.encryption.instanceSeed already exists (config.go). Work is the derivation: new content encrypts to an identity from HKDF(deploymentIdentity, instanceSeed) instead of the shared master, so reusing one master across instances no longer makes their data mutually decryptable. Existing classical identities upgrade on first touch and are RETAINED for decrypt; the recovery identity must be derived too. Pairs with #110 (PQ proof). First step: derivation in internal/storage/encrypt + wire from config in main.go + a round-trip test.

**Triage: autonomous, but crypto — PR review before deploy.** The config field `storage.encryption.instanceSeed` already exists (config.go). Work is the derivation: new content encrypts to an identity from `HKDF(deploymentIdentity, instanceSeed)` instead of the shared master, so reusing one master across instances no longer makes their data mutually decryptable. Existing classical identities upgrade on first touch and are RETAINED for decrypt; the recovery identity must be derived too. Pairs with #110 (PQ proof). First step: derivation in `internal/storage/encrypt` + wire from config in `main.go` + a round-trip test.
Author
Owner

Correction to my earlier triage — this is NOT a safe autonomous build. On re-reading the full issue + the code:

  • The classical instanceSeed separation (#31) is already shipped, wired (main.go builds StaticKeys{... Derived: DeriveInstanceIdentity(master, seed)}), and thoroughly tested — derive_test.go's TestStaticKeysDomainSeparation proves instance-B-with-a-different-seed can't decrypt instance-A's data, and neither can the bare shared master (Manuel's exact blast-radius objection). Done.
  • #108 is the post-quantum extension, which main.go deliberately refuses at startup: filippo.io/age gives no deterministic-from-seed constructor for hybrid ML-KEM identities (only random GenerateHybridIdentity + string ParseHybridIdentity). Deriving one means reverse-engineering age's PQ bech32 payload and betting it's a stable seed encoding — "guessing inside the key layer", and if age's format assumptions are wrong the data silently orphans. That's a data-loss landmine, so I'm not building it autonomously.

Recommended paths (unchanged from the issue body):

  • (2, the clean fix) file an upstream feature request to filippo.io/age for a public HybridIdentity-from-seed constructor. I can draft it.
  • (3, safe + useful now) document per-instance deploymentIdentity (+ old master in deploymentIdentityLegacy) as the PQ separation mechanism — folds into the #109 handbook.

Leaving parked (no milestone), which is correct. The startup error already points operators at the per-instance-identity workaround.

**Correction to my earlier triage — this is NOT a safe autonomous build.** On re-reading the full issue + the code: - The **classical** instanceSeed separation (#31) is already shipped, wired (`main.go` builds `StaticKeys{... Derived: DeriveInstanceIdentity(master, seed)}`), and thoroughly tested — `derive_test.go`'s `TestStaticKeysDomainSeparation` proves instance-B-with-a-different-seed can't decrypt instance-A's data, and neither can the bare shared master (Manuel's exact blast-radius objection). Done. - #108 is the **post-quantum** extension, which `main.go` deliberately refuses at startup: `filippo.io/age` gives no deterministic-from-seed constructor for hybrid ML-KEM identities (only random `GenerateHybridIdentity` + string `ParseHybridIdentity`). Deriving one means reverse-engineering age's PQ bech32 payload and betting it's a stable seed encoding — "guessing inside the key layer", and if age's format assumptions are wrong the data **silently orphans**. That's a data-loss landmine, so I'm not building it autonomously. **Recommended paths (unchanged from the issue body):** - (2, the clean fix) file an upstream feature request to `filippo.io/age` for a public `HybridIdentity`-from-seed constructor. I can draft it. - (3, safe + useful now) document per-instance `deploymentIdentity` (+ old master in `deploymentIdentityLegacy`) as the PQ separation mechanism — folds into the #109 handbook. Leaving parked (no milestone), which is correct. The startup error already points operators at the per-instance-identity workaround.
Author
Owner

Reference note (plain-terms explainer, for revisiting later)

Is Cairn's data post-quantum encrypted?

With storage.encryption.postQuantum: true, yes — new content's file key is sealed with a hybrid of ML-KEM-768 + X25519:

  • ML-KEM-768 = the NIST-standardized post-quantum algorithm (FIPS 203) — the part a quantum computer can't break.
  • X25519 = the classical algorithm.
  • Hybrid = an attacker must break both; you're never worse off than classical, and you're protected against "harvest now, decrypt later" (someone copying the bucket today to crack post-quantum).
  • File contents use ChaCha20-Poly1305 (authenticated / "verified": tampering makes decryption fail, not return garbage).

Two honest caveats:

  1. At-rest, not end-to-end. Protects data on the untrusted storage backend, not against a compromised server (cairnd holds keys during a request). Documented boundary.
  2. Standardized + hybrid, not formally-verified code. Algorithm is NIST-standard; library is Filippo Valsorda's age.

The cairn-enc dogfood runs postQuantum: true (recoveryIdentity is AGE-SECRET-KEY-PQ-…), so its new content is PQ-hybrid encrypted. #108 does not affect it — the dogfood is keyCustody: keycloak-profile; instanceSeed only applies to keyCustody: deployment.

#31 vs #108, by analogy

Both concern instanceSeed — which stops several instances that share one master key from reading each other's data. Picture the master key as a key-cutting mold:

  • Reuse the master everywhere → every lock opens with the identical key. Steal one, open all (big blast radius).
  • instanceSeed = stamp a unique building number into the mold before cutting each key. Same mold, different keys; A's key won't turn B's lock, and the bare un-stamped master opens nothing stamped. Blast radius gone.

The difference is what the key is made of:

  • #31 — classical (X25519): a plain brass blank. Any random cut on a brass blank IS a valid key, so HKDF(master, seed) → 32 bytes → a working unique key. Built, shipped, tested (derive_test.go proves B-with-a-different-seed and the bare master both fail to open A).
  • #108 — post-quantum (hybrid ML-KEM): a high-security chip-key. Not just random bytes — it has internal structure. You can't grind a blank into a valid chip-key; you need the key-maker's machine that turns a seed into a proper chip-key. age only offers "cut a random chip-key" (GenerateHybridIdentity) or "read an existing one" (ParseHybridIdentity) — no from-seed constructor. Faking it = guessing age's internal chip format, and a wrong guess makes a malformed key that jams the lock forever (data permanently unreadable). Hence the loud startup refusal.

Reassurance: the PQ way to separate instances needs no derivation — just give each instance its own independent chip-key (a separate deploymentIdentity, old master parked in deploymentIdentityLegacy). Cleaner anyway. #108 only matters for the narrow operator who insists on one shared PQ master and seed separation → parked until real demand.

## Reference note (plain-terms explainer, for revisiting later) ### Is Cairn's data post-quantum encrypted? With `storage.encryption.postQuantum: true`, **yes** — new content's file key is sealed with a **hybrid of ML-KEM-768 + X25519**: - **ML-KEM-768** = the NIST-standardized post-quantum algorithm (FIPS 203) — the part a quantum computer can't break. - **X25519** = the classical algorithm. - **Hybrid** = an attacker must break *both*; you're never worse off than classical, and you're protected against "harvest now, decrypt later" (someone copying the bucket today to crack post-quantum). - File **contents** use ChaCha20-Poly1305 (authenticated / "verified": tampering makes decryption fail, not return garbage). Two honest caveats: 1. **At-rest, not end-to-end.** Protects data on the untrusted storage backend, not against a compromised *server* (cairnd holds keys during a request). Documented boundary. 2. **Standardized + hybrid, not formally-verified code.** Algorithm is NIST-standard; library is Filippo Valsorda's age. The `cairn-enc` dogfood runs `postQuantum: true` (recoveryIdentity is `AGE-SECRET-KEY-PQ-…`), so its new content is PQ-hybrid encrypted. **#108 does not affect it** — the dogfood is `keyCustody: keycloak-profile`; instanceSeed only applies to `keyCustody: deployment`. ### #31 vs #108, by analogy Both concern **`instanceSeed`** — which stops several instances that share one master key from reading each other's data. Picture the master key as a **key-cutting mold**: - Reuse the master everywhere → every lock opens with the identical key. Steal one, open all (big blast radius). - `instanceSeed` = **stamp a unique building number into the mold before cutting each key**. Same mold, different keys; A's key won't turn B's lock, and the bare un-stamped master opens nothing stamped. Blast radius gone. The difference is **what the key is made of**: - **#31 — classical (X25519): a plain brass blank.** Any random cut on a brass blank IS a valid key, so HKDF(master, seed) → 32 bytes → a working unique key. **Built, shipped, tested** (`derive_test.go` proves B-with-a-different-seed and the bare master both fail to open A). - **#108 — post-quantum (hybrid ML-KEM): a high-security chip-key.** Not just random bytes — it has internal structure. You can't grind a blank into a valid chip-key; you need the **key-maker's machine** that turns a seed into a proper chip-key. age only offers "cut a **random** chip-key" (`GenerateHybridIdentity`) or "read an **existing** one" (`ParseHybridIdentity`) — **no from-seed constructor.** Faking it = guessing age's internal chip format, and a wrong guess makes a malformed key that **jams the lock forever** (data permanently unreadable). Hence the loud startup refusal. **Reassurance:** the PQ way to separate instances needs no derivation — just **give each instance its own independent chip-key** (a separate `deploymentIdentity`, old master parked in `deploymentIdentityLegacy`). Cleaner anyway. #108 only matters for the narrow operator who insists on one shared PQ master *and* seed separation → parked until real demand.
Author
Owner
@hyrsh
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#108
No description provided.