Conversation
The signature scheme is a stateful one-time signature indexed by slot, and `sign` states that a key must never sign two different messages for the same slot without enforcing it. The only thing upholding that was `_attested_slots`, an in-memory set pruned after four slots, with no per-slot guard at all on the proposal path. A crash-restart inside a slot, a backward clock step, or a restore from backup therefore re-ran the duty against a possibly different head and signed a second message under the same one-time key, which opens two positions in the same hash chains and lets a third signature be forged. Record the highest slot each validator key has signed and check it at the signing boundary. The record lives in the node database, so it survives a restart; without a database it stays process-local and `run` warns that the protection is not durable. Attestation and proposal keys are tracked separately, so signing both in one slot stays legal. The claim commits before the signature so an interruption in between forfeits the duty, not the key. `_attested_slots` stays as the duty loop's dedup optimization; the new record is the safety guarantee. Signing records are never pruned: dropping a row re-opens the key it protects. Closes leanEthereum#1203
|
Hello, I am new around here, so please take this as one pair of eyes rather than a verdict. I came at it from the leanVM side while working on #1209, got curious, and since this has been sitting a while I thought a read might be more use than another issue comment. Short version: it looks right to me, the 17 tests pass on The ordering is the bit I would have got wrong. Claiming the slot before signing means an interruption costs a duty rather than a key, and that is the only way round that is safe. The one-line comment saying so is doing a lot of work. I wanted to see the restart behaviour for myself rather than take the test's word for it, so I reserved slot 5, threw the object away, and built a fresh I also tried breaking it in two ways to see whether your tests bite, and they do:
One thing that might be worth adding to the case for this, from the leanVM side. Since Two small things I noticed, both take-them-or-leave-them. The storage call can move a record backwards. INSERT INTO signing_records (validator_index, key_role, last_signed_slot)
VALUES (?, ?, ?)
ON CONFLICT (validator_index, key_role) DO UPDATE SET
last_signed_slot = MAX(last_signed_slot, excluded.last_signed_slot)I ran that against your table definition to be sure it behaves: 10, then 3, then 12 leaves 10, 10, 12. The durability is real, but it is inherited. I was curious what the connection actually runs at, and it is And a question, which may well be a non-issue. Records are keyed by validator index, while the thing that must not be reused is the key. The manifest lists None of this stops it merging as far as I am concerned. Happy to send the SQL change as a small PR against your branch if that is useful, or to leave it entirely with you. |
Closes #1203.
sign()requires that a key never sign two different messages for one slot, but only an in-memory set upheld it — and the proposal path had no per-slot guard at all. A crash-restart, a backward clock step, or a restore re-ran the duty against a possibly different head and signed twice under the same one-time key.Records the highest slot each key has signed and refuses
slot <= last_signed_slotat the signing boundary.signing_recordstable keyed by(validator_index, key_role), never pruned.run()warns._attested_slotsstays as the duty loop's dedup.Concurrent nodes sharing a key keep separate records, so exclusive key-store access remains an operational requirement.
17 new tests, including a restart scenario: sign slot 1, reopen the database, replacement service refused. 2961 passed, coverage 91.79%.