feat(realtime): encrypt proposal blobs and consume forced inclusions#958
feat(realtime): encrypt proposal blobs and consume forced inclusions#958AnshuJalan wants to merge 8 commits into
Conversation
|
@claude review |
|
Claude finished @AnshuJalan's task in 3m 0s —— View job PR Review: feat(realtime): encrypt proposal blobs and consume forced inclusionsTodo:
Overall this is a clean, well-structured addition. The AES-256-GCM crypto layer itself is correct, the fast-fail config validation is good, and the forced-inclusion wiring is straightforward. There is one critical correctness bug in privacy mode, one security hygiene issue with the key in Debug output, and a few smaller items below. Critical: Double-Encryption Nonce Mismatch in Privacy ModeThis is the most important finding. When Path 1 — let blob_payload = ethereum_l1.execution_layer.proposal_cipher().wrap(&manifest_data)?;
let sidecar_builder: SidecarBuilder<BlobCoder> = SidecarBuilder::from_slice(&blob_payload);
// blob_hashes from this sidecar go to Raiko → proof binds to these hashesPath 2 — let blob_payload = self.cipher.wrap(&manifest_data)?;
// nonce_2 ≠ nonce_1 → blob_hash_2 ≠ blob_hash_1Raiko's proof commits to Fix: Encrypt the manifest once (in // Proposal struct gains an optional pre-encrypted blob payload
pub struct Proposal {
// ...
pub encrypted_blob_payload: Option<Vec<u8>>,
}Then Security:
|
| Severity | Finding |
|---|---|
| 🔴 Critical | Double-encryption: async_submitter.rs and proposal_tx_builder.rs independently call cipher.wrap(), generating mismatched blob hashes in privacy mode |
| 🟡 Medium | RealtimeConfig derived Debug exposes the raw AES key bytes |
| 🟡 Medium | as u16 cast violates cast_possible_truncation = deny workspace lint |
| 🟢 Info | Both CodeQL nonce alerts are false positives |
| 🟢 Minor | FI state failure silently proceeds to a likely-reverting L1 call |
| 🟢 Nit | Stale #[allow(clippy::too_many_arguments)] on build_propose_blob |
|
|
||
| - uses: docker/login-action@v3 | ||
| - name: Login to JFrog Artifactory | ||
| uses: docker/login-action@v3 |
| steps: | ||
| - uses: docker/login-action@v3 | ||
| - name: Login to JFrog Artifactory | ||
| uses: docker/login-action@v3 |
Previously async_submitter and proposal_tx_builder each independently called build_blob_payload (which calls cipher.wrap, which mints a fresh random AES-GCM nonce). In privacy mode this produced two different ciphertexts, two different blob hashes, and a Raiko proof that committed to a hash the L1 transaction could never reproduce — every privacy-mode propose call would have reverted. Fix: store the blob_payload that async_submitter computes (just before the Raiko request) on Proposal. proposal_tx_builder consumes that field when building the L1 sidecar, falling back to compute-on-the-fly only when blob_payload is absent (safe in plaintext mode where the output is deterministic; logs a warning if the cipher is enabled, since the mismatch would only show up at L1-revert time). Caught by Claude PR review on #958. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five smaller items from the #958 review left after the critical double-encryption fix: - Manual Debug impl on RealtimeConfig that redacts privacy_symmetric_key, raiko_api_key (replaces the derived Debug, which would print raw key bytes if a config was ever logged via {:?}). - Display now reports whether the privacy key is set (set/unset), matching the existing privacy_mode line, for clearer startup logs. - execution_layer: replace `as u16` cast (which the workspace lint cast_possible_truncation = deny would have rejected) with u16::try_from + a fallback to fi_max_per_proposal (the value is bounded but the compiler can't prove it). - execution_layer: surface FI-state RPC failure as Err to the caller instead of silently falling back to numForcedInclusions = 0. The old behavior would burn blob-tx gas on a guaranteed-revert with UnprocessedForcedInclusionIsDue when there were due FIs; skipping the slot is cheaper and lets the next tick retry. - Drop stale #[allow(clippy::too_many_arguments)] from build_propose_tx and build_propose_blob (now 4 args each, well under the threshold). Plus cargo fmt formatting fixups. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the realtime proposer for the privacy stack and re-enables forced inclusion consumption against the new RealTimeInbox queue. Encryption: new realtime/src/privacy/ with ProposalCipher (AES-256-GCM with a fresh CSPRNG nonce per blob). Applied at both manifest-building sites — the L1 proposal builder and the async Raiko-request builder — so the bytes Raiko sees match the bytes hashed on L1. Catalyst does NOT decrypt; FI blobs are encrypted off-system to PK_sys by their submitters. Forced inclusion: ExecutionLayer reads getForcedInclusionState() each propose tick, computes numForcedInclusions = min(pending, fi_max_per_proposal), and threads it into ProposeInput / ProposeInputV2. The contract enforces consumption of any "due" FI; if the cap is below the due count, the propose call reverts with UnprocessedForcedInclusionIsDue. Config: SURGE_PRIVACY_MODE, SURGE_PRIVACY_SYMMETRIC_KEY, FI_MAX_PER_PROPOSAL new env vars on RealtimeConfig. Privacy mode without a key fails fast at startup. ABI regenerated to expose the new contract methods. Tests: 5 new privacy round-trip tests (random nonce, fresh-nonce-per-call, plaintext fallback). Companion PRs: surge-taiko-mono (protocol + driver) and raiko (prover decryption). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The first cut emitted [scheme || frame(64) || compressed_manifest] which caused raiko's blob_tx_slice_param_for_source to read the scheme byte as the first byte of the bytes32(version) field, fail the version check, and return None. The driver's ExtractVersionAndSize did the same. Both fell back to default-empty manifests but with diverging block params, tripping the validate_input_block_param assertion inside the Zisk guest. New helper build_blob_payload re-emits the Shasta frame around the cipher-wrapped inner: [bytes32(1) || bytes32(inner_len) || scheme || body]. Both the L1 proposal-tx builder and the async Raiko-request submitter call through the same helper so the L1 blob hash and the bytes Raiko sees stay identical. Three new tests cover the round-trip layout (plaintext, encrypted, short-input rejection); the prior 5 still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…or_source Inlines a copy of raiko's parser (raiko/lib/src/input.rs:442) and asserts build_blob_payload's output passes the version + size frame check, then yields a slice that begins with the scheme byte. This is the exact gate that rejected the original buggy payload (captured in surge-taiko-mono/blob-payloads-share/04-blob-after-decode_blob_data.hex where decoded[0..] started with 0x01 b0 b9 78 ... — scheme byte at the wrong offset). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously async_submitter and proposal_tx_builder each independently called build_blob_payload (which calls cipher.wrap, which mints a fresh random AES-GCM nonce). In privacy mode this produced two different ciphertexts, two different blob hashes, and a Raiko proof that committed to a hash the L1 transaction could never reproduce — every privacy-mode propose call would have reverted. Fix: store the blob_payload that async_submitter computes (just before the Raiko request) on Proposal. proposal_tx_builder consumes that field when building the L1 sidecar, falling back to compute-on-the-fly only when blob_payload is absent (safe in plaintext mode where the output is deterministic; logs a warning if the cipher is enabled, since the mismatch would only show up at L1-revert time). Caught by Claude PR review on #958. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five smaller items from the #958 review left after the critical double-encryption fix: - Manual Debug impl on RealtimeConfig that redacts privacy_symmetric_key, raiko_api_key (replaces the derived Debug, which would print raw key bytes if a config was ever logged via {:?}). - Display now reports whether the privacy key is set (set/unset), matching the existing privacy_mode line, for clearer startup logs. - execution_layer: replace `as u16` cast (which the workspace lint cast_possible_truncation = deny would have rejected) with u16::try_from + a fallback to fi_max_per_proposal (the value is bounded but the compiler can't prove it). - execution_layer: surface FI-state RPC failure as Err to the caller instead of silently falling back to numForcedInclusions = 0. The old behavior would burn blob-tx gas on a guaranteed-revert with UnprocessedForcedInclusionIsDue when there were due FIs; skipping the slot is cheaper and lets the next tick retry. - Drop stale #[allow(clippy::too_many_arguments)] from build_propose_tx and build_propose_blob (now 4 args each, well under the threshold). Plus cargo fmt formatting fixups. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ba8d39d to
9cd3364
Compare
…to latest when push to master
Summary
Wires the realtime proposer for the cross-stack privacy feature and re-enables forced-inclusion consumption against the new
RealTimeInboxqueue.realtime/src/privacy/withProposalCipher(AES-256-GCM with a freshOsRngnonce per blob). Applied at both manifest-building sites — the L1 proposal builder and the async Raiko-request builder — so the bytes Raiko sees match what's hashed on L1.ExecutionLayer.send_batch_to_l1readsgetForcedInclusionState()each propose tick, computesnumForcedInclusions = min(pending, fi_max_per_proposal), and threads it intoProposeInput/ProposeInputV2. The contract reverts withUnprocessedForcedInclusionIsDueif the cap is below the due count.SURGE_PRIVACY_MODE,SURGE_PRIVACY_SYMMETRIC_KEY,FI_MAX_PER_PROPOSALnew env vars onRealtimeConfig. Privacy mode without a key fails fast at startup. ABI regenerated to expose the new contract methods.PK_sys.Companion PRs:
See
PRIVACY_STACK.mdfor the cross-stack design reference.