Skip to content

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

Open
salazarsebas wants to merge 3 commits into
boundlessfi:testnetfrom
salazarsebas:fix/op-id-domain-separated-derivation
Open

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

Conversation

@salazarsebas

@salazarsebas salazarsebas commented Jul 21, 2026

Copy link
Copy Markdown

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.

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)

1 participant