feat(tbtc/signer)!: add durable witness history and external anchor (ABI 4.2)#4198
Open
mswilkison wants to merge 6 commits into
Open
feat(tbtc/signer)!: add durable witness history and external anchor (ABI 4.2)#4198mswilkison wants to merge 6 commits into
mswilkison wants to merge 6 commits into
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
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
…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
force-pushed
the
codex/signer-store-identity-abi
branch
from
July 25, 2026 22:37
a5363e9 to
7bfa0e5
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Based on #4005. 4 commits.
Descriptor-bound stable
.store-id,O_NOFOLLOW/openatwith inode and path checks, atomic replacement, an append-only PREPARE/COMMIT/ABORT state-witness journal, retained key-package inventory withdkg_share_epoch, and ABI symbols for durable identity, inventory and witness proof, with cross-language vectors.Why the transcript had to change
durable_store_fingerprintmixed the stablestore_idwith three volatile inputs: the parent directory's path string +st_dev+st_ino, the filesystemst_dev, and the advisory lock file's name +st_dev+st_ino. That composite was the first field of everystate_commitment, andparse_state_witness_journalrejects 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, soTBTC_SIGNER_STATE_CORRUPTION_POLICY=quarantine_and_resetnever 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 — whilefrost_tbtc_durable_store_identitykept 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, backendencrypted-file-v1(unchanged), domainstbtc-signer-durable-session-store-fingerprint-v2\0,tbtc-signer-state-witness-genesis-v2\0,tbtc-signer-state-witness-commitment-v2\0, magicTBTCWITNESSv2\0\0\0.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 →renameat→ directory 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_historystill grows ~104 B/persist.Evidence
cargo fmt --check,cargo check,cargo clippy --all-targets --all-features -- -D warningsall clean.cargo test --lib -- --test-threads=1→ 273 passed / 0 failed / 1 ignored (baseline 263; +10 new tests).Reviewer notes
$TMPDIR/frost_tbtc_engine_state.jsonwill 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_devchange 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.validate_state_imagestill 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.cfg(not(unix))stub is not compile-verified on a non-unix target;rustupwas unavailable for a--targetcross-check.