Commit ca6ee3b
authored
feat(tbtc/signer): member-key interactive signing state for multi-seat operators (#4098)
## What
Keys a session's interactive signing state by `member_identifier`
(`interactive_signing: Option<InteractiveSigningState>` → `BTreeMap<u16,
InteractiveSigningState>`) so a **multi-seat operator can run
interactive signing for all its seats in one process**.
## Why
A multi-seat operator runs concurrent interactive signing per seat, all
sharing one member-independent `SessionID`, in one process (the engine
state is a process-global singleton). Today that breaks three ways: the
2nd seat's `InteractiveSessionOpen(same session, different member)` →
`SessionConflict`; `round2` frees the whole state (wiping siblings); and
the per-attempt consumed-nonce marker makes a sibling's `round2` falsely
trip `ConsumedNonceReplay`. Surfaced by the keep-core real-cgo e2e
(#4097).
## Design (reviewed: Gemini sound, Codex approve-with-refinements)
Both reviewers rejected a session-wide live-attempt model — it would let
one seat erase another's nonces, the exact bug being removed.
- **Per-member live attempt**: `open(M, strictly-newer attempt)`
replaces only M's entry (zeroizing M's old nonces); a stale/equal open
for M is rejected, but a sibling on a different attempt is untouched.
Seats are independent — exactly as separate processes would be.
- **round2** removes only the member's entry (siblings stay live); the
durable marker carries replay protection.
- **Consumed markers** keyed per-`(attempt_id, member_id)` (composite);
a **legacy bare `attempt_id` marker is honored fail-closed** on read.
`aggregated_interactive_attempt_markers` stay per-attempt (one signature
per attempt over public data).
- **abort** is session-level over the map (removes all matching
entries); **TTL sweep** is per-entry by `opened_at_unix` (siblings
survive); **capacity** counts live member entries (a new member is a
slot; a same-member replacement isn't).
- `interactive_state_for_attempt_mut` is a member-keyed lookup. Live
state still never persists (empty map on reload). Zeroization preserved
via `InteractiveRound1State::Drop`.
## Tests
New `interactive_multi_seat_two_members_one_process_aggregate_bip340`:
two seats open the same attempt in one process, Round1 independently,
member 1's Round2 frees only its entry and writes only its marker
(member 2 stays live and unblocked), and the two interactive shares
**aggregate to a valid BIP-340 signature**. Existing single-member tests
updated for the map. **All 292 lib tests pass; `cargo fmt` clean.**
Follow-ups (additional edge tests Codex itemized — per-member attempt
advance, abort-by-attempt over multiple members, capacity
new-vs-replacement) can land in review.
🤖 Generated with [Claude Code](https://claude.com/claude-code)4 files changed
Lines changed: 900 additions & 114 deletions
0 commit comments