Skip to content

[Security] Verifier AssertSent skip is one-shot and unbound when pubin is late (unauthorized Take2) #430

Description

@asbestos22

Summary

handle_assert_sent_verifier is the automated defense against a bad OperatorAssert. When connector_e is unspent it never runs validate_pubin_disprove. It then skips ChallengeAssert if Groth16 verifies on [static_input] only.

static_input is the global operator VK hash + Ziren version. x_d is whatever 32 bytes the operator put in this graph’s assert WOTS. Guest (blockhash, constant, included_watchtowers) is not decoded. Off-chain get_operator_proof already rejects outputs.constant != statement.constant. The on-chain skip does not.

PubinDisprove does bind this graph’s constant and x_d == blake3(guest pubin). That script only runs if connector_e is already spent when AssertSent is handled. Honest nodes commit pubin before Assert. A malicious operator can invert that:

  1. Broadcast OperatorAssert first (WOTS key is local; operator_sign_assert spends connector_c, independent of connector_e).
  2. Stock verifier: connector_e unspent → skip pubin check → Groth16 true on a replayed proof → return Ok(()).
  3. Local queue marks AssertSent Processed. Later ticks still detect AssertSent, but upsert_message(..., is_update: false) will not recreate {graph_id}_AssertSent.
  4. Commit pubin before the commit-timeout CSV (Bitcoin 432 after WCI). That spends connector_e, so handle_operator_commit_pubin_timeout_verifier returns without sending OperatorCommitTimeout (E+F).
  5. No handler re-runs validate_pubin_disprove after a late pubin. handle_take2_ready_operator only checks Challenge status, unspent inputs, and CSV. Take2 after assert+432 and WCI+576.

Not proven here: live Assert / Take2 / BTC leaving the peg. This is a complete code path on stock gc-v2, not a live steal. First graph on a fresh VK cannot replay (needs a prior valid operator-proof for this VK).

A committee verifier that was offline at AssertSent and first comes online after the late pubin, with an empty local DB, would create AssertSent and slash via PubinDisprove. Deployed verifiers keep persistent DBs. Same caveat as #429.

Affected component

GOATNetwork/bitvm-node gc-v2 — Bridge State Machine (verifier challenge path).

  • node/src/handle.rs handle_assert_sent_verifier — pubin check gated on connector_e already spent; Groth16 skip on [derive_operator_static_input()] only
  • node/src/utils.rs derive_operator_static_input / operator_identity — VK + Ziren, not hash_operator_constant
  • bitvm-gc TxAssertWitness::verify_groth16_proof — Groth16 on [static_input, x_d]
  • node/src/action.rsOk(()) → message Processed
  • node/src/utils.rs upsert_message(..., is_update: false) — will not recreate {graph_id}_AssertSent
  • node/src/scheduled_tasks/graph_maintenance_tasks.rs detect_assert_sent_flow — re-detects every Challenge tick; upsert still no-ops
  • handle_operator_commit_pubin_timeout_verifier — returns if connector_e or connector_f already spent
  • handle_take2_ready_operator — no L2 withdraw / no statement check
  • Honest AssertReady is only detected after pubin_commit_completed; malicious operator does not use that path (operator_sign_assert is local)

Checked on gc-v2. Not #410 (witness format / recover-and-verify, not graph-constant bind), not #416 (circuit public-values bind), not #418, not #429 (KickoffSent / Take1).

Mechanism

// handle_assert_sent_verifier
if let Ok(connector_e_input) = graph.watchtower_challenge_init.connector_e_input() {
    // only if connector_e is ALREADY spent:
    if let Some(commit_pubin_txid) = outpoint_spent_txid(...) {
        match validate_pubin_disprove(...) { /* slash or proceed */ }
    }
}
// falls through when connector_e is unspent
if assert_witness.verify_groth16_proof(&vk, &[static_input]) {
    tracing::info!("Skip ChallengeAssert ... operator assert proof is valid");
    return Ok(()); // no defer
}

operator_sign_assert spends kickoff connector_c. operator_sign_commit_pubin spends WCI connector_e. Independent UTXOs. Invert-order is allowed on-chain.

Bitcoin pubin_disprove_scripts.rs: x_d is the guest-pubin commitment. Replay of graph A’s proof into graph B:

Later pubin If PubinDisprove ran
This graph’s 96 bytes blake3 ≠ A’s x_d → slash
A’s 96 bytes A’s constant ≠ baked hash_operator_constant → slash

It does not run after the skip.

Steps (code path)

  1. Graph is committee-presigned. Challenge flow complete. connector_e unspent.
  2. Operator already produced a valid operator-proof on an earlier graph for this VK. Replays (π, x_d) into this graph’s assert WOTS and broadcasts OperatorAssert.
  3. detect_assert_sent_flow enqueues AssertSent.
  4. Each online verifier: connector_e unspent; Groth16([static_input], x_d) true; skip ChallengeAssert; Ok(()).
  5. Message → Processed. Restart / later ticks do not recreate it on that node.
  6. Operator commits some 96-byte pubin before WCI+432. Timeout input connector_e is spent → timeout never broadcasts.
  7. Wait ConnectorD / ConnectorF CSV.
  8. Operator signs Take2 (n-of-n already pre-signed) and broadcasts.

Impact (proven vs not)

Proven (source)
Stock online verifier skips ChallengeAssert on a Groth16 that is not bound to this graph’s constant, when Assert is confirmed before pubin. That skip is one-shot. A later pubin disables OperatorCommitTimeout. Take2 has no statement check.

Not proven
Live Assert / Take2 / BTC moved / undercollateralized peg on testnet or mainnet.

Suggested fix

Do not skip ChallengeAssert unless pubin is on chain and validate_pubin_disprove returns None (consistent with this graph’s baked constant). If connector_e is unspent: defer (push_local_unhandled_messages_with_reason), do not Ok(())/Processed. After a late pubin, re-run the pubin check (or detect a PubinCommitted message). Optionally pass hash_operator_constant into the Groth16 public inputs so skip cannot succeed on a foreign x_d.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions