Skip to content

fix(platform-wallet-ffi): wallet_id gate on resolver-fed sign entrypoints#3735

Draft
Claudius-Maginificent wants to merge 1 commit into
v3.1-devfrom
fix/platform-wallet-ffi-sign-gate-wallet-id
Draft

fix(platform-wallet-ffi): wallet_id gate on resolver-fed sign entrypoints#3735
Claudius-Maginificent wants to merge 1 commit into
v3.1-devfrom
fix/platform-wallet-ffi-sign-gate-wallet-id

Conversation

@Claudius-Maginificent
Copy link
Copy Markdown
Collaborator

Issue being fixed or feature implemented

Four FFI sign entrypoints in rs-platform-wallet-ffi take a MnemonicResolverHandle plus a wallet_id_bytes arg and derive signing material from the mnemonic the resolver returns. None of them previously checked that the seed actually belongs to that wallet_id. A host that called any of them with a mismatched (wallet_id, mnemonic) pair — whether by host bug or by a malicious resolver — would happily derive and sign with the wrong key.

This patch closes that gap.

It also unblocks the seedless-load model in PR #3692: that PR removes a load-time wrong-seed gate, so the sign-time gate has to exist before that change can land without regressing wrong-seed protection. Decoupled here so the security fix can ship to v3.1-dev on its own merits, ahead of the rehydration model rework.

What was done?

New helper: crate::sign_gate::verify_seed_matches_wallet_id

packages/rs-platform-wallet-ffi/src/sign_gate.rs (new):

#[inline]
#[must_use]
pub fn verify_seed_matches_wallet_id(
    root_pub: &ExtendedPubKey,
    expected_wallet_id: &[u8; 32],
) -> bool {
    let root_extended = RootExtendedPubKey::from_extended_pub_key(root_pub);
    let derived = Wallet::compute_wallet_id_from_root_extended_pub_key(&root_extended);
    bool::from(derived.ct_eq(expected_wallet_id))
}
  • Constant-time comparison via subtle::ConstantTimeEq — no early-return on byte mismatch.
  • #[must_use] so a caller can't silently drop the verdict.
  • Operates on the public half of the root extended key — no private material flows into the comparator.
  • New dep: subtle = "2" (well-known constant-time-comparison crate, single line added to Cargo.lock, no transitive bumps).

New result code: ErrorWrongSeedForWallet = 13

PlatformWalletFFIResultCode::ErrorWrongSeedForWallet = 13 in error.rs. Previously unoccupied slot (the enum jumped ErrorUtf8Conversion = 12 → NotFound = 98).

For the byte-tagged sign_with_mnemonic_resolver_and_path path, a new u8 tag SIGN_WITH_RESOLVER_ERR_WRONG_SEED = 11 is also added in that module.

Gate wired into 4 entrypoints

Entrypoint File Gate location
dash_sdk_sign_with_mnemonic_resolver_and_path sign_with_mnemonic_resolver.rs After master derivation, before derive_priv
dash_sdk_derive_and_persist_identity_keys identity_derive_and_persist.rs Before persister callback ever fires
dash_sdk_derive_identity_key_at_slot_with_resolver derive_identity_key_at_slot.rs Before derive_at_slot_inner
platform_wallet_manager_bind_shielded (shielded feature) shielded_sync.rs Before PLATFORM_WALLET_MANAGER_STORAGE touch

At each site, on a mismatch:

  • master.private_key.non_secure_erase() — derived master xpriv zeroed
  • Caller-owned output buffer wiped (sig_buf zeroed via ptr::write_bytes; out_row left at IdentityKeyPreviewFFI::empty(); *out_signature_len = 0)
  • Typed error returned (ErrorWrongSeedForWallet for structured paths, SIGN_WITH_RESOLVER_ERR_WRONG_SEED for the u8-tagged path)
  • Persister callback / coordinator handoff never reached

Two of the four sites (derive_identity_key_at_slot, shielded_sync) non_secure_erase the gate-master unconditionally — defense in depth, since the gate-master is throwaway whether the verdict is true or false. The other two keep the master alive past the gate because the same master is then used for derive_priv and is erased downstream via the existing pattern.

How Has This Been Tested?

cargo fmt --all -- --check
cargo clippy -p platform-wallet-ffi --all-targets -- -D warnings
cargo clippy -p platform-wallet-ffi --all-targets --features shielded -- -D warnings
cargo check --workspace
cargo test -p platform-wallet-ffi
cargo test -p platform-wallet-ffi --features shielded shielded_sync
cargo test -p platform-wallet           # workspace regression sanity

All green. 83 unit + 26 comprehensive + 5 integration tests pass; 132 platform-wallet tests still pass; new gate tests included:

Test Asserts
sign_gate::tests::matching_seed_passes CT comparator returns true on matching seed
sign_gate::tests::mismatched_seed_fails CT comparator returns false on distinct seeds
sign_with_mnemonic_resolver::tests::happy_path_signs_and_returns_signature sig_len == 65 (compact-recoverable secp256k1)
sign_with_mnemonic_resolver::tests::wrong_wallet_id_fails_closed_with_wrong_seed_tag rc=-1, tag=11, sig_len==0, every byte of 128-byte sig_buf is zero
identity_derive_and_persist::tests::happy_path_persists_three_keys_and_returns_pubkeys gate doesn't false-positive on matching seed
identity_derive_and_persist::tests::wrong_wallet_id_fails_closed_before_persisting_anything code=ErrorWrongSeedForWallet, out.count==0, out.items.is_null(), persister callback row count is zero
derive_identity_key_at_slot::tests::matching_seed_returns_derived_key DIP-9 path, 33-byte pubkey, non-null WIF, non-zero private scalar
derive_identity_key_at_slot::tests::wrong_wallet_id_fails_closed_with_wrong_seed_tag all output fields null/zero, every byte of private_key_bytes is zero
shielded_sync::tests::wrong_wallet_id_fails_closed code=ErrorWrongSeedForWallet via NULL_HANDLE — gate short-circuits before manager-storage touch
shielded_sync::tests::null_resolver_handle_rejected check_ptr! ordering locked in

Every wrong-seed test asserts on substance — typed tag + output-buffer state + side-effect counters — not "did the function run".

Breaking Changes

None for shipped consumers. The change is purely additive:

  • New helper module (does not modify existing public APIs).
  • New result code variant at a previously unoccupied enum slot.
  • Gate insertions return new error tags only on the wrong-seed mismatch path — flows with correct (wallet_id, mnemonic) pairs behave identically to before.

No ! in the title.

Security note

This patch is logically the prerequisite for PR #3692 (seedless watch-only rehydration), which removes a load-time wrong-seed gate. Without this PR landed first, #3692 would briefly create a window where the same wallet_id could be signed by any seed. Once this lands, the merge-up cycle (v3.1-dev → #3625 → #3672 → #3692) will dedupe the duplicate sign-gate commits sitting on #3692 naturally — no force-push to that PR needed.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

🤖 Co-authored by Claudius the Magnificent AI Agent

…ints

Four FFI sign entrypoints take a MnemonicResolverHandle + wallet_id_bytes
and derive signing material from the resolver-supplied mnemonic. None of
them previously checked that the seed actually belongs to wallet_id_bytes
— a host calling them with a mismatched (wallet_id, mnemonic) pair would
happily derive and sign with the wrong key.

This patch closes that gap:

- New crate::sign_gate::verify_seed_matches_wallet_id(root_pub, expected_wallet_id) -> bool
  (constant-time via subtle::ConstantTimeEq, #[must_use]).
- New PlatformWalletFFIResultCode::ErrorWrongSeedForWallet = 13 for the
  structured-result entrypoints.
- New SIGN_WITH_RESOLVER_ERR_WRONG_SEED u8 tag for the byte-tagged path.

Wired into:
1. sign_with_mnemonic_resolver::dash_sdk_sign_with_mnemonic_resolver_and_path
2. identity_derive_and_persist::dash_sdk_derive_and_persist_identity_keys
3. derive_identity_key_at_slot::dash_sdk_derive_identity_key_at_slot_with_resolver
4. shielded_sync::platform_wallet_manager_bind_shielded (shielded feature)

Each call site: invokes the gate BEFORE any derive_priv / signing /
persister callback / coordinator handoff; on mismatch zeroes any derived
master key (master.private_key.non_secure_erase()), zeros any caller-owned
output buffer (e.g. out_signature), and returns the typed tag — the
persister callback is never reached, no signature bytes leak.

Tests (co-located in each entrypoint's source under #[cfg(test)] mod tests):
- sign_with_mnemonic_resolver: happy_path_signs_and_returns_signature +
  wrong_wallet_id_fails_closed_with_wrong_seed_tag (asserts every byte of
  sig_buf is zero on mismatch).
- identity_derive_and_persist: wrong_wallet_id_fails_closed_before_persisting_anything
  (asserts persister callback row count is zero).
- derive_identity_key_at_slot: matching_seed_returns_derived_key +
  wrong_wallet_id_fails_closed_with_wrong_seed_tag.
- shielded_sync (gated on `shielded` feature): wrong_wallet_id_fails_closed +
  null_resolver_handle_rejected.
- sign_gate::tests: helper unit tests for the CT comparison.

Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added this to the v3.1.0 milestone May 25, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 25, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 916a4d2d-1fa3-4be6-9707-8ed7e96b732d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/platform-wallet-ffi-sign-gate-wallet-id

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 and usage tips.

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.

2 participants