Skip to content

Check snapshot isolation of signature transactions #6188

Description

We may break snapshot isolation because signature transactions are special.

There is a delay between booking a version for a signature, and producing the corresponding write. What happens if a separate transaction tries to read from the signature table in that gap? Does it read an inaccurate value, or do we separate the booked-version and readable-version? Should you be allowed to read from the signature table in other transactions?

Activity

  1. achamayou commented on Oct 1, 2026

    @achamayou
    Member

    Investigation: the reservation is exposed as a readable version

    Investigated the published revision f656489f2fa2, on 2026-10-01.

    There is no separate complete/readable-version frontier in the default transaction read path. Reserving signature sequence number S advances the same store version that a new transaction uses as its read version. Before the signature is materialised, that transaction can capture the preceding signature, or absence if this is the first one. The more serious cases are inconsistent cross-table snapshots and successfully applied writes whose inputs do not correspond to any prefix of the ledger.

    This is not a non-repeatable get() on one handle, and is not, by itself, a forged signature, a broken Merkle signature, or divergent replica state. Reading a valid older signature in isolation can be acceptable for an explicitly "latest available signature" API. The defect is treating the allocated version as an immutable, consistent KV snapshot when a lower/equal-version write can still arrive.

    1. Why the gap is real

    The relevant sequence is:

    • emit_signature() calls store.next_txid() before submitting the pending signature transaction.
    • next_txid() calls next_version_unsafe(), incrementing Store::version. current_txid_and_commit_term() returns that version to readers.
    • Store::commit() queues pending transactions and only invokes a signature's call() once the preceding replication entries are available. Even without a missing predecessor, there is a window between allocation and applying the write, including signing/serialisation work.
    • Only MerkleTreeHistoryPendingTx::call() constructs the reserved transaction, computes the signatures, and calls commit_reserved().
    • BaseTx captures a read TxID at the first KV handle acquisition, not at transaction construction. Each table's change set is acquired lazily. Map::create_change_set() selects the newest currently present table state at or below that read version.

    last_replicated, the replicated history's end index, and compacted exist separately, but none is used as the default readable frontier here. Also, the reservation does not hold the signature-table locks throughout the gap. The store's replication commit_lock does not cover default read-version acquisition or ordinary transactions' earlier local write application.

    In dual-signing mode the delayed transaction writes three tables: public:ccf.internal.signatures, public:ccf.internal.cose_signatures, and public:ccf.internal.tree. COSE-only mode still delays the latter two. The raw-signature table deliberately stops advancing in COSE-only mode; that separate, intentional staleness must not be confused with this race.

    2. Old/absent data under a version that will contain a newer signature

    Here P is the preceding signature, and the signature table already exists:

    sequenceDiagram
        participant E as Signature emitter
        participant K as KV store
        participant A as Reader A
        participant B as Reader B
        E->>K: next_txid() reserves S
        Note over K: Store version is S, signature table still contains P
        A->>K: First handle pins R = S and snapshots signatures
        K-->>A: Signature P
        E->>K: Pending signature applies its writes at S
        B->>K: First handle pins the same R = S
        K-->>B: Signature S
        A->>K: get() using its original signature handle
        K-->>A: Still P
        A->>K: commit() with no writes
        K-->>A: SUCCESS, read TxID is S
        Note over A,B: Same read TxID, different signature contents
    
    Loading

    The old handle is stable: BaseTx reuses its per-table change set, and get() reads that captured state. Creating another typed handle over the same named table in the same transaction does not refresh it. The state is captured at actual KV handle acquisition, even if the first get() happens later; has(), iteration/size, and previous-write-version queries can also observe the old captured state.

    Read-only endpoint transactions do not ordinarily repair this at commit: apply_changes() calls prepare() only when there are writes, and get_txid() reports the read TxID for a no-write transaction.

    First-signature qualification: if the table does not yet exist, the reader creates a transaction-local empty map and observes absence. If the real table is created before an endpoint's CommittableTx::commit(), the separate new-map conflict check can reject that transaction, even without writes. A plain ReadOnlyTx has no such commit step, and a transaction that finishes before materialisation can still return or act on absence. So "all read-only transactions always bypass all validation" would be too strong.

    3. A torn signature/tree/COSE bundle

    For a reader whose read version includes S, opening different tables on opposite sides of materialisation can yield a combination which was never an atomic KV state. This diagram assumes dual signing, where all three tables change at S:

    sequenceDiagram
        participant R as Reader at R = S
        participant K as KV store
        participant E as Pending signature S
        R->>K: Open signatures table before S is applied
        K-->>R: Capture signature P and root P
        E->>K: Atomically apply raw, COSE, and tree writes at S
        R->>K: Open tree table after S is applied
        K-->>R: Capture tree S
        R->>K: Open COSE table after S is applied
        K-->>R: Capture COSE signature S
        Note over R: Cached signature/root P plus tree/COSE S
        R->>K: No-write commit
        K-->>R: SUCCESS for existing tables
    
    Loading

    This is not a partially applied signature transaction: the writes themselves take all affected map locks. The inconsistent assembly comes from lazy, separately cached table snapshots at a read version that was published before its signature write was present. The reverse old/new mixture is possible if the tables are opened in a different order. Keys within a single captured table remain consistent with that table's captured state.

    In COSE-only mode, the equivalent tear is old COSE/new tree or new COSE/old tree. Old raw/new COSE is not sufficient evidence of a tear there: leaving the raw table unchanged is intentional.

    An application building its own proof from such a bundle can obtain a signature/root and proof/tree from different signature transactions, leading to verification failure, a wrong coverage decision, or an early error. It cannot thereby make an invalid proof pass correct cryptographic verification. Commit-time conflicts also cannot undo incorrect handler branching or an error response returned before commit.

    Safe boundary: a transaction pinned to R < S before reservation excludes the new signature from every later table handle, so this particular gap does not tear its snapshot. A transaction that captures the relevant tables after all writes through its read version are present is also safe.

    4. Dependent writes: conflict detection helps only in one ordering

    For a write transaction, prepare() compares observed key versions with the current table state:

    Ordering Result
    Reader captured P; S materialises before the reader prepares its write The changed signature dependency conflicts; the normal frontend retries.
    Reader captured P; reader prepares/applies its write before S materialises The dependency still matches P, so the reader can succeed at a sequence number greater than S. Filling the earlier signature slot does not revalidate that already-applied transaction.
    Reader has no writes and the tables already exist Read-set preparation is skipped; stale/torn results can succeed.

    The second ordering can affect persisted application or governance decisions, not just a transient GET. Normal application transactions can already have applied later-version writes while replication is held behind the missing signature:

    sequenceDiagram
        participant E as Signature emitter
        participant W as Independent writer
        participant R as Dependent reader/writer
        participant K as Local KV
        participant L as Ordered ledger
        Note over K,L: Initial prefix 2 has signature 1 and application data 0
        E->>K: Reserve signature slot 3, leave it unfilled
        W->>K: Apply application data 4 at version 4
        K-->>W: SUCCESS, replication waits for slot 3
        R->>K: Pin read version 4, read signature and application data
        K-->>R: Signature 1 and application data 4
        R->>K: Prepare and apply derived writes at version 5
        K-->>R: SUCCESS, signature dependency still matches version 1
        E->>K: Materialise signature 3
        K->>L: Replicate entries 3, 4, 5 in order
        E->>K: Emit covering signature 6
        K->>L: Signature 6 can commit the preceding writes
        Note over L: Prefix 2 has sig 1/data 0, prefix 3 has sig 3/data 0
        Note over L: Prefixes 4 and 5 have sig 3/data 4, not sig 1/data 4
    
    Loading

    The read inputs (signature 1, application data 4) match no ledger prefix, yet derived writes can be replicated and subsequently committed. They can therefore disagree with decisions an auditor would reproduce from the preceding ledger state. Replicas still agree on the recorded write sets: CCF replicates writes, not re-execution of the endpoint's read logic. There is no demonstrated consensus divergence or authorization bypass in this investigation.

    This violates the documented consistent, opaque view of the KV / version-to-ledger-prefix expectation.

    Governance nuance: proposal creation has a special replicated-history-root read TxID, rather than the default allocated version. That avoids a reservation which is not yet in that prefix. It is specific to proposal creation, not a blanket protection for governance: ballot submission can resolve and apply a proposal using the ordinary transaction path.

    5. How many readers?

    Counts below are direct, table-specific KV handle acquisition locations, not text matches, declarations, writers, or every caller of a shared helper.

    Current CCF source: five production raw-signature reader locations in four files.

    Location Purpose Exposed to this ordinary live-primary reservation schedule?
    history.h:601 Verify signatures while applying a deserialised signature entry Not the normal pending-signature reader: verification follows application of the received entry.
    node_state.h:117 Resolve latest signature view Used after snapshot installation and ordered public-ledger recovery, not an ordinary app transaction.
    node_state.h:2156 End public recovery at the last signed entry Recovery/startup path.
    historical_queries.h:73 Historical receipt signature extraction Reads a separately reconstructed ledger-entry store, not the live primary KV.
    node_frontend.h:833 GET /node/state, including last_signed_seqno Yes: ordinary live, no-write reads of raw and COSE tables.

    /node/state takes the maximum raw/COSE sequence number, so a mixed pair may still give a sensible latest-available sequence number. It does not construct a receipt. The demonstrated snapshot defect should not be inflated into a claim that this endpoint emits invalid proofs.

    There are also five production COSE-table reader locations, alongside the same five raw-table locations, and four production tree-table reader locations:

    Thus banning only reads of the raw table would miss a related live governance consumer and would leave the same mechanism on the COSE/tree tables.

    Two additional direct raw-table readers are in C++ tests (history and snapshot). Other test matches are writers/fixtures. The Python SDK's signature parser and Python e2e readers inspect serialised ledger entries, not live KV snapshots.

    No table-specific live readers of these three tables were found in the current repository's sample/template applications, sample constitutions, JS SDK, or test application code. That is not proof that generic access cannot reach them: for example, the permissions test endpoint accepts an arbitrary table name.

    Built-in receipts are a separate, safe path for this issue: historical receipts use per-entry reconstructed stores, and on-commit receipts use a complete, version-keyed signature-cache tuple, consumed here. Neither builds proofs by combining the primary's live signature-table snapshots.

    External applications: a ban is not usage-free

    A bounded public GitHub search verified five statically configured live raw-signature read sites in five external repositories. All are governance constitutions deriving a new member's acknowledgement state_digest from signature.root; these are copies/adaptations of the same default-governance logic, not five distinct proof-building algorithms.

    Repository Verified source
    Azure/azure-cleanroom Constitution actions, lines 413-424, build wiring.
    chinenyeokafor/minimal_ccf_image Actions, lines 451-463, startup wiring.
    peterms2021/cbdc Constitution actions, lines 376-388, startup wiring.
    maceip/steward Pinned default actions, lines 690-702, composition wiring.
    microsoft/ccfdns Test/demo constitution, lines 427-439, demo wiring.

    Three more live-style expressions were found in copied KMS helper modules, but their action closures were not verified reachable, so they are not included in the five configured consumers: microsoft/azure-privacy-sandbox-kms, datakaveri/DEPA-kms-gcp, and mayankgiri90/DEPA-kms-gcp-OIDC.

    No bespoke external application endpoint reader, live COSE/tree reader, or non-core C++/Rust consumer was verified in this search. Offline ledger parsers, recorded fixtures, and copied CCF distributions were excluded. This is a lower bound, not an ecosystem census or evidence of deployed exploitation: the search covered the first 100 results for each of the three literal table names plus Tables::SIGNATURES and ccf::Signatures, excluding microsoft/CCF, and inspected up to ten non-fork candidate repositories. Private/unindexed code and dynamically constructed names are not covered.

    6. Is preventing reads an option?

    Yes, as an API-policy mitigation, but not as a compatible blanket ban on every transaction.

    Today JS permissions explicitly grant read access to public internal tables in application and governance contexts. C++ transactions and the Rust bridge also allow named-table reads. A JS-only restriction would not fix the native paths.

    A defensible restriction would deny live transactional reads of the whole delayed signature-table family, while retaining narrowly defined framework replay/restore access and historical/offline reads. It would require migrating /node/state and the live member state-digest helper, and replacing or adapting the external constitution reads above. Banning only application endpoint reads leaves governance exposed; exempting all governance would retain the configured external readers.

    Do not simply blacklist the names in check_kv_map_access(): historical JS access calls the same helper, so that would also forbid safe historical reads. Native/framework distinctions would likewise need an explicit access mechanism rather than removing the table types.

    7. Possible solutions and tradeoffs

    Option What must change Main tradeoff / limitation
    Separate allocated and fully applied/readable versions Keep sequence allocation independent; expose only a contiguous fully applied prefix to default readers. Advance it after all writes at the missing signature slot are present, including any later local transactions already applied. Broadest repair of the KV snapshot contract. Must handle out-of-order local completion, first-table creation, multiple reservations, rollback/view epochs, and compaction. Reads may lag locally successful writes while a hole remains: preserve session read-your-writes by waiting or delaying completion/publication rather than silently serving an older prefix.
    Make reservation plus publication atomic to readers Serialize the relevant allocation/application/publication boundary, or protect the signature tables before exposing the reserved version and keep readers out until the write exists. Potentially simpler semantics, but signing can extend a critical section and reduce concurrency. Requires redesign around the existing map/version/commit lock order, nested non-recursive lock acquisitions, and a pending transaction potentially running on a different thread. Merely holding the existing replication commit_lock or signature-emitter lock is insufficient.
    Track pending signature slots and block/retry affected reads When a live reader's version includes an unfilled signature slot, defer or reject affected change-set acquisition, including absent tables, and synchronise the check with slot completion. More targeted repair, retaining parallel application writes. Cover all three tables, arbitrary access APIs, multiple slots and rollback. A check at get() alone can be too late if the handle already captured old state. Waiting while holding locks or occupying the worker needed to finish the signature risks deadlock/starvation; an immediate retry loop can also exhaust retries before progress.
    Remove live table access from the supported API Restrict live app/governance reads; provide version-coherent signature/root metadata or receipt APIs for the legitimate consumers. Preserve replay/restore/historical access. Feasible mitigation, but a documented compatibility change with known external governance consumers. An out-of-transaction "latest committed/available signature" API needs explicit semantics; it must not masquerade as the calling transaction's KV snapshot.
    Strengthen validation as defence in depth Consider read-only validation and a pending-slot check, not just ordinary key-version checks. Read-only prepare() alone is not a full fix: it still passes if the signature has not yet materialised, and writers can still validate/apply in the gap. Checking returned sig.seqno <= R is also insufficient. Validation after execution does not prevent erroneous branching/errors on an inconsistent snapshot.

    The first three are correctness fixes if implemented end-to-end. Restricting live reads is a viable mitigation or alternative API design, not a claim that the underlying version-frontier invariant is sound.

    Taking the three signature-table snapshots together would prevent torn bundles, but would not by itself fix future read-version labels or old-signature/new-application inputs. Switching to COSE-only removes the raw-table update, not the COSE/tree race. Moving allocation until after signing is not a free reorder either: the COSE signature includes the TxID, and the signature/tree must cover exactly the ledger prefix ending at S - 1, so the version/history boundary must remain coordinated.

    Using the replicated-history index as the read version looks tempting, but it should not be a one-line substitution: the existing proposal-root path warns about session consistency, and session read-your-writes is a documented guarantee.

    8. Reproduction and regression cases

    A session-local C++ probe was built against the unmodified ccf_kv at the revision above, using NullTxEncryptor, StubConsensus, and the actual next_txid(), ReservedTx::commit_reserved(), normal transaction commit, and ordered deserialisation paths. Synthetic raw-signature values and COSE/tree byte markers identify table generations; this probes KV isolation, not cryptographic validity or a deployed consensus network. No production or repository test source was modified.

    The deterministic observations were:

    • Two no-write transactions reporting read TxID 1.3 returned raw signature sequence numbers 1 and 3, respectively.
    • The first reader retained raw signature 1 but obtained tree/COSE generation 3 through later table handles; its no-write commit succeeded.
    • A dependent writer prepared after materialisation returned FAIL_CONFLICT, with no write applied.
    • A dependent writer prepared before materialisation succeeded at version 5, storing inputs (signature 1, application data 4). Those writes survived ordered replay; no replayed prefix contained that input pair, including after a subsequent covering signature.
    • Pinning the snapshot before reservation consistently returned the earlier signature and tree.
    • For a first signature, a plain read-only handle retained absence, while a CommittableTx committed after real table creation hit the new-map conflict check.

    These are the core regression schedules for any fix. Extend them to COSE-only mode, multiple pending signature slots, rollback/view changes, first-signature/table creation, compaction, arbitrary JS/native access, early handler error paths, and sequential requests on one session when later application writes are already locally successful.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions