Context
Phase 2 of the persistence-gap work (see follow-up to #30) requires persisting DelegateRecord so that auto-settle can fire after a cosigner-runtime restart without waiting for the client to re-delegate. Today state.delegate_session is in-memory only — restart drops every stored intent, and users must re-delegate on next refresh via the has_active_delegate=false && vtxos.isNotEmpty branch in mpc_service.dart.
DelegateRecord wraps ark::client::batch::DelegateSettleSession (batch.rs), which holds — among other fields — a delegate_cosigner_kp: Keypair. That keypair's 32-byte secret is the same value as the server's per-user DKG secret, stored at dkg-secret.<canonical_user_id> via the SecretStore trait (persistence/traits.rs). It's the MuSig2 cosigner secret for tree signing.
The naive serialization for delegate persistence puts the secret directly into the persisted record:
#[derive(Serialize, Deserialize)]
pub struct PersistedDelegate {
// ...identity fields, signed PSBTs, sighash meta...
delegate_cosigner_secret_hex: String, // ⚠️ 32-byte secret
}
This record would live in the regular KvStore (sled tree delegate_sessions), not the SecretStore. That's a downgrade in security posture — SecretStore exists as a separate trait specifically so production backends can route secrets through an encrypted-at-rest store (KMS, HSM, OS keychain, etc.) distinct from regular KV state.
Risk
If the same secret lives in both places, we have:
- Two copies of the secret to rotate / wipe on user delete
- A backend that thought it was only storing non-secret state (the sled
delegate_sessions tree) now holds the cosigner's MuSig2 key share
- A regression vector: a future contributor adding an "export sled for debugging" tool could accidentally exfiltrate the secret
Two ways to handle
Option A (recommended) — Don't store the secret in the record
The persisted record carries only the user's canonical id (it's the sled key already), plus everything else needed to reconstruct the session. At rehydration time in registry::get_or_spawn, look up dkg-secret.<canonical> from SecretStore and pass it to DelegateSettleSession::from_persisted(...).
Pros: secret stays in one place, owned by the trait designed to hold it.
Cons: rehydration needs SecretStore access (already wired through SharedServices, so trivially available).
Option B — Store the full record including the secret
PersistedDelegate includes delegate_cosigner_secret_hex directly. KvStore now holds key material.
Pros: rehydration is a single deserialize call, no secondary lookup.
Cons: duplicates the secret into a store not designed for it.
Recommendation
Option A. The extra lookup is one synchronous secret_store.get_secret(\"dkg-secret.<canonical>\") call already paralleled elsewhere (e.g. lookup_policy_by_recovery_id in dkg.rs). No new code patterns required.
Acceptance
Related
Context
Phase 2 of the persistence-gap work (see follow-up to #30) requires persisting
DelegateRecordso that auto-settle can fire after a cosigner-runtime restart without waiting for the client to re-delegate. Todaystate.delegate_sessionis in-memory only — restart drops every stored intent, and users must re-delegate on next refresh via thehas_active_delegate=false && vtxos.isNotEmptybranch in mpc_service.dart.DelegateRecordwrapsark::client::batch::DelegateSettleSession(batch.rs), which holds — among other fields — adelegate_cosigner_kp: Keypair. That keypair's 32-byte secret is the same value as the server's per-user DKG secret, stored atdkg-secret.<canonical_user_id>via theSecretStoretrait (persistence/traits.rs). It's the MuSig2 cosigner secret for tree signing.The naive serialization for delegate persistence puts the secret directly into the persisted record:
This record would live in the regular
KvStore(sled treedelegate_sessions), not theSecretStore. That's a downgrade in security posture —SecretStoreexists as a separate trait specifically so production backends can route secrets through an encrypted-at-rest store (KMS, HSM, OS keychain, etc.) distinct from regular KV state.Risk
If the same secret lives in both places, we have:
delegate_sessionstree) now holds the cosigner's MuSig2 key shareTwo ways to handle
Option A (recommended) — Don't store the secret in the record
The persisted record carries only the user's canonical id (it's the sled key already), plus everything else needed to reconstruct the session. At rehydration time in
registry::get_or_spawn, look updkg-secret.<canonical>fromSecretStoreand pass it toDelegateSettleSession::from_persisted(...).Pros: secret stays in one place, owned by the trait designed to hold it.
Cons: rehydration needs
SecretStoreaccess (already wired throughSharedServices, so trivially available).Option B — Store the full record including the secret
PersistedDelegateincludesdelegate_cosigner_secret_hexdirectly.KvStorenow holds key material.Pros: rehydration is a single deserialize call, no secondary lookup.
Cons: duplicates the secret into a store not designed for it.
Recommendation
Option A. The extra lookup is one synchronous
secret_store.get_secret(\"dkg-secret.<canonical>\")call already paralleled elsewhere (e.g.lookup_policy_by_recovery_idin dkg.rs). No new code patterns required.Acceptance
PersistedDelegate(the serializable struct incrates/ark) does NOT contain any secret material — only identity x-only pubkeys, signed PSBTs, sighash meta, network/exit_delay/etc.DelegateSettleSession::from_persisted(...)takesdelegate_cosigner_secret_hex: &stras a separate parameterregistry::get_or_spawnrehydration looks up the secret fromSecretStorebefore reconstructing the sessiondelegate_sessionstree contents, assert the raw bytes do not contain the secret hexRelated
ark_tx_history/device_tokensrehydration work — same actor-spawn rehydration site