Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
6f9a496
feat(shakedex): parse a seller's price and list what Finalize & sign …
DimazzzZ Oct 9, 2026
b47a365
feat(shakedex): write our listing file and read it back strictly
DimazzzZ Oct 9, 2026
90b99d2
test(shakedex): pin the written steps exactly and refuse a fee in our…
DimazzzZ Oct 9, 2026
5b82d76
feat(shakedex): keep the FINALIZE into the lock, its steps and its fi…
DimazzzZ Oct 9, 2026
73e9e85
fix(shakedex): pin the FINALIZE-delete revert to its own listing and …
DimazzzZ Oct 9, 2026
0e1f95e
feat(shakedex): check the lock transfer before Finalize & sign (R18, …
DimazzzZ Oct 9, 2026
5944196
feat(shakedex): Finalize & sign the price in one unlock, and export t…
DimazzzZ Oct 9, 2026
2887229
feat(shakedex): follow a listing from the lockup to its mined FINALIZE
DimazzzZ Oct 9, 2026
890f988
fix(shakedex): read an owner coin spent in the mempool and a mined lo…
DimazzzZ Oct 9, 2026
9ef1337
feat(shakedex): remind daily that a locked name is ready to Finalize …
DimazzzZ Oct 9, 2026
3ed33c3
test(shakedex): the shakedex CLI buys a Namehold Buy Now on a live node
DimazzzZ Oct 9, 2026
57536a9
fix(shakedex): one rule for the lock TRANSFER's ownership and lockup,…
DimazzzZ Oct 9, 2026
07f54ef
fix(shakedex): settle a listing whose dead FINALIZE lost its lock TRA…
DimazzzZ Oct 9, 2026
cc81aa4
fix(shakedex): keep reminding until the FINALIZE into the lock is sen…
DimazzzZ Oct 9, 2026
2c1d5ce
test(shakedex): pin the stored lock key check and the signed FINALIZE…
DimazzzZ Oct 9, 2026
87e6ab8
test(shakedex): a reorg of the FINALIZE into the lock and a FINALIZE …
DimazzzZ Oct 9, 2026
1edcd79
docs(shakedex): say when the self-check signs and the median time is …
DimazzzZ Oct 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,12 +243,16 @@ 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`), 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. Locking a name (day 0) derives the name's lock key from the unlocked seed as well, only to read its public key and run the R18 self-check, whose test signature is over a template with a zero outpoint and is checked and dropped; only the public key is stored on the listing, and the TRANSFER commits to the program of the key just checked. 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`).
**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. The wallet itself spends a lock coin of ours only by a cancel, signed with the lock key, which the signer derives from the seed at that moment and never stores; a buyer spends it with a price step the lock key signed at Finalize & sign (5). Locking a name (day 0) derives the name's lock key from the unlocked seed as well, only to read its public key and run the R18 self-check, whose test signature is over a template with a zero outpoint and is checked and dropped; only the public key is stored on the listing, and the TRANSFER commits to the program of the key just checked. 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

**Guarantee:** The background daemon refreshes each purchase's state from the chain but never rebroadcasts one. A purchase that went missing is rebroadcast at most once, and only by the app's own sync, through the same broadcast gates as every other send.

#### 5. Price steps signed by the lock key (selling)

**Mitigation:** The lock key signs a price step (`0x84`, `SINGLEREVERSE|ANYONECANPAY`) only in `commands/shakedex.rs::finalize_and_sign_confirmed`, behind the `shakedex_finalize_and_sign` command, and only after the user confirms the Finalize & sign prompt in the secure window. Before the prompt it makes one `0x84` signature, the R18 self-check's (`sell::lock_self_check`), over a test template with a zero outpoint: it is verified and dropped, never stored or sent. The prompt is `commands/secure_confirm.rs::confirm_rows`, on `prompt_secure`: its own `confirm` request, not the transaction prompt, listing the FINALIZE's network fee, every step with its price and when it becomes valid, the payment address, the lock address and the warning that each signature lets anyone buy at its price until a cancel is mined. A declined prompt signs and writes nothing. Before it asks, `prepare_lock_finalize` refuses unless the listing is the active profile's and ReadyToFinalize, the node reports the name still held, unrevoked, by the lock TRANSFER after its lockup, and that confirmed TRANSFER commits to the lock of the key re-derived from the seed for the name, whose public key must be the one stored on the listing (R18; the key is derived after every node read, held in memory until the command returns, never stored). Each step's template has one input, the lock coin the FINALIZE into the lock creates (that FINALIZE's txid, which Handshake fixes before signing, and the index of its one FINALIZE output paying the lock address, `sell::lock_output`), with that output's value, and one output paying the price to the listing's reserved payment address; its lock time is the node's median time minus 512 seconds, read from the node just before the prompt (with the other node reads, not after it), and nothing is signed when the node does not report a median time. `sell::sign_step` signs that template with the lock key and refuses a signature that does not verify against it (sighash `0x84`, low-S, the lock key's public key). The FINALIZE and every step are signed in one hold of the unlocked session; the signed FINALIZE draft, the steps, the listing file and the listing's move to Finalizing are written in one database transaction, and nothing is sent: `commands/tx.rs::broadcast_tx_draft` sends the FINALIZE through the usual broadcast gates. A step over a FINALIZE that is never mined can never be spent, since any other FINALIZE has another txid; `shakedex_export_listing_file` refuses to hand out the listing file while the listing is Locking, ReadyToFinalize or Finalizing, that is before its FINALIZE is mined, and for a listing that is over (Aborted, Expired, Cancelled, Sold) or Restored.

---

## Mitigations reference table
Expand All @@ -266,8 +270,10 @@ The Market lists names for sale through Shakedex, read from the LearnHNS Market
| 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, 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` |
| Lock key signs anything but a price step or 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; price steps (`0x84`) are signed only by Finalize & sign after the secure-window confirmation, over the lock coin its own FINALIZE creates, paying the listing's payment address, each verified before it is kept | `noncustodial/shakedex/cancel.rs` (`check_lock_key_input`), `noncustodial/actions.rs` (`sign_plan_tx`), `commands/shakedex.rs` (`finalize_and_sign_confirmed`), `noncustodial/shakedex/sell.rs` (`sign_step`, `lock_output`) | `actions::tests::sign_plan_refuses_a_lock_key_input_that_is_not_a_cancel`, `shakedex_sell_tests::{lock_then_finalize_and_sign, refuses_finalize_on_commitment_mismatch, confirmation_lists_every_step, cancelled_confirmation_writes_nothing}` |
| Name locked to a key that cannot move it | The lock key is re-derived and self-checked (script, program, address, a test `0x84` signature) before the TRANSFER draft is written; the TRANSFER commits to that key's program | `noncustodial/shakedex/sell.rs` (`lock_self_check`, `lock_transfer_covenant`), `commands/shakedex.rs` (`build_lock_draft_inner`) | `shakedex_sell_tests::{refuses_lock_on_self_check_failure, lock_draft_commits_to_the_derived_lock_address}`, `shakedex_vector_tests::lock_transfer_covenant_matches_hsd` |
| Name finalized into a lock this wallet cannot move | Finalize & sign re-derives the lock key after the node reads and refuses unless the node's confirmed lock TRANSFER commits to that key's lock and the stored public key is that key's (R18) | `commands/shakedex.rs` (`prepare_lock_finalize`) | `shakedex_sell_tests::refuses_finalize_on_commitment_mismatch` |
| Listing file out before its FINALIZE is mined | Export refuses in Locking, ReadyToFinalize and Finalizing | `commands/shakedex.rs` (`export_listing_file_from_conn`) | `shakedex_sell_tests::listing_file_is_exported_only_once_the_finalize_is_mined` |
| 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
2 changes: 1 addition & 1 deletion docs/CODING_STANDARDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Locally `cargo test` is enough; CI uses nextest for its two-lane split (see
Existing imports of `commands::read` (profile resolution, node readiness),
`commands::sync::open_conn`, `commands::namebase` and
`commands::secure_prompt` are legacy shared layers — do not add to them.
- Where the shared pieces live instead: a background job opens its connection with `db::connection::open_migrated` (the body behind the legacy `open_conn`); the context a draft-building command starts from (`Ctx`, `load_ctx`, `fee_rate`, `fetch_name_state`, `renewal_block`) is `commands::draft_ctx`; send policy that needs only a node client (`broadcast_network_guard_with_client`) is `noncustodial::rpc`. `tests/shakedex_layering_tests.rs` reads the Shakedex sources and fails on a sibling-command import; add a new module's source to its list.
- Where the shared pieces live instead: a background job opens its connection with `db::connection::open_migrated` (the body behind the legacy `open_conn`); the context a draft-building command starts from (`Ctx`, `load_ctx`, `fee_rate`, `fetch_name_state`, `renewal_block`) is `commands::draft_ctx`; send policy that needs only a node client (`broadcast_network_guard_with_client`) is `noncustodial::rpc`; a secure-window confirmation of rows (`confirm_rows`, on the legacy `secure_prompt`) is `commands::secure_confirm`. `tests/shakedex_layering_tests.rs` reads the Shakedex sources and fails on a sibling-command import; add a new module's source to its list.
- **Pure logic is split from IO** so it can be unit-tested without a live node
or a Tauri `State`: the `*_with_client(&dyn NodeRpc, ...)` pattern for RPC
code, `*_from_conn(&Connection)` for DB code, `*_pure.rs` modules for
Expand Down
Loading
Loading