Description and context
The kernel seals DKG secret material (dist_key_share.sealed, private_coeffs.sealed) with no binding to its storage slot (round / code commitment), so a sealed blob can be relocated or rolled back into another round's slot and still unseals and is accepted.
- SGX path (
enclave/seal.go): SealWithUniqueKey(data, nil) / Unseal(sealed, nil) — nil AAD.
store/dkg_store.go: LoadDistKeyShare derives the round purely from the file path ({stateDir}/{round}/{codeCommitment}/dist_key_share.sealed); MarshalDistKeyShare embeds no round. The only binding is the enclave sealing key (binds to TEE identity, not to a round).
feat/launcher refactor does not fix this: the new Sealer.Seal(plaintext) interface (enclave/sealer.go) has no AAD/context parameter, and sealdb.Set(key, value) seals value without binding the DB key. The TDX header AAD binds config fields (magic/version/provider_count), not the round slot. SGX still uses nil AAD.
Impact — stale-share replay. A host with write access to the state dir can copy/rollback a sealed share from round N's directory into round N+1's slot; after a cold-cache restart it unseals and is accepted as round N+1's share → the node deals/decrypts under the wrong share → divergence. Reproduced on an SGX devnet during PR #89 review (relocate round N's sealed share into round N+1's slot → restart → N+1 divergence; a restart-only control with no file change stayed clean). Same class as the med3 "store key binding" finding from the PR #75 audit (previously discussed for the launcher/TDX path), now confirmed on the SGX kernel path. Both dist_key_share.sealed and private_coeffs.sealed are affected.
Related: PR #89 (#89), PR #75 audit med3.
Suggested solution
Bind the storage slot into the seal so an unseal at the wrong slot fails:
- Thread an AAD/context through the seal API:
Seal(plaintext, aad) / Unseal(sealed, aad), with aad = round || code_commitment (and the artifact kind).
- On
feat/launcher, extend the Sealer interface accordingly and have sealdb pass the DB key (which already encodes round/code_commitment) as AAD.
- On the current SGX kernel path, pass the round + code commitment as AAD to
SealWithUniqueKey / Unseal.
Because this spans both the SGX kernel path and the feat/launcher seal architecture, coordinate the change across both (single seal-AAD contract used by SGX and TDX).
Secondary hardening (same file): SealToFile uses os.WriteFile with no fsync / temp-file+rename, so a crash right after finalize can lose or truncate the sealed share and drop the next reshare into the divergent recompute fallback — worth an atomic write + fsync.
Definition of done
- Sealed
dist_key_share and private_coeffs blobs bind round + code commitment into AAD; unseal at a mismatched slot fails.
- A relocated/rolled-back sealed blob is rejected on unseal (regression test reproducing the replay must now fail to unseal).
- Consistent seal-AAD contract across the SGX kernel path and the
feat/launcher (Sealer/sealdb, SGX + TDX) paths.
- Atomic + fsync write for sealed files.
Description and context
The kernel seals DKG secret material (
dist_key_share.sealed,private_coeffs.sealed) with no binding to its storage slot (round / code commitment), so a sealed blob can be relocated or rolled back into another round's slot and still unseals and is accepted.enclave/seal.go):SealWithUniqueKey(data, nil)/Unseal(sealed, nil)— nil AAD.store/dkg_store.go:LoadDistKeySharederives the round purely from the file path ({stateDir}/{round}/{codeCommitment}/dist_key_share.sealed);MarshalDistKeyShareembeds no round. The only binding is the enclave sealing key (binds to TEE identity, not to a round).feat/launcherrefactor does not fix this: the newSealer.Seal(plaintext)interface (enclave/sealer.go) has no AAD/context parameter, andsealdb.Set(key, value)sealsvaluewithout binding the DBkey. The TDX header AAD binds config fields (magic/version/provider_count), not the round slot. SGX still uses nil AAD.Impact — stale-share replay. A host with write access to the state dir can copy/rollback a sealed share from round N's directory into round N+1's slot; after a cold-cache restart it unseals and is accepted as round N+1's share → the node deals/decrypts under the wrong share → divergence. Reproduced on an SGX devnet during PR #89 review (relocate round N's sealed share into round N+1's slot → restart → N+1 divergence; a restart-only control with no file change stayed clean). Same class as the med3 "store key binding" finding from the PR #75 audit (previously discussed for the launcher/TDX path), now confirmed on the SGX kernel path. Both
dist_key_share.sealedandprivate_coeffs.sealedare affected.Related: PR #89 (#89), PR #75 audit med3.
Suggested solution
Bind the storage slot into the seal so an unseal at the wrong slot fails:
Seal(plaintext, aad)/Unseal(sealed, aad), withaad = round || code_commitment(and the artifact kind).feat/launcher, extend theSealerinterface accordingly and havesealdbpass the DB key (which already encodes round/code_commitment) as AAD.SealWithUniqueKey/Unseal.Because this spans both the SGX kernel path and the
feat/launcherseal architecture, coordinate the change across both (single seal-AAD contract used by SGX and TDX).Secondary hardening (same file):
SealToFileusesos.WriteFilewith no fsync / temp-file+rename, so a crash right after finalize can lose or truncate the sealed share and drop the next reshare into the divergent recompute fallback — worth an atomic write + fsync.Definition of done
dist_key_shareandprivate_coeffsblobs bind round + code commitment into AAD; unseal at a mismatched slot fails.feat/launcher(Sealer/sealdb, SGX + TDX) paths.