Skip to content

Delegate persistence: avoid duplicating cosigner secret outside SecretStore #31

@aruokhai

Description

@aruokhai

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:

  1. Two copies of the secret to rotate / wipe on user delete
  2. A backend that thought it was only storing non-secret state (the sled delegate_sessions tree) now holds the cosigner's MuSig2 key share
  3. 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

  • PersistedDelegate (the serializable struct in crates/ark) does NOT contain any secret material — only identity x-only pubkeys, signed PSBTs, sighash meta, network/exit_delay/etc.
  • DelegateSettleSession::from_persisted(...) takes delegate_cosigner_secret_hex: &str as a separate parameter
  • registry::get_or_spawn rehydration looks up the secret from SecretStore before reconstructing the session
  • Test: persist a delegate, dump the sled delegate_sessions tree contents, assert the raw bytes do not contain the secret hex

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions