Skip to content

feat(validator): refuse to sign a slot a key already signed - #1204

Open
adust09 wants to merge 1 commit into
leanEthereum:mainfrom
adust09:feat/durable-signing-protection
Open

feat(validator): refuse to sign a slot a key already signed#1204
adust09 wants to merge 1 commit into
leanEthereum:mainfrom
adust09:feat/durable-signing-protection

Conversation

@adust09

@adust09 adust09 commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

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_slot at the signing boundary.

  • New signing_records table keyed by (validator_index, key_role), never pruned.
  • The claim commits before the signature: an interruption forfeits the duty, not the key.
  • Roles tracked separately, so proposing and attesting in one slot stays legal.
  • Without a database, records are process-local and run() warns.
  • A refusal skips one duty; _attested_slots stays 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%.

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No durable guard against signing two different messages in one slot (XMSS OTS key reuse)

1 participant