Skip to content

fix(tbtc/signer): sign-store followup - local compaction, hash-chain hardening, review fixes - #4271

Open
piotr-roslaniec wants to merge 20 commits into
codex/signer-store-identity-abifrom
codex/signer-store-identity-abi-followup
Open

piotr-roslaniec wants to merge 20 commits into
codex/signer-store-identity-abifrom
codex/signer-store-identity-abi-followup

Conversation

@piotr-roslaniec

Copy link
Copy Markdown
Collaborator

Summary

Follow-up to #4198 (codex/signer-store-identity-abi). Implements the four items originally deferred from that review's findings, plus a second multi-agent-review pass over the resulting branch, with all confirmed findings fixed.

P0 (was a permanent write-lockout): unanchored signers now perform local witness-journal compaction when the record ceiling is reached, instead of failing closed forever with no recovery path.

P0 #2 (per-record hash chain): already landed earlier in this branch; this PR adds the missing tamper/domain/cross-segment/frozen-vector test coverage for it.

P1 trust journal / P1 witness_history: trust journal gets a records-based ceiling alongside its byte cap; witness_history growth was already mitigated by the pre-existing rotation path (documented, not a separate code change).

Plus: FFI-boundary error redaction moved to a single chokepoint (scoped to Internal only -- Validation is user-facing text, not path-bearing); backup/quarantine directory access hardened against symlink swaps; segment-header/retired-journal-helper simplifications; narrowed #[allow(dead_code)] scoping; corrected FFI-surface documentation; new compaction runbook.

Full finding-by-finding detail is in the commit message.

Verification

  • cargo fmt --manifest-path pkg/tbtc/signer/Cargo.toml -- --check
  • cargo clippy --locked --manifest-path pkg/tbtc/signer/Cargo.toml --all-targets -- -D warnings
  • cargo test --locked --manifest-path pkg/tbtc/signer/Cargo.toml (386 passed, 7 ignored)
  • cargo test --locked --manifest-path pkg/tbtc/signer/Cargo.toml formal_verification_
  • cargo deny check advisories

All pass locally; CI runs the same gates plus TLA model checks.

D1 (Split): Drop ABI minor bump (4 -> 0) and remove the 11 new FFI symbols added by
this branch. The internal engine work (anchor.rs, anchor_trust.rs, inventory.rs, store.rs
additions) is preserved; the new FFI exports and ABI major version are deferred to
a follow-up PR that re-exposes them with proper Go-side coordination. The PR's
'ABI 4.3 coordinated merge with #4199' framing is now moot.

D2 (Rename): durable -> descriptor_bound on DurableStoreIdentityResult. Matches the
existing C header doc which says 'fails closed if a live path, lock, store-ID, or
state entry no longer matches its held no-follow descriptor.'

P0 #3 (v1->v2 re-anchor): Error message at retired_v1_state_witness_journal_error no
longer references a 'documented v1->v2 witness re-anchor' that did not exist. The
recovery procedure is now inline in the error. New
retired_v1_state_witness_journal_recovery_steps() exposes the same procedure as a
Rust function for test coverage. New
pkg/tbtc/signer/docs/signer-store-v1-to-v2-migration-runbook.md is the operator
runbook for v1 stores that hit this code path.

P1 (validate_state_image 3x): New validate_state_image_with_digest helper accepts a
precomputed digest, avoiding 2 redundant state-file reads + 2 SHA-256 hashes per
persist. The first two revalidates in replace_state use the new helper; the third
(after rename) still uses the original.

P1/P2 (Hardening): cfg(not(unix)) guards on StateFileLock::acquire now return a
clear 'requires Unix; not supported on this platform' error at construction time
(distinguishable from 'store corrupted'). validate_entry_name now rejects '.' and
'..' (preventing path traversal if a future caller bypasses canonicalize).
unique_temp_name drops PID from the temp-name format (16-byte OsRng suffix is
sufficient to prevent name collisions; PID leak no longer useful for same-uid
attackers).

P1/P2 (Tests): 16 new tests in tests.rs + 2 new tests in store.rs, plus 2 existing
tests strengthened. Covers: orphan temp recovery, short file non-recovery,
cross-mount restore, mid-journal modification, cache invalidation, O_NOFOLLOW
symlink rejection for all entry paths, mode 0600 on all entries, dkg_share_epoch
rollback prevention, inventory retention (rotation/eviction/multi-wallet), FFI
symbol exports match ABI, 0x24*32 fingerprint vector, mid-record torn-repair,
production-realistic state_witness_max_records test, production default rotation
threshold test. 4 tests marked #[ignore] with explanation pending setup
clarification.

Test results: cargo test --lib -- --test-threads=1 -> 345 passed, 7 ignored, 0 failed.
Clippy: cargo clippy --all-targets --all-features -- -D warnings -> No issues found.
cargo fmt --check -> clean.

Ref: agent-docs/reviews/pr-4198/findings.json (multi-agent-review of this branch)
Ref: agent-docs/gap-inventory.md (D1, D2, C1 decisions)
Lists the four P0/P1 items deferred to this branch:
- Per-record hash chain for witness journal (P0 #2)
- Compaction implementation for witness journal (P0 #4)
- Trust journal rotation/compaction (P1)
- witness_history unbounded growth (P1)

Each item references its multi-agent-review location and a brief
implementation plan. Reference to the implementation commit on the main
PR branch (b976a46) and the gap-inventory decisions (D1, D2, C1).
Every fixed-width witness record now carries a 32-byte chain_hash field
that commits to all preceding records via a domain-separated SHA-256
link (chain_hash[i+1] = H(domain || chain_hash[i] || record[i+1])),
making any historical tamper with the journal detectable on reload
even when the state-commitment chain itself is unchanged.

Records grow from 105 to 137 bytes; the segment header layout is
unchanged so the frozen Go/Rust cross-language 472-byte header vector
is preserved and cross-segment chains are anchored by anchoring the
first record of each new segment to the previous segment's
header_commitment, rather than to zeros.

Bump the plain witness magic from TBTCWITNESSv2 to TBTCWITNESSv3 and
add an actionable v2 retirement error so any v2-format journal left on
disk by an older build is rejected with a one-time migration runbook
instead of failing closed with a generic partial-record error. Old
v2 journals must be renamed aside (NOT deleted) and the signer
restarted; the new build regenerates at generation 1, accepting the
v2->v3 break as a migration event.
Companion to the per-record hash chain implementation: the v2 record
layout (105-byte records, no per-record chaining) is now retired and
rejected at startup with an actionable migration error. This runbook
mirrors the v1-to-v2 runbook format and gives an operator the exact
shell commands to verify the magic, rename the retired journal aside,
restart under the new ABI, and confirm the new v3 fingerprint. It
also calls out the Go-side pin requirement so the rollout is
coordinated across the threshold set, and points at the unchanged
segment header layout so the cross-language byte vector stays valid.
…d advisory flock

- Remove DurableStoreIdentityResult: the FFI symbol was already dropped
  in D1 (ABI reversal) so the 12-field result struct is unreferenced.
- Add redacted_internal_error helper and apply it to the lock-file and
  state-directory error paths in acquire_with_mode, so production profiles
  do not leak absolute on-disk paths through the FFI error channel.
- Add advisory_exclusive_lock (best-effort flock) on the durable store ID
  and the state witness journal, complementing the existing state lock with
  a defense-in-depth guard against a second process that bypasses the lock
  file.
- Cover the redaction helper with a unit test that exercises both
  production (redacted) and development (verbose) branches.
…through F-34)

Addresses the second review pass tracked under agent-docs/reviews/ for
this branch (findings.json).

P0:
- F-01: implement local witness-journal compaction for unanchored signers
  (compact_witness_journal_local, recover_state_witness_compaction). Fixes
  a permanent write-lockout once the record ceiling is reached with no
  signed anchor configured. Retires the previous segment immediately after
  publish, matching the existing signed-rotation convention, so
  revalidate_store_entries's steady-state invariant holds.
- F-08 folded into F-01: the ceiling error message no longer points at a
  checkpoint ABI with zero FFI exports.

P1:
- F-02: add hash-chain tamper/domain/v2-rejection/cross-segment tests.
- F-17: correct signer-api-contract-decision-brief.md/README.md's FFI
  surface claims (round-level DKG/signing-package, not coarse session API)
  and reconcile the ABI major version number.

P2:
- F-03: move error redaction to the FFI boundary (ffi_redacted_message),
  scoped to Internal only -- Validation is user-facing business-rule text
  and must not be redacted.
- F-07: add a records-based ceiling to the trust journal alongside the
  existing byte cap.
- F-09: harden backup/quarantine directory access with openat/O_NOFOLLOW,
  with relative-path (AT_FDCWD) support for the bare-state-path case.
- F-14: pin a frozen cross-language vector for the record chain-hash domain.
- F-16/F-27/F-34: rewrite FOLLOWUP.md to reflect actual landed state.
- F-21: document the frozen 472-byte segment header wire format instead of
  refactoring it (cross-language contract, not safe to change).
- F-22: collapse v1/v2 retired-journal helpers into parameterized versions.
- F-23: rename rotation tests/comments that misused 'compaction'.
- F-24: narrow module-wide #![allow(dead_code)] to the specific orphaned
  FFI symbols in policy/state/dkg/codec/inventory/api.
- F-05/F-06/F-18/F-26: new compaction runbook, hash-chain security-model
  and filesystem-dependency notes, mark the secret-material plan superseded.

P3:
- F-11: gate the corrupted-state eprintln! behind the production profile.
- F-15: add the missing post-rename DKG-retirement crash-injection test.
- F-30: encode_state_witness_record returns a stack array, not a Vec.
- F-31: rewrite two evergreen-comment violations.
- F-32: add Status: fields to decision-brief.md and rust-rewrite-bootstrap.md.

Bugs found and fixed during verification (not in the original findings):
- Recursive stack overflow: compact_witness_journal_local's own PREPARE/
  COMMIT append re-entered ensure_witness_record_capacity, re-triggering
  compaction. Split append_witness_record into a checked wrapper plus an
  unchecked core the compaction path uses for its own terminal records.
- prepare_witness computed the next witness before ensuring capacity, so a
  mid-call compaction (which advances the tip) made the pre-computed
  witness stale. prepare_witness now takes the state-image digest and
  computes the witness after ensuring capacity/compaction.
- A test asserted the rotated segment's second record chained directly
  from header_commitment; the chain is sequential (record 1 chains from
  record 0's hash), not every record independently from the header.
- open_state_directory_nofollow required an absolute path, breaking the
  bare-filename ('.'-parent) state path test; added an AT_FDCWD-based
  traversal for relative paths.

cargo fmt --check, cargo clippy --all-targets -- -D warnings, cargo test,
cargo test formal_verification_, and cargo deny check advisories all pass.
@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 84159333-9ef3-45f0-a3e9-22685fdad352

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/signer-store-identity-abi-followup

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.

The v1.8.0 tla2tools.jar release asset was rebuilt upstream (updated
2026-08-11 per the GitHub release API), so the pinned SHA-256 no longer
matched the download and CI's TLA model checks job failed closed as
designed. Verified the new hash against the official release URL
(https://github.com/tlaplus/tlaplus/releases/download/v1.8.0/tla2tools.jar)
independently before re-pinning it.

Ran pkg/tbtc/signer/scripts/formal/run_tla_models.sh locally with the
updated pin; all models (RoastAttemptStateMachine, RoastRolloutPolicy,
StateKeyProviderPolicy incl. production config, TeeEnforcementModes) pass
with no errors found.
@piotr-roslaniec
piotr-roslaniec changed the base branch from main to dev September 1, 2026 09:37
Fixes 30 confirmed findings from the PR review plus one incidental
correctness gap discovered while repairing test coverage:

- P0: local-compaction crash-recovery reachability + unconditional
  rename bug in recover_state_witness_compaction, with new boundary
  fixture test
- P0: align docs with the code's actual immediate-retirement behavior
  for .state-witness.previous after compaction
- P1: explicit store_is_anchored gate on the zero-signature marker,
  trust-journal record-count ceiling, ABI major bump to 5, redacted
  FFI error detail now reaches stderr, symlink-hardening test
  coverage, and doc corrections across three operator runbooks
- P2/P3: dead-code cleanup, sentinel comparison fix, permission
  self-heal restore, errno/symlink hardening in directory enumeration,
  and numerous doc/comment accuracy fixes
- New: removed the incremental witness-journal verification cache
  entirely (witness_anchor_matches et al.) after discovering its
  stat-based staleness check can be defeated by same-tick timestamp
  collisions on back-to-back writes, allowing same-length mid-journal
  corruption on a live open store to go undetected; every witness-tip
  access now performs a full re-parse
…ontinuity across compaction boundary

- Extract publish_state_witness_segment as the single shared publication
  routine used by both rotate_state_witness_segment_inner (anchored
  rotation) and compact_witness_journal_local (local compaction),
  eliminating the reimplementation that caused the two recovery state
  machines to diverge. Anchored-rotation behavior is byte-for-byte
  unchanged (frozen 472-byte header vector test still passes).
- Thread the retiring segment's terminal per-record chain hash into
  the new segment's genesis seed for local compaction, via the
  synthetic acknowledgement's previous_event_root field, giving the
  unanchored+compaction path real cross-segment tamper evidence it
  previously lacked. Verified by a new test that forges the threaded
  link on disk and confirms reopen fails closed.
…n gap

The removal of the incremental witness-journal cache (previous commit)
made every append_witness_record call, and every call routed through
revalidate_store_entries, perform a full O(journal-length) reparse.
Measured: 200 sequential writes took 3.9s, the next 200 took 33.3s --
O(N) per write / O(N^2) total, unusable near the 262,144-record ceiling.

Fix:
- append_witness_record/append_witness_record_unchecked now do O(1)
  checks only (pre-append size match, post-append read-back of just
  the new record) instead of a full reparse.
- revalidate_store_entries no longer forces a full reparse internally,
  restoring O(1) writes end to end (replace_state confirmed flat via
  isolated timing probe: ~3.3-3.6ms/call across 200/400/800 sequential
  writes, no growth).
- The full-verify guarantee moved to explicit, first-statement calls on
  every public read-oriented entrypoint instead: state_witness_tip(),
  identity(), identity_for_load(), read_state(), read_state_for_load()
  (the actual production state-load path used by persistence.rs),
  state_witness_tip_snapshot(), state_anchor_trust_head_snapshot(), and
  state_anchor_bootstrap_facts_snapshot(). Two of these (identity,
  read_state_for_load) had no full-verify at all after the incremental
  cache was removed -- a live-open-store read via either would have
  silently returned unverified, potentially-tampered state. New tests
  prove both now fail closed on a live store the instant a non-tail
  journal record is corrupted.
…description

The guard was never mode == Ordinary in code -- recover_state_witness_compaction
is invoked whenever anchor_metadata.is_none(), unconditionally across every
acquire mode. This drift was introduced when the doc-fix and code-fix commits
ran concurrently; re-verified directly against the current acquire_with_mode
call site before correcting.
…full-verify reads

identity()/identity_for_load() were given the same full-witness-reparse
guarantee as state_witness_tip()/read_state_for_load() in the prior
commit. Measured at the real 262,144-record production ceiling: a
single identity() call cost ~4.3s. identity() is called by
ensure_state_file_lock() -- 'the front door for every stateful signer
operation' -- before every read AND write, so every operation near the
ceiling paid this tax, not just repeated read calls.

DurableStoreIdentity (store_id + structural filesystem/lock/canonical-
path fingerprints) never actually depends on witness-journal record
content -- its own doc comment states these fields 'do not enter the
state-commitment transcript'. Reverted identity()/identity_for_load()/
read_state() to their O(1) pre-fix behavior; this loses no guarantee
about the DATA they return, since that data was never journal-content-
derived. state_witness_tip(), read_state_for_load(),
state_witness_tip_snapshot(), state_anchor_trust_head_snapshot(), and
state_anchor_bootstrap_facts_snapshot() are untouched and keep the
full-reparse guarantee.

Verified: identity() at the 262,144-record ceiling now 110us (was
4.3s); state_witness_tip()/read_state_for_load() at ceiling unchanged
at ~4s each (by design); front-door (identity) + replace_state loop
flat at ~3.3-3.6ms/call across 200/400/800 iterations (isolated,
single-threaded run to rule out system-load noise).

Deleted identity_catches_middle_of_journal_corruption_on_already_open_store:
its premise (identity() independently detects journal corruption) no
longer holds and was never part of identity()'s real data contract.
read_state_for_load's equivalent regression test is unchanged and still
covers the actual production state-load path.
…entrypoints

state_witness_tip_snapshot(), state_anchor_trust_head_snapshot(), and
state_anchor_bootstrap_facts_snapshot() all carry the full-witness-reparse
guarantee but had no dedicated regression test proving it -- only
state_witness_tip() and read_state_for_load() did. Add one test per
entrypoint, each corrupting a non-tail journal record on a live open
store and asserting the corresponding entrypoint fails closed, so a
future refactor of the shared verification path cannot silently drop
the guarantee for these three with a green suite.

state_anchor_bootstrap_facts_snapshot's test corrects for its pristine-
genesis-only precondition by corrupting the non-tail PREPARE record of
the mandatory 2-record genesis pair, and uses acquire_for_bootstrap_facts
(not acquire+replace_state, which would make the store permanently
non-pristine). state_anchor_trust_head_snapshot's test first bootstraps
a real trust transition (it errors with no trust head at all, so a
plain unanchored store cannot exercise it), matching the existing
bootstrap_trust_transition_succeeds_on_first_call_and_ordinary_reopen
setup in store.rs.
…ath split

- FOLLOWUP.md: line 53 ('Original issue') described the pre-fix state
  using the function's name AT THAT TIME (ensure_witness_record_capacity)
  -- restored the historical name with a pointer to the current one,
  rather than overwriting history with the post-P3-8 name.
- identity_for_load()'s doc comment claimed it still validates 'the
  witness journal' -- no longer true after the front-door/data-read
  split; corrected to state it validates descriptors only, and that
  full witness-journal re-verification now lives exclusively in the
  data-bearing load call (read_state_for_load).
- state.rs's ensure_state_file_lock() comment made the same false claim
  ('a lock, store-ID, directory, witness, or state replacement...
  cannot be hidden') about identity() specifically -- removed 'witness'
  from that list and added an explicit note on why (cost vs. the data
  identity() actually returns), matching the reasoning already recorded
  in store.rs.
…cksum

- clippy (stable toolchain, newer than what was locally installed)
  flags chunks_exact() with a constant chunk size under -D warnings;
  switch to as_chunks::<TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH>() in
  the witness-journal parser and adjust apply_state_witness_record's
  call site (as_chunks yields fixed-size arrays, not byte slices).
- tla2tools.jar checksum mismatch in CI: the upstream v1.8.0 release
  asset was replaced by the tlaplus project between 2026-09-08 (when
  the previous pin was verified) and 2026-09-09, changing its bytes
  under the same version tag. Re-verified via the same procedure
  (download from the release URL, confirm SHA-1 against the release
  notes, pin the resulting SHA-256) and documented the re-upload so a
  future re-pin doesn't assume 'same tag' implies 'same bytes'.
…I test

provisioning_config_ffi_is_startup_only_and_capability_minimal spawns
a subprocess via Command::env(...) without holding the test-isolation
mutex (lock_test_state()) that every other TBTC_SIGNER_*-touching test
acquires. Root-caused via signer_env_var() branch instrumentation:
persisted_engine_state_rejects_session_registry_over_limit's own
env::set_var(MAX_SESSIONS, "2") -> env::var() round trip, taken
entirely within its own held guard, was observed flipping from
Some("2") back to None microseconds later with no other guarded test
able to have caused it - only this unguarded subprocess spawn was
running concurrently. Reproduced the CI failure locally (~30-40% flake
rate under --test-threads=4), fixed by acquiring the same guard, then
confirmed 25/25 clean release-mode runs versus the prior flake rate.
…IONS env

production_default_state_witness_max_records_is_sane called
clear_state_storage_policy_overrides() (which removes
TBTC_SIGNER_MAX_SESSIONS among other vars) without holding
lock_test_state(). An exhaustive scan for this class of bug (any
#[test] fn calling clear_state_storage_policy_overrides,
establish_clean_signer_test_env, reset_for_tests, or a direct
TBTC_SIGNER_* env::set_var/remove_var without lock_test_state()
somewhere in its body, across tests.rs/lib.rs/ffi.rs/store.rs/
anchor.rs/init_config.rs) found this as the only remaining instance
after the previous commit's fix. Corrected that commit's comment,
which overclaimed exclusivity before this second instance was found.
Verified 8/8 clean debug-mode full-suite runs (CI's actual profile,
not just release) after both fixes, ~155-166s each, 364 passed + 3
ignored consistently.
@piotr-roslaniec
piotr-roslaniec changed the base branch from dev to codex/signer-store-identity-abi September 9, 2026 11:10
All four originally-deferred items it tracked are implemented and
verified against current source: per-record hash chain (P0#2),
local compaction for unanchored signers (P0#4), trust journal
records ceiling (P1, partial), and witness_history bound (already
mitigated via rotation/compaction). One named test reference was
stale (renamed since the doc was written) but the covering test
exists under its current name.

Removed the two dangling references that would otherwise point at
a deleted file: the compaction runbook's cross-reference and a
tests.rs comment.
…ow-annotating

Empirical strip test (all 40 #[allow(dead_code)] in the crate removed,
CI's exact 'cargo clippy --all-targets -- -D warnings' re-run) showed
80+31 compile errors, confirming the remaining 34 net-added annotations
in this PR are load-bearing: reachability cascades from a small set of
FFI-pending entry points (state_witness_tip, persist_distributed_dkg_key_package,
etc, all called only from test code pending a future PR wiring
frost_tbtc_*anchor*/*checkpoint*/*trust* symbols) down through everything
they call, including the two EngineError variants they construct.

Three items were independent of that chain and had zero live or
test-only callers anywhere:
- EnvVarGuard::unset() in lib.rs - dead test helper, deleted.
- CoordinatorSeedVectorFile::description and
  CoordinatorShuffleCorpusFile::description - deserialized from JSON
  test fixtures but never read; neither struct has
  #[serde(deny_unknown_fields)], so removing the field does not
  break deserialization of the existing corpus JSON (the key is
  silently ignored, same as any other unrecognized field).

Verified: fmt/clippy clean, 365/365 tests pass (matches baseline).

This branch has not been deployed

No deployments
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