Skip to content

Mark a FROST nonce used when it is claimed, not never - #933

Merged
kwsantiago merged 2 commits into
mainfrom
nonce-reuse-guard-inert
Aug 1, 2026
Merged

Mark a FROST nonce used when it is claimed, not never#933
kwsantiago merged 2 commits into
mainfrom
nonce-reuse-guard-inert

Conversation

@kwsantiago

@kwsantiago kwsantiago commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Summary

  • NonceStore::check_and_add_nonce could not reject a reused FROST nonce commitment. Its reject branch tested e.used, and the only writer of that field was mark_nonce_used, which had no callers anywhere in the workspace. No entry was ever used, so a commitment seen a second time fell through to the Ok(true) branch and was signed.
  • The caller in keep-cli/src/commands/frost_network/hardware.rs:82 reads as protected and is not: its "nonce has already been used - aborting to prevent key compromise" error was unreachable.
  • Fixed by marking the entry used at the moment it is claimed for signing, which is where the missing transition belonged. mark_nonce_used is deleted; it was the phantom second phase that made the guard look complete.

Why it matters

Signing twice with one FROST nonce under two different challenges gives s = (z1 - z2) / (c1 - c2) — the signer's key share, recoverable by anyone who sees both signature shares. This store is the host-side defence for the hardware-signer path, where the commitment comes back from an external device: a device that replays a persisted nonce checkpoint after a reboot, or whose own RNG has degraded, is exactly the case it exists to catch.

What was subtle about it

The two-phase design is real and load-bearing, so the fix is not "reject on presence". The kind-21106 pre-commitment flow (hardware.rs:416) registers commitments ahead of time via add_nonce, deliberately unused, and nonce_stats reports them as available. Rejecting any commitment already in the store would have broken that feature at first legitimate use. What was missing was only the transition from registered to claimed, which now happens inside check_and_add_nonce rather than in a function nobody called.

Test plan

  • Four tests added, covering: a repeated commitment is refused, a pre-committed nonce is usable exactly once, groups do not share commitments, and the rejection survives a reopen (the reboot-replay case)
  • Negative control: against the pre-fix check_and_add_nonce, three of the four fail. The one that passes (groups_do_not_share_commitments) never depended on the defect
  • cargo test -p keep-cli --bins nonce_store: 4 passed
  • cargo clippy -p keep-cli --all-targets clean, cargo fmt --check clean

Provenance

Found by an adversarial review pass over an unrelated RNG-hygiene change (#932), which swept the workspace for guards that cannot fire. Filed separately because it is a different bug class in a different subsystem, and mixing it into that PR would bury both.

Summary by CodeRabbit

  • Bug Fixes
    • Improved hardware signing nonce tracking by recording session information with each generated nonce.
    • Prevented nonce reuse and strengthened handling of legacy nonce records.
    • Improved persistence reliability to protect nonce data during interruptions or concurrent updates.
  • Tests
    • Added coverage for nonce reuse prevention, migration, persistence, stale handles, grouping, session tracking, and pre-commitments.

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Nonce storage now supports versioned migration, session-aware one-time claims, stale-handle protection, and atomic persistence. Hardware FROST signing passes the session ID when it registers a nonce.

Changes

FROST nonce lifecycle

Layer / File(s) Summary
Versioned migration and atomic persistence
keep-cli/src/signer/nonce_store.rs
Legacy stores are migrated by marking existing entries used. Store updates use temporary files, synchronization, restrictive permissions, and atomic rename.
Session-aware nonce claims
keep-cli/src/signer/nonce_store.rs, keep-cli/src/commands/frost_network/hardware.rs
Nonce claims record optional session IDs, reject reuse, consume pre-committed entries once, and retain group statistics. Hardware signing supplies the session ID. Tests cover migration, persistence, grouping, stale handles, and reuse rejection.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • privkeyio/keep#15: Introduces the hardware FROST signing flow whose nonce tracking now records session IDs.
  • privkeyio/keep#38: Covers FROST nonce lifecycle handling in keep-frost-net/src/session.rs.
  • privkeyio/keep#63: Extends NonceStore nonce-claim and session-tracking behavior.

Poem

A rabbit sees each nonce claimed,
Its session tag is safely framed.
Old stores wake, then mark used,
Repeated claims are now refused.
Atomic files hop into place—
One neat burrow, sealed with grace.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: marking a FROST nonce as used when it is claimed.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch nonce-reuse-guard-inert

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@kwsantiago
kwsantiago force-pushed the nonce-reuse-guard-inert branch from 1cf0233 to 8782b5b Compare August 1, 2026 01:52

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
keep-cli/src/signer/nonce_store.rs (1)

87-96: 🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift

Keep the nonce store lock tied to a stable pathname.

with_lock acquires lock_exclusive() on the file opened for self.path, but write_locked() replaces self.path with a new inode. A second opener can race to open the original inode before the rename and then acquire its own lock after unlock() releases the old handle. Use a separate lock file whose pathname is never replaced by rename, or keep the store file in place for the duration of with_lock.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@keep-cli/src/signer/nonce_store.rs` around lines 87 - 96, Update with_lock
and write_locked so the lock remains associated with a stable inode across
nonce-store replacement: use a separate, never-renamed lock file for
lock_exclusive, or avoid replacing the locked store inode while with_lock runs.
Ensure all openers acquire the same stable pathname lock before accessing or
renaming self.path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@keep-cli/src/signer/nonce_store.rs`:
- Around line 140-159: Update write_locked after std::fs::rename to open
nonce_store.json’s parent directory and call sync_all() on it, propagating
contextual errors consistently with the existing file fsync operations. Preserve
the current temp-file write, file sync, and atomic rename sequence.

---

Outside diff comments:
In `@keep-cli/src/signer/nonce_store.rs`:
- Around line 87-96: Update with_lock and write_locked so the lock remains
associated with a stable inode across nonce-store replacement: use a separate,
never-renamed lock file for lock_exclusive, or avoid replacing the locked store
inode while with_lock runs. Ensure all openers acquire the same stable pathname
lock before accessing or renaming self.path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: cf96e155-494e-4e32-9fdb-3e19205e67f5

📥 Commits

Reviewing files that changed from the base of the PR and between 95e7626 and 8782b5b.

📒 Files selected for processing (2)
  • keep-cli/src/commands/frost_network/hardware.rs
  • keep-cli/src/signer/nonce_store.rs

Comment on lines +140 to 159
fn write_locked(path: &Path, content: &str) -> Result<()> {
let tmp_path = path.with_extension("tmp");
let mut tmp = {
let mut opts = OpenOptions::new();
opts.create(true).write(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
opts.open(&tmp_path)
.context("Failed to open nonce store temp file")?
};
tmp.write_all(content.as_bytes())
.context("Failed to write nonce store")?;
writer.flush().context("Failed to flush nonce store")?;
tmp.sync_all().context("Failed to fsync nonce store")?;
drop(tmp);
std::fs::rename(&tmp_path, path).context("Failed to replace nonce store")?;
Ok(())
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Files matching nonce_store:\n'
fd -a 'nonce_store\.rs$' . || true

file=$(fd 'nonce_store\.rs$' . | head -n 1 || true)
if [ -n "${file:-}" ]; then
  printf '\nSelected file: %s\n' "$file"
  wc -l "$file"
  printf '\nLines 1-220:\n'
  sed -n '1,220p' "$file" | cat -n
fi

printf '\nSearch for NonceStore/write_locked usages:\n'
rg -n "NonceStore|write_locked|claim|nonce|rename|sync_all" -S . || true

Repository: privkeyio/keep

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Relevant keep-web directory fsyncs:\n'
sed -n '444,795p' keep-web/src/state.rs | cat -n

printf '\nNonce Store check_and_add_nonce and remaining lines 220-330:\n'
sed -n '220,330p' keep-cli/src/signer/nonce_store.rs | cat -n

Repository: privkeyio/keep

Length of output: 22564


Fsync the nonce store directory after the rename.

write_locked fsyncs the temp file, then renames it into place, but a power loss can persist the rename without persisting the containing directory entry. That can make self.path resolve to the old nonce store after reboot, so the claim can be replayed. Open nonce_store.json.parent() and call sync_all() after std::fs::rename, matching the other atomic writes in the workspace.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@keep-cli/src/signer/nonce_store.rs` around lines 140 - 159, Update
write_locked after std::fs::rename to open nonce_store.json’s parent directory
and call sync_all() on it, propagating contextual errors consistently with the
existing file fsync operations. Preserve the current temp-file write, file sync,
and atomic rename sequence.

@kwsantiago
kwsantiago merged commit e98cea6 into main Aug 1, 2026
11 checks passed
@kwsantiago
kwsantiago deleted the nonce-reuse-guard-inert branch August 1, 2026 02:03
@kwsantiago kwsantiago mentioned this pull request Aug 1, 2026
5 tasks
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.

1 participant