Skip to content
Open
5 changes: 3 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ The Market lists names for sale through Shakedex, read from the LearnHNS Market

#### 3. Signing an input the wallet does not own

**Mitigation:** The seller's lock coin is a foreign input carrying its own witness; the wallet signs only its own inputs. A Ledger signs every input as the wallet's own P2WPKH, so a plan with a foreign input, a custom sequence or a lock time is refused twice: when the draft is signed (`commands::tx`) and in the Ledger signer itself (`providers::ledger::signing`).
**Mitigation:** The seller's lock coin is a foreign input carrying its own witness; the wallet signs only its own inputs. A Ledger signs every input as the wallet's own P2WPKH, so a plan with a foreign input, a custom sequence or a lock time is refused twice: when the draft is signed (`commands::tx`) and in the Ledger signer itself (`providers::ledger::signing`), which sees only the plan's shape. Draft signing refuses every Shakedex draft for a Ledger by its action as a class (`shakedex::is_shakedex_action`), because the FINALIZE into a lock is an ordinary plan the device signer cannot tell from any other; a plan with a lock-key or foreign input is refused by its shape. A lock coin of ours is spent only by a cancel, signed with the lock key, which the signer derives from the seed at that moment and never stores. The signer refuses an input that is both foreign and lock-key (`sign_plan_tx`), and refuses a lock-key input unless it is a `0x83` (`ANYONECANPAY|SINGLE`) input with a final sequence in a plan with lock time 0, from a receive-branch path at an unhardened index, whose output at the same index is a TRANSFER at that key's lock address with exactly 4 items, of this name (item 0 is the name hash), to a version-0 address (item 2 is `00`) whose hash (item 3) is our own receive address re-derived from the input's path (`noncustodial/shakedex/cancel.rs::check_lock_key_input`).

#### 4. The daemon and purchases

Expand All @@ -265,7 +265,8 @@ The Market lists names for sale through Shakedex, read from the LearnHNS Market
| Audit log leaks secrets | Redacted to `***` on write; re-redacted on read | `settings.rs:40-41, 68-69` | `settings_cmd_tests` |
| Market redirect or host swap | HTTPS LearnHNS host only, no redirects, override debug-only | `market/learnhns.rs` | `learnhns_tests` |
| Tampered listing or price | Verified on the profile's node; price re-checked before broadcast | `noncustodial/shakedex/verify.rs` | `shakedex_verify_tests`, `shakedex_cmd_tests` |
| Ledger signs a foreign input | Refused at draft signing and in the signer | `commands/tx.rs`, `providers/ledger/signing.rs` | `ledger_plan_guard_tests` |
| Ledger signs a foreign input, a lock coin or a Shakedex draft | Draft signing refuses every Shakedex draft by action and a lock-key or foreign plan by shape; the device signer refuses foreign, lock-key and custom-sequence or lock-time plans by shape only, and cannot see the action | `commands/tx.rs`, `providers/ledger/signing.rs` | `ledger_plan_guard_tests` |
| Lock key signs anything but a cancel | Signer accepts only a `0x83` input whose same-index output is a TRANSFER of the name at its lock address to our own version-0 receive address, and refuses an input that is both foreign and lock-key | `noncustodial/shakedex/cancel.rs` (`check_lock_key_input`), `noncustodial/actions.rs` (`sign_plan_tx`) | `actions::tests::sign_plan_refuses_a_lock_key_input_that_is_not_a_cancel` |
| Daemon rebroadcasts a purchase | `SyncCaller::Daemon` never rebroadcasts | `commands/sync.rs`, `shakedex_jobs.rs` | `shakedex_purchase_state_tests::{daemon_sync_makes_no_send_call_where_the_apps_sync_does, daemon_never_rebroadcasts_and_leaves_it_to_the_app}` |

---
Expand Down
12 changes: 6 additions & 6 deletions docs/specs/2026-10-05-shakedex-name-sales.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Requirements name the code that enforces them and the test that pins them. Those
*Pinned:* `noncustodial::shakedex::template` unit tests `{boundary_at_mtp, sequence_is_fffffffe}`.

**R4 — Fees are charged on vsize, including foreign witnesses.** The purchase (seller's input witness `[sig65, lockScript]`), the buyer's FINALIZE (`[lockScript]`), the cancel and the cancel's FINALIZE are sized with their real witnesses, not the P2WPKH constants, at the profile's fee rate with a floor of 5000 doos/kB as shakedex uses.
*Enforced:* `noncustodial/shakedex/purchase.rs` (the fee is charged on the vsize of the plan signed in full, foreign witness included, with the floor `SHAKEDEX_MIN_RATE_PER_BYTE`).
*Enforced:* `noncustodial/shakedex/funding.rs` (the fee is charged on the vsize of the plan signed in full, foreign witness included, with the floor `funding::SHAKEDEX_MIN_RATE_PER_BYTE`), used by `purchase.rs`, `sell.rs` and `cancel.rs`.
*Pinned:* `noncustodial::shakedex::purchase` unit test `fee_covers_hsd_vsize_with_foreign_witness`, and `shakedex_vector_tests::{purchase_plan_matches_hsd_signed_hex, purchase_finalize_plan_matches_hsd_signed_hex}` against hsd-signed transactions; selling adds `noncustodial::shakedex::cancel` unit test `fee_covers_hsd_vsize_with_the_lock_witness`, and on a live node `tests/live_node_it.rs::{shakedex_lock_finalize_pays_its_fee_rate_on_vsize, shakedex_cancel_pays_its_fee_rate_on_vsize}`.

### 3.2 Buying
Expand Down Expand Up @@ -93,9 +93,9 @@ A mined purchase whose TRANSFER is spent on chain is never lost: only our FINALI
*Enforced:* `commands/shakedex.rs` (`shakedex_experimental`, default off).
*Pinned:* `shakedex_cmd_tests::{mainnet_purchase_needs_experimental_flag, regtest_purchase_ignores_flag}`, `shakedex_finalize_tests::finalize_ignores_experimental_flag`.

**R16 — Shakedex from recovery-phrase profiles only, for now.** Ledger, watch-only and extended-private-key profiles have Buy and Sell disabled with "Shakedex works with a recovery-phrase wallet for now". An extended-private-key profile signs in software too, but its signing of a plan with a foreign input is not verified yet, so it is held back with the others. The backend refuses to send any plan with a foreign input, a non-default sequence or a fixed witness to the Ledger signer, which would otherwise sign every input with `0xffffffff` and a P2WPKH witness.
*Enforced:* `commands/tx.rs::refuse_unsupported_ledger_plan`, called from `sign_tx_draft_inner` before any Ledger signing; `providers/ledger/signing.rs::sign_transaction`'s own guard.
*Pinned:* `ledger_plan_guard_tests::{ledger_refuses_plan_with_foreign_input, ledger_refuses_cancel_plan}`, `providers::ledger::signing` unit test `foreign_or_custom_plan_is_refused_before_the_device`, `market-list.test.tsx` and `purchase-confirm.test.tsx` (Buy disabled with the reason).
**R16 — Shakedex from recovery-phrase profiles only, for now.** Ledger, watch-only and extended-private-key profiles have Buy and Sell disabled with "Shakedex works with a recovery-phrase wallet for now". An extended-private-key profile signs in software too, but its signing of a plan with a foreign input is not verified yet, so it is held back with the others. The backend refuses to send any Shakedex draft, and any plan with a foreign input, a lock-key input, a non-default sequence or a fixed witness, to the Ledger signer, which would otherwise sign every input with `0xffffffff` and a P2WPKH witness.
*Enforced:* `commands/tx.rs::refuse_unsupported_ledger_plan`, called from `sign_tx_draft_inner` before any Ledger signing, refuses every Shakedex draft by its action and a plan with a lock-key or foreign input (or a custom sequence, lock time or fixed witness) by its shape; `providers/ledger/signing.rs::sign_transaction`'s own guard sees only the plan's shape, so it refuses a foreign, lock-key or custom-shaped plan but would accept the lock FINALIZE, an ordinary plan.
*Pinned:* `ledger_plan_guard_tests::{ledger_refuses_plan_with_foreign_input, ledger_refuses_cancel_plan, every_shakedex_action_is_refused_for_the_ledger}`, `providers::ledger::signing` unit test `foreign_or_custom_plan_is_refused_before_the_device`, `market-list.test.tsx` and `purchase-confirm.test.tsx` (Buy disabled with the reason).

### 3.3 Selling

Expand All @@ -106,8 +106,8 @@ A mined purchase whose TRANSFER is spent on chain is never lost: only our FINALI
| main | `m/44'/5353'/0'/2'/1433607229'` | `hs1qkq6yytu0c4puw0xlrdwedylkk5a04yrgecgz9wgyu2sua3mzh4jqt74rdj` |
| regtest | `m/44'/5355'/0'/2'/1433607229'` | `rs1qr3mxw2n44nxqf43jqr69d42nmypn84jgzxtxeafpxn4nq58tylzsrz9ec5` |

*Enforced:* `noncustodial/shakedex/lock_key.rs` on `ExtendedPrivKey::derive_path`.
*Pinned:* `shakedex_lock_key_tests::{lock_key_matches_the_r17_golden_vector, every_level_is_hardened, lock_key_is_not_derivable_from_the_account_xpub, coins_at_a_lock_address_are_not_spendable, lock_key_of_an_invalid_name_is_refused}`.
*Enforced:* `noncustodial/shakedex/lock_key.rs` on `ExtendedPrivKey::derive_path`; `noncustodial/actions.rs::sign_plan` derives a lock key only at sign time and signs only a cancel with it, checked by `noncustodial/shakedex/cancel.rs::check_lock_key_input` (an input that is both foreign and lock-key is refused in `sign_plan_tx`): sighash `0x83` into the output at the input's index, which must be a TRANSFER of that name at the key's lock address committing to the version-0 address of ours the input's `branch`/`child_index` derive (the receive branch, an unhardened index), with a final sequence and no lock time.
*Pinned:* `shakedex_lock_key_tests::{lock_key_matches_the_r17_golden_vector, every_level_is_hardened, lock_key_is_not_derivable_from_the_account_xpub, coins_at_a_lock_address_are_not_spendable, lock_key_of_an_invalid_name_is_refused}`; `noncustodial::actions` unit tests `{sign_plan_signs_a_lock_key_input_with_the_lock_key, sign_plan_refuses_a_lock_key_input_that_is_not_a_cancel, own_inputs_leave_out_a_lock_key_input_and_the_ledger_refuses_it}`.

**R18 — The lock is checked before the name enters it.** A wrong key or script would strand the name: FINALIZE into the lock needs no signature, but every way out does. Before broadcasting the day-0 TRANSFER, the wallet derives the lock key, builds the script and address, signs a test `0x84` over a template and verifies it. Before the FINALIZE into the lock, it re-derives and checks that the confirmed TRANSFER's covenant commits to SHA3-256 of `lock_script(derived pub)`. Either check failing refuses the step.
*Enforced:* `noncustodial/shakedex/sell.rs::lock_self_check`, `commands/shakedex.rs::{shakedex_build_lock_draft, shakedex_finalize_and_sign}`.
Expand Down
23 changes: 19 additions & 4 deletions src-tauri/src/commands/tx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1104,6 +1104,7 @@ mod ledger_signing_guards_tests {
sighash_type: 1,
sequence: crate::noncustodial::actions::FINAL_SEQUENCE,
foreign_witness_hex: None,
lock_key_name: None,
}],
outputs: vec![
crate::noncustodial::actions::PlanOutput {
Expand Down Expand Up @@ -1148,6 +1149,7 @@ mod ledger_signing_guards_tests {
sighash_type: 1,
sequence: crate::noncustodial::actions::FINAL_SEQUENCE,
foreign_witness_hex: None,
lock_key_name: None,
}],
outputs: vec![
crate::noncustodial::actions::PlanOutput {
Expand Down Expand Up @@ -1199,6 +1201,7 @@ mod ledger_signing_guards_tests {
sighash_type: 1,
sequence: crate::noncustodial::actions::FINAL_SEQUENCE,
foreign_witness_hex: None,
lock_key_name: None,
}],
outputs: vec![crate::noncustodial::actions::PlanOutput {
value: 99_500_000,
Expand Down Expand Up @@ -1232,6 +1235,7 @@ mod ledger_signing_guards_tests {
sighash_type: 1,
sequence: crate::noncustodial::actions::FINAL_SEQUENCE,
foreign_witness_hex: None,
lock_key_name: None,
}],
outputs: vec![crate::noncustodial::actions::PlanOutput {
value: 100_000_000, // output > input (impossible)
Expand All @@ -1251,10 +1255,11 @@ mod ledger_signing_guards_tests {

/// The Ledger signer streams every input as the wallet's own P2WPKH with a
/// final sequence (`providers/ledger/signing.rs`). A plan with a foreign
/// input, a custom sequence or a lock time would be signed wrongly, so it is
/// refused before the device is touched, and so is a plan that cannot be
/// read. A `send_hns` draft stores build parameters, not a plan: the signer
/// builds that plan from the wallet's own coins, so it has nothing to refuse.
/// input, a lock coin of ours signed by its lock key, a custom sequence or a
/// lock time would be signed wrongly, so it is refused before the device is
/// touched, and so is every Shakedex draft and a plan that cannot be read. A
/// `send_hns` draft stores build parameters, not a plan: the signer builds
/// that plan from the wallet's own coins, so it has nothing to refuse.
pub(crate) fn refuse_unsupported_ledger_plan(
profile_kind: &str,
action: &str,
Expand All @@ -1263,6 +1268,15 @@ pub(crate) fn refuse_unsupported_ledger_plan(
if profile_kind != "ledger_hardware" || action == "send_hns" {
return Ok(());
}
// Shakedex is for recovery-phrase wallets only (R16, R29), refused as a
// class: the FINALIZE into a lock is an ordinary plan the device could
// sign, and a class keeps a new Shakedex action from slipping past a
// list of known ones.
if crate::noncustodial::shakedex::is_shakedex_action(action) {
return Err(AppError::InvalidInput(
crate::noncustodial::shakedex::RECOVERY_PHRASE_ONLY.into(),
));
}
let plan = serde_json::from_str::<crate::noncustodial::actions::DraftPlan>(plan_json)
.map_err(|e| AppError::Other(format!("corrupted draft: unreadable signing plan: {e}")))?;
if plan.has_foreign_or_custom_inputs() {
Expand Down Expand Up @@ -2639,6 +2653,7 @@ mod pure_helper_tests {
sighash_type: 1,
sequence: crate::noncustodial::actions::FINAL_SEQUENCE,
foreign_witness_hex: None,
lock_key_name: None,
}
}

Expand Down
Loading
Loading