Skip to content

fix: non-malleable child op_id derivation and namespaced OpSeen - #95

Merged
0xdevcollins merged 3 commits into
boundlessfi:testnetfrom
salazarsebas:fix/op-id-domain-separated-derivation
Jul 23, 2026
Merged

fix: non-malleable child op_id derivation and namespaced OpSeen#95
0xdevcollins merged 3 commits into
boundlessfi:testnetfrom
salazarsebas:fix/op-id-domain-separated-derivation

Conversation

@salazarsebas

@salazarsebas salazarsebas commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes a HIGH severity payout DoS: XOR-derived child op_ids were malleable, and profile OpSeen lived in a single global namespace that unprivileged bootstrap_self could squat.

Closes #68

Problem

  1. derive_child / derive_child_indexed in boundless-events XORed a 1-byte tag (and optional sub-index) into the parent op_id. XOR is reversible and collision-friendly.
  2. Profile OpSeen was keyed only by op_id (DataKey::OpSeen(BytesN<32>)), shared by privileged events-originated mutations and unprivileged bootstrap_self.
  3. Attack path: observe a pending parent op_id, recompute deterministic children (BOOTSTRAP / BUMP_REP / REGISTER_EARNINGS), front-run bootstrap_self(attacker, child_id), then the events→profile call hits OpAlreadySeen and blocks reputation / earnings (and hard-fails grant paths that do not use try_).

Scout incorrect_exponentiation flags on ^= were false positives (intentional XOR). Removing XOR clears those flags by deleting the malleable construction.

Fix

Events — domain-separated SHA-256 child ids

child = sha256(parent ‖ op_tag ‖ sub_idx ‖ callee_contract_id)
  • callee_contract_id is the configured profile contract address (XDR-encoded domain separator).
  • derive_child uses sub_idx = 0; derive_child_indexed keeps the explicit index.
  • Host env.crypto().sha256 keeps WASM well under the 64 KB CI ceiling.

Profile — namespaced OpSeen

DataKey::OpSeen(domain: Address, op_id: BytesN<32>)
Entry point Domain
bootstrap / bump_reputation / slash_reputation / register_earnings configured events contract
bootstrap_self calling user
admin_slash admin address

Unprivileged bootstrap_self can no longer mark the events domain, so front-running child ids is useless against legitimate orchestration.

OpSeen remains temporary() storage; no persistent migration required.

Tests

New module contracts/events/src/tests/op_id_security.rs:

Test Asserts
sha256_child_ids_differ_for_xor_colliding_parents Distinct parents / tags / indices produce distinct children
bootstrap_self_cannot_front_run_events_child_op_ids Attacker pre-marks child ids via bootstrap_self; claim_prize still pays and updates reputation + earnings
events_domain_child_op_id_replay_still_rejected True replay of the same events-domain op_id still fails

Local verification

cargo test --release
# boundless-events: 219 passed
# boundless-profile: 66 passed

cargo fmt -- --check  # clean

WASM sizes:
  boundless_events.wasm:  55622 bytes
  boundless_profile.wasm: 15893 bytes
# both under 65536 ceiling

Risk notes

  • Child op_id values change for any in-flight parent (testnet-acceptable; not a client API break).
  • Temporary OpSeen key shape changes; existing temporary entries simply expire under the old layout.
  • No public entrypoint signature changes.

Checklist

  • Root cause addressed (malleable derivation + global OpSeen)
  • Acceptance tests from the issue
  • Full package test suites green
  • rustfmt clean
  • WASM under 64 KB

Summary by CodeRabbit

  • Security Improvements

    • Strengthened operation identity generation to prevent collisions and manipulation.
    • Prevented attackers from pre-claiming identifiers used by legitimate prize claims.
    • Preserved rejection of genuine replayed operations.
  • Bug Fixes

    • Improved idempotency tracking by separating operations across contract domains.
    • Ensured profile bootstrap, earnings, reputation, and event operations are tracked independently.

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e4e39c6e-4269-4c8b-82ae-110feaca07a7

📥 Commits

Reviewing files that changed from the base of the PR and between 246d307 and 734b6c0.

📒 Files selected for processing (9)
  • contracts/events/src/idempotency.rs
  • contracts/events/src/tests/mod.rs
  • contracts/events/src/tests/op_id_security.rs
  • contracts/profile/src/bootstrap.rs
  • contracts/profile/src/earnings.rs
  • contracts/profile/src/idempotency.rs
  • contracts/profile/src/reputation.rs
  • contracts/profile/src/storage.rs
  • contracts/profile/src/types.rs

📝 Walkthrough

Walkthrough

The change hardens cross-contract idempotency by hashing events child operation IDs with a profile-contract domain separator and by scoping profile replay markers to domains. New tests cover collision resistance, front-running resistance, and true replay rejection.

Changes

Idempotency hardening

Layer / File(s) Summary
Domain-scoped idempotency storage
contracts/profile/src/types.rs, contracts/profile/src/storage.rs
OpSeen storage keys now include both a domain address and operation ID.
Profile flow domain wiring
contracts/profile/src/idempotency.rs, contracts/profile/src/bootstrap.rs, contracts/profile/src/earnings.rs, contracts/profile/src/reputation.rs
Profile operations use events-specific or user-specific idempotency domains for checks and completion markers.
Collision-resistant child IDs and regression coverage
contracts/events/src/idempotency.rs, contracts/events/src/tests/*
Child IDs are SHA-256 digests over parent and domain data; tests cover crafted collisions, front-running, and replay rejection.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Attacker
  participant EventsContract
  participant ProfileContract
  participant ProfileStorage
  EventsContract->>EventsContract: derive hashed child op_ids
  Attacker->>ProfileContract: bootstrap_self(attacker, child op_id)
  ProfileContract->>ProfileStorage: store under attacker domain
  EventsContract->>ProfileContract: claim_prize with child op_ids
  ProfileContract->>ProfileStorage: check events domain
  ProfileContract-->>EventsContract: apply legitimate profile updates
Loading

Possibly related PRs

Suggested reviewers: 0xdevcollins

Poem

I’m a rabbit guarding IDs in the night,
Hashing each child till the paths fit right.
Domains keep markers safely apart,
Replay gets stopped at the contract’s heart.
Claims now hop through without a snare—
Secure little burrows everywhere!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the two main changes: non-malleable child op_id derivation and namespaced OpSeen storage.
Linked Issues check ✅ Passed The PR matches issue #68 by replacing XOR derivation with SHA-256 domain separation, namespacing OpSeen, and adding the requested regression tests.
Out of Scope Changes check ✅ Passed The changes all support the idempotency/security fix and do not introduce clear unrelated scope.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@0xdevcollins
0xdevcollins merged commit 24cade1 into boundlessfi:testnet Jul 23, 2026
4 checks passed
0xdevcollins added a commit that referenced this pull request Jul 23, 2026
…95) (#96)

#95 namespaced the profile OpSeen but left the events contract's OpSeen
global. Permissionless entrypoints (submit, apply_to_bounty, add_funds)
mark caller-supplied op_ids in the same namespace as privileged payout
paths (select_winners, claim_prize, claim_milestone, start_cancel), so an
attacker who observes a pending privileged op_id could front-run a
permissionless call with it and revert the payout with OpAlreadySeen —
the same cross-flow squat DoS #95 fixed next door.

Key OpSeen by (authorizing caller, op_id): DataKey::OpSeen(Address,
BytesN<32>). Each entrypoint passes the address it require_auth'd; the two
permissionless cranks (process_cancel_batch, finalize_cancel) namespace
under the contract's own address. A permissionless call now writes its own
domain and can't burn a privileged op_id.

OpSeen is temporary() storage, so the key-shape change needs no migration;
old-shape entries expire. No public entrypoint signatures change. op_id
idempotency is now per-domain: cross-caller reuse of the same op_id is
allowed (and safe), true same-domain replay still reverts.

Tests: op_id_security::permissionless_apply_cannot_squat_select_winners_op_id
(attacker's apply with the owner's op_id no longer blocks the payout);
prize_claim::op_id_replay_reverts reworked to a same-domain replay.
225 events + 66 profile green; make build OK (events 56,091 B < 64 KB);
fmt clean.
0xdevcollins added a commit that referenced this pull request Jul 23, 2026
…ractmeta (#98)

Version stamps had drifted and were internally inconsistent: events
contractmeta said 1.2.0 while INITIAL_VERSION said 1.3.0, and neither
reflected the public-surface / storage changes merged since (#86 submission
cap, #88 manager two-step, #96 OpSeen namespacing). Profile was still 1.1.0
despite #95 namespacing its OpSeen.

Bump both contracts coherently — INITIAL_VERSION, contractmeta, and the
Cargo package version all set to:
  events  1.3.0 -> 1.4.0   (submission cap, manager two-step, OpSeen ns)
  profile 1.1.0 -> 1.2.0   (namespaced OpSeen)

version()-asserting admin tests updated to match. 225 events + 66 profile
tests green; make build OK (events 56,091 B, profile 15,893 B, both under
the 64 KB ceiling); fmt clean.
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.

op_id child derivation is XOR-malleable and profile OpSeen is squattable (payout DoS)

2 participants