Skip to content

feat(tbtc/signer)!: add durable witness history and external anchor (ABI 4.2)#4198

Open
mswilkison wants to merge 6 commits into
extraction/frost-signer-mirror-2026-05-26from
codex/signer-store-identity-abi
Open

feat(tbtc/signer)!: add durable witness history and external anchor (ABI 4.2)#4198
mswilkison wants to merge 6 commits into
extraction/frost-signer-mirror-2026-05-26from
codex/signer-store-identity-abi

Conversation

@mswilkison

@mswilkison mswilkison commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Based on #4005. 4 commits.

Descriptor-bound stable .store-id, O_NOFOLLOW/openat with inode and path checks, atomic replacement, an append-only PREPARE/COMMIT/ABORT state-witness journal, retained key-package inventory with dkg_share_epoch, and ABI symbols for durable identity, inventory and witness proof, with cross-language vectors.

BREAKING — coordinated merge required. This bumps the signer ABI 3.5 → 4.0 and the state-witness transcript v1 → v2. It cannot merge alone: keep-core #4199 pins requiredTBTCSignerABIMajor = 3 (pkg/frost/signing/native_tbtc_signer_abi_version.go:22) and will reject this library at load, and ci/frost-signer-pin.env must move to the commit that provides ABI 4.0. That file's own comment states the rule — bump it in the same PR as the Go constant.

Why the transcript had to change

durable_store_fingerprint mixed the stable store_id with three volatile inputs: the parent directory's path string + st_dev + st_ino, the filesystem st_dev, and the advisory lock file's name + st_dev + st_ino. That composite was the first field of every state_commitment, and parse_state_witness_journal rejects any record that does not recompute under the current fingerprint.

So deleting the zero-byte lock file (tmp reaper, "stale lock" cleanup, container rebuild), restoring the data directory from backup at the same path (tar/rsync gives new inodes), renaming or moving the directory, or remounting on btrfs/ZFS/overlayfs/NFS/tmpfs made 100% of committed records unverifiable and the signer unstartable.

Worse, the failure is raised inside StateFileLock::acquire, so TBTC_SIGNER_STATE_CORRUPTION_POLICY=quarantine_and_reset never reaches it. The only remaining operator action was deleting .state-witness — which re-genesises at generation 1, exactly the rollback this feature exists to detect — while frost_tbtc_durable_store_identity kept returning a byte-identical fingerprint, so a host pinning that identity got no signal.

Fix: commitments bind only stable content. The path/filesystem/lock fingerprints are still computed at open, still enforced on every access by revalidate_store_entries (symlink/inode/device/nlink/uid/mode checks unchanged), and still reported for diagnostics — they simply no longer enter any committed transcript. A v1 journal is recognised by magic alone (deliberate: the v1 fingerprint may no longer be recomputable, which is the failure mode) and fails closed with an actionable migration error that names both transcript versions and warns against deleting the journal. A v1 journal is never rewritten or repaired; the v1 vectors are retained as rejection-path regressions.

v2 cross-language vectors — for the Go side (#4199)

Constants: schema tbtc-signer-durable-session-store-identity/v2, backend encrypted-file-v1 (unchanged), domains tbtc-signer-durable-session-store-fingerprint-v2\0, tbtc-signer-state-witness-genesis-v2\0, tbtc-signer-state-witness-commitment-v2\0, magic TBTCWITNESSv2\0\0\0.

fingerprint(store_id) = SHA256(fp_domain ‖ u32be(len(schema))‖schema ‖ u32be(len(backend))‖backend ‖ u32be(32)‖store_id)
genesis(fp)           = SHA256(genesis_domain ‖ fp)
commitment(fp,g,p,d)  = SHA256(commit_domain ‖ fp ‖ u64be(g) ‖ p ‖ d)

durable_store_fingerprint(0x11*32) = 8bb8d21c69e78916e8f165b0c861c0d84c5d7af5393f75b0321fe048f772abba
durable_store_fingerprint(0x24*32) = 52fcbfc4b2c6a93645106a32c62113192cac30b934b905e1ad357792c4ce8628
state_witness_genesis(0x11*32)     = 44085b42d29bf25f06207142f9e2db58eaf86f88d92b6e18104161ce59e98a89
state_commitment(0x11*32,42,0x22*32,0x33*32)
                                   = ea5eb04a4776357e59875f683390a2ff4b7dd511ad394e588dfab147f94fa867

chained: fp = fingerprint(0x11*32)
         genesis(fp)                      = 3179b8bc6614b0951b703f9c418b17cf7cd8b7f1bef1f86587385d4c150efab2
         commitment(fp,1,genesis,0x33*32) = 5387626d5314b17b324f9a7df1ab16fcbf10917a137527bf33c71847e1b77da0

Also fixed

Torn genesis was unrecoverable. Creation wrote a 258-byte header+PREPARE+COMMIT blob non-atomically before syncing; a hard kill left a short file that was fatal on next start, again inside acquire() where the corruption policy cannot help. The asymmetry was backwards — a torn append was repaired, a torn genesis was not. Genesis now publishes atomically: complete image → temp entry in the same directory (openat + O_EXCL + O_NOFOLLOW, mode 0600) → fsync → target-absent check → renameatdirectory fsync. Same for .store-id.

A short file at the final name is deliberately not "recovered": this build cannot produce one, so it is external damage, and a zero-length or half-written journal is indistinguishable from deliberate truncation — re-creating it would re-genesis the anti-rollback chain over whatever state image is present. Those bands fail closed with errors that say a short file is not a torn write, and to restore rather than delete. Recovery is proven for real crash residue: a planted orphan temp entry, after which the store comes up clean and advances.

Unbounded re-verification. The journal grew ~210 B/persist with no compaction and was fully re-read and re-hashed roughly four times per persist — at ~1e6 lifetime persists, a ~210 MB journal re-hashed ~4x per persist, degrading toward missed signing and DKG deadlines. Now a per-lock verified-prefix cache makes steady state O(1) (stamp + 48-byte header + 105-byte tail). Newly appended bytes are read back from disk before being trusted, any mismatch falls through to the full parse, and the cache is in-memory only so a fresh open always re-parses. Measured: 60 persists → 12,858-byte journal, exactly 1 full parse, 669 bytes/persist, constant in journal length.

Compaction/rotation is not implemented — preserving the anchor chain and anti-rollback across a rewrite is a separate, riskier change. Resident witness_history still grows ~104 B/persist.

Evidence

cargo fmt --check, cargo check, cargo clippy --all-targets --all-features -- -D warnings all clean. cargo test --lib -- --test-threads=1273 passed / 0 failed / 1 ignored (baseline 263; +10 new tests).

Reviewer notes

  • Any developer machine with a pre-change store at $TMPDIR/frost_tbtc_engine_state.json will hit the v1 migration error. That is the intended signal, not a bug — but it presents as ~94 failing tests until the stale store is removed.
  • st_dev change could not be simulated portably. The property is proven structurally instead: identity.fingerprint == durable_store_fingerprint(store_id) with no other input, plus a whole-store restore into a different directory (new path, new inodes, new lock file) that verifies and advances.
  • Residual, documented in code: a same-uid writer who modifies the journal's middle and leaves size/mtime/ctime unchanged (requires clock control, root, or a coarse-granularity filesystem within one tick) is detected on the next fresh open rather than immediately.
  • validate_state_image still reads and hashes the whole state image on every revalidate (4x/persist). Bounded by engine-state size rather than persist count, so out of scope here — but it is the next thing on that hot path.
  • The cfg(not(unix)) stub is not compile-verified on a non-unix target; rustup was unavailable for a --target cross-check.

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

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: Pro Plus

Run ID: 90ee68a5-2e02-42c7-8afb-1460d85966a6

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
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/signer-store-identity-abi

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.

@mswilkison mswilkison changed the title feat(tbtc/signer): durable store identity and state-witness journal (ABI 3.5) feat(tbtc/signer)!: durable store identity and state-witness journal (ABI 4.0) Jul 25, 2026
mswilkison added a commit that referenced this pull request Jul 25, 2026
Lockstep bump for the signer's ABI 3.5 -> 4.0 change (keep-core #4198).
Per this file's own rule, the Go constants and ci/frost-signer-pin.env move
together with the lib commit that provides them.

Under ABI 3, `durable_store_fingerprint` mixed the stable `.store-id` with the
parent directory's path string and inode, the filesystem device, and the
advisory lock file's inode — and that composite was the first field of every
state commitment. So deleting the zero-byte lock file, restoring the data
directory from backup at the same path, renaming it, or remounting where
st_dev is unstable made every committed record unverifiable and the signer
unstartable, with no in-band recovery: the failure is raised inside
StateFileLock::acquire, before the corruption policy can apply.

ABI 4.0 binds commitments to the stable `.store-id` alone. The path,
filesystem and lock fingerprints are still computed and still enforced at
open — they simply no longer enter any committed transcript. The symbol set
is unchanged, but the VALUES of existing wire fields differ, so an ABI-3
library must not be linked by this bridge.

  requiredTBTCSignerABIMajor    3 -> 4
  requiredTBTCSignerABIMinMinor 5 -> 0
  FROST_SIGNER_MIRROR_REF       6e0fa97 -> a5363e9

Minor 0 is the initial ABI-4 surface and carries forward every symbol added
through 3.5 under the v2 identity and transcript schemas.

Verified: go build ./pkg/frost/... ./pkg/tbtc/... clean;
go test ./pkg/frost/signing/ passes, including the contract-pin guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012KZb3W6TwYtKTw1mb2sfpS
mswilkison and others added 6 commits July 25, 2026 17:39
…ABI 3.5)

Descriptor-bound stable .store-id, O_NOFOLLOW/openat with inode and path
checks, atomic replacement, an append-only PREPARE/COMMIT/ABORT state-witness
journal, retained key-package inventory with dkg_share_epoch, and ABI 3.5
symbols for durable identity, inventory and witness proof, with cross-language
vectors.

Closes seven previously audited blockers: legacy-migration fixtures now drop the
lock and remove .state-witness before writing a true pre-witness fixture;
open_or_create_state_witness truncates only an incomplete trailing record and
syncs before parsing, while a complete-but-malformed record still fails closed;
ensure_state_file_lock fully validates a newly acquired store; a cfg(not(unix))
stub for reconcile_pending_witness; three clippy fixes; and a rollback test
proving a coherent generation-N snapshot is rejected against an independently
retained N+1 anchor.

Verified: cargo fmt --check, cargo check, cargo clippy --all-targets
--all-features -- -D warnings all clean; cargo test --lib --test-threads=1
263 passed / 0 failed / 1 ignored (baseline was 260 passed / 2 failed plus
3 clippy errors).

KNOWN DEFECTS - an independent adversarial review confirmed three issues in this
design that the above gates do not detect. Do not merge before addressing:

1. (medium) store.rs:232 - lock_fingerprint hashes the advisory lock file's
   st_dev/st_ino and canonical_path_fingerprint hashes the parent directory's
   path string and inode. Both feed durable_store_fingerprint, the first field
   of every state_commitment. Deleting the zero-byte lock file, restoring from
   backup at the same path, renaming the directory, or remounting on
   btrfs/ZFS/overlayfs/NFS/tmpfs therefore makes every committed record
   unverifiable and the signer unstartable. The failure is raised inside
   acquire(), so TBTC_SIGNER_STATE_CORRUPTION_POLICY=quarantine_and_reset never
   reaches it; the only remaining action is deleting .state-witness, which
   re-genesises at generation 1 - exactly the rollback this feature exists to
   detect - while frost_tbtc_durable_store_identity keeps returning a
   byte-identical fingerprint, so the host gets no signal.

2. (low) store.rs:1210 - the truncation-repair comment asserts "a record is
   written and fsynced as one unit", but creation writes a 258-byte
   header+PREPARE+COMMIT blob non-atomically before syncing. A hard kill during
   first initialisation leaves a short file that is fatal and unrecoverable on
   next start. A torn append is repaired; a torn genesis is not.

3. (medium) store.rs:609 - the witness journal grows ~210 bytes per persist with
   no compaction or rotation, and is fully re-read and re-verified roughly four
   times per persist. Cost grows without bound in lifetime persist count,
   degrading toward missed signing and DKG deadlines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012KZb3W6TwYtKTw1mb2sfpS
The v1 store fingerprint mixed the stable `.store-id` bytes with three
volatile filesystem descriptors: the canonical parent path string plus the
directory's st_dev/st_ino, the filesystem st_dev, and the lock file's name
plus its st_dev/st_ino. That fingerprint is the first field of every state
commitment, and `parse_state_witness_journal` rejects any record whose
commitment does not recompute under the CURRENT fingerprint.

Failure mode: deleting the zero-byte lock file (tmp reaper, "stale lock"
cleanup, container rebuild), restoring the data directory from backup at the
same path (tar/rsync assigns new inodes), renaming or moving the directory,
or any remount that moves st_dev (btrfs/ZFS subvolumes, overlayfs, NFS,
tmpfs - note the non-production default state path is std::env::temp_dir())
made 100% of committed records unverifiable and the signer unstartable. The
error is raised inside StateFileLock::acquire, so
TBTC_SIGNER_STATE_CORRUPTION_POLICY=quarantine_and_reset never reaches it,
leaving `rm .state-witness` - a generation-1 re-genesis, exactly the rollback
the journal exists to detect - as the only operator action.

Fix: separate verification-time checks from commitment-time binding.

- `durable_store_fingerprint` now binds the schema constant, the backend
  constant, and the 32 stable store-ID bytes, and nothing else. It anchors
  `state_commitment` and `state_witness_genesis`.
- The canonical-path, filesystem, and lock descriptors are still computed and
  still enforced on every access by `revalidate_store_entries` (symlink,
  inode, device, nlink, ownership, and mode checks are untouched), and are
  still reported by frost_tbtc_durable_store_identity for diagnostics. They
  no longer enter any committed transcript.
- This is a break in a frozen cross-language transcript, so the fingerprint,
  genesis, and commitment domains move to v2, the identity schema moves to
  /v2, and the journal magic moves to TBTCWITNESSv2. A v1 journal is
  recognized by its magic alone - which works even when the v1 fingerprint
  can no longer be recomputed - and fails closed with an actionable migration
  error instead of a generic "invalid commitment". A v1 journal is never
  rewritten, repaired, or reset.
- Transcript v2 changes the bytes of existing wire fields, so the FFI
  contract moves to ABI major 4 (minor resets to 0; the symbol set is
  unchanged from 3.5). Go bridges must move in lockstep.

New v2 cross-language vectors (Go must reproduce these exactly):
  durable_store_fingerprint([0x11;32])
    = 8bb8d21c69e78916e8f165b0c861c0d84c5d7af5393f75b0321fe048f772abba
  durable_store_fingerprint([0x24;32])
    = 52fcbfc4b2c6a93645106a32c62113192cac30b934b905e1ad357792c4ce8628
  state_witness_genesis([0x11;32])
    = 44085b42d29bf25f06207142f9e2db58eaf86f88d92b6e18104161ce59e98a89
  state_commitment([0x11;32], 42, [0x22;32], [0x33;32])
    = ea5eb04a4776357e59875f683390a2ff4b7dd511ad394e588dfab147f94fa867

The frozen v1 vectors are retained as regression tests for the rejection
path, alongside new tests proving that a deleted lock file and a whole-store
restore to a different directory with fresh inodes both keep every committed
record verifiable and let the chain advance from the restored tip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012KZb3W6TwYtKTw1mb2sfpS
`open_or_create_state_witness` opened the journal O_CREAT|O_EXCL and then
wrote the 258-byte header+PREPARE+COMMIT image with one non-atomic
`write_file_at` before fsyncing it; `open_or_create_store_id` had the same
shape for its 32 bytes. A hard kill inside that window left a short file at
the final name, and every short length is fatal on the next start:

  .store-id       0 or 1-31 bytes -> "invalid length"
  .state-witness  0-47   -> header incomplete
                  48-152 -> missing/partial record
                  153-257-> no committed genesis record

All of these are raised inside `StateFileLock::acquire`, so
TBTC_SIGNER_STATE_CORRUPTION_POLICY=quarantine_and_reset cannot help. Note
the asymmetry the previous comment had backwards: a torn APPEND after genesis
is correctly repaired by `truncate_incomplete_witness_record`, while a torn
GENESIS - the one write that is not a single fixed-width record - was
unrecoverable.

Fix: `create_entry_atomically` writes the complete image to a temp entry in
the same directory, fsyncs it, checks the target name is still absent,
renames over it, and then fsyncs the DIRECTORY so the rename itself is
durable. Both genesis writers use it. Mode 0600 and the openat/O_EXCL/
O_NOFOLLOW discipline are preserved end to end - the temp entry is created
through the held no-follow directory descriptor with O_EXCL, so it can never
be a symlink-following hazard, and the descriptor that survives the rename is
the one the store keeps, so nothing is reopened by name. A failed publish
unlinks the temp entry. A crash before the rename now leaves only a temp
entry and an absent target, which the opener already handles by creating it.

Recovery for a short file at the FINAL name is deliberately not offered: this
build cannot produce one, so it is damage from outside the signer, and a
zero-length or half-written journal is indistinguishable from a deliberate
truncation. Re-creating it would re-genesis the anti-rollback chain at
generation 1 over whatever state image is present - the rollback the journal
exists to detect. Those bands now fail closed with errors that name the file,
state that a short file is not a torn write, and tell the operator to restore
rather than delete.

Tests cover both crash windows (a planted orphan temp entry for .store-id and
.state-witness, after which the store comes up clean and advances) and every
fatal short length band of both files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012KZb3W6TwYtKTw1mb2sfpS
`revalidate_store_entries` read the ENTIRE journal and
`parse_state_witness_journal` recomputed a SHA-256 commitment for every
record ever written. `replace_state` invokes it three times per persist and
`ensure_state_file_lock` -> `identity()` adds a fourth, while the journal
grows ~210 bytes per persist with no compaction, rotation, or checkpointing.
Cost therefore grew without bound in LIFETIME persist count: at ~1e6 persists
that is a ~210 MB journal re-read and re-hashed ~4x per persist, degrading
toward missed signing and DKG deadlines. (A stale 336 KB journal left in the
temp dir by an earlier run of this crate's own test suite already accounted
for roughly two thirds of its wall-clock time.)

The journal is append-only, so verification is incremental. The verified
prefix is now cached per `StateFileLock` as (file identity, change stamp,
verified offset, history length, last verified generation + commitment, exact
trailing record bytes). A later access re-checks that anchor in O(1):

- the journal's dev/ino must be the descriptor the store holds,
- its size/mtime/ctime stamp must be untouched since the last verification -
  any write moves at least one, and ctime cannot be back-dated by an
  unprivileged writer,
- the 48-byte header must still bind this store ID,
- the trailing record must still be byte-identical.

Bytes appended since the last verification are read BACK from disk and
compared with what was written, at append time, so no journal byte is ever
trusted without having been read. Any mismatch - including a moved file
identity - falls through to the full parse, which is what still produces the
precise error, so nothing is weakened: a tampered prefix is caught in-process
through the change stamp and unconditionally on a fresh open, where the cache
does not exist. The cache is in-memory only and is never a trust anchor
across process restarts.

Compaction and rotation are deliberately NOT implemented here: preserving the
anchor chain and the anti-rollback property across a rewrite is a separate,
riskier change. The resident `witness_history` therefore still grows with
lifetime persist count.

Tests: a benchmark-style assertion that per-persist journal read volume stays
constant (< 1 KiB) and below the journal length while 60 persists grow the
journal to ~12.9 KB, with exactly one full parse for the whole run; and a
tampering test that flips a byte deep inside the already-verified prefix and
requires detection both in-process and on a fresh open, plus recovery when
the committed bytes are restored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012KZb3W6TwYtKTw1mb2sfpS
@mswilkison
mswilkison force-pushed the codex/signer-store-identity-abi branch from a5363e9 to 7bfa0e5 Compare July 25, 2026 22:37
@mswilkison mswilkison changed the title feat(tbtc/signer)!: durable store identity and state-witness journal (ABI 4.0) feat(tbtc/signer)!: add durable witness history and external anchor (ABI 4.2) Jul 25, 2026
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