Domain separation (instanceSeed) for post-quantum deployment identities #108
Labels
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: Cordy/Cairn#108
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Tracking issue for the gap shipped in v0.3.29 (#28):
storage.encryption.instanceSeed(#31) combined withpostQuantum: trueunderkeyCustody: "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 asAGE-SECRET-KEY-1…. A hybrid identity adds ML-KEM-768 key material, and filippo.io/age exposes onlyGenerateHybridIdentity()(random) andParseHybridIdentity()(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:
keyCustody: "deployment"— keycloak-profile and openbao custody never use instanceSeed.deploymentIdentityand park the old shared master indeploymentIdentityLegacy. 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:
DeriveInstanceIdentity(HKDF → seed bytes → bech32AGE-SECRET-KEY-PQ-→ParseHybridIdentityas oracle, same pattern as the X25519 path).HybridIdentityin filippo.io/age — the clean fix.No milestone — parked until demand exists. The startup error message points operators at the per-instance-identity workaround.
Triage: autonomous, but crypto — PR review before deploy. The config field
storage.encryption.instanceSeedalready exists (config.go). Work is the derivation: new content encrypts to an identity fromHKDF(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 ininternal/storage/encrypt+ wire from config inmain.go+ a round-trip test.Correction to my earlier triage — this is NOT a safe autonomous build. On re-reading the full issue + the code:
main.gobuildsStaticKeys{... Derived: DeriveInstanceIdentity(master, seed)}), and thoroughly tested —derive_test.go'sTestStaticKeysDomainSeparationproves 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.main.godeliberately refuses at startup:filippo.io/agegives no deterministic-from-seed constructor for hybrid ML-KEM identities (only randomGenerateHybridIdentity+ stringParseHybridIdentity). 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):
filippo.io/agefor a publicHybridIdentity-from-seed constructor. I can draft it.deploymentIdentity(+ old master indeploymentIdentityLegacy) 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.
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:Two honest caveats:
The
cairn-encdogfood runspostQuantum: true(recoveryIdentity isAGE-SECRET-KEY-PQ-…), so its new content is PQ-hybrid encrypted. #108 does not affect it — the dogfood iskeyCustody: keycloak-profile; instanceSeed only applies tokeyCustody: 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: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:
derive_test.goproves B-with-a-different-seed and the bare master both fail to open A).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 indeploymentIdentityLegacy). Cleaner anyway. #108 only matters for the narrow operator who insists on one shared PQ master and seed separation → parked until real demand.@hyrsh