Non-custodial heir track: Door B, kill master backdoor, seal at-rest token, honest claim#124
Merged
Conversation
Kills the F2 path where a no-wallet email heir's key was derived from GHOSTKEY_MASTER_KEY (ghostkey-core keys.rs derive_heir_seed). That path meant one master-key breach exposed every such heir key, with no per-vault ciphertext required. The browser already generates and seals a per-heir key under the claim token in every case, so we just stop opting into server derivation: heir_derivation is now always null on new vaults, and the heir envelope (Block A) seals the browser-generated heirParty key. At claim, these heirs use the existing sealed-blob path (ClaimPage falls through to it when there is no derivation) — the same path non-no-wallet heirs already use today. The server's heir_derivation route is left intact so existing F2 vaults keep claiming. The redundant "no wallet" checkbox (its only job was to select F2) is removed from both PasswordSetupPortal and AddHeirPortal; a first-class Door A/Door B choice replaces it in the next step. Tested: typecheck, lint, build, unit tests green. NOT yet run: a full regtest end-to-end claim for a new no-wallet heir. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds an advanced setup option where the heir provides their own xpub. GhostKey then holds nothing that can spend their Bitcoin — the only genuinely non-custodial heir path. The owner still uses the password flow; only the heir key differs. Server (routes.rs): SealedSetup's heir_xprv + claim_token become optional. A Door B vault seals owner material only, stores no heir secret and no at-rest claim token, and so behaves like a legacy vault — the scheduler mints a fresh claim token at trigger and the heir claims via the existing manual-PSBT self-sign path with their own wallet. Validation rejects a half-populated shape (heir xprv XOR token). Web (PasswordSetupPortal): per-heir advanced disclosure to paste an origin-tagged xpub, with live validity feedback and honest copy (non-custodial; heir signs with a Core-compatible wallet at claim). A guard blocks creation if Door B is on but the xpub is invalid, so we never silently fall back to generating a key. Block A and the video message are skipped for Door B (no heir secret to seal; the video would be sealed under a token the heir never receives). Tested: typecheck, lint, build, web unit tests, 130 server tests green. NOT yet done: a Door-B-specific server test, and a regtest end-to-end Door B claim (manual-PSBT self-sign). The manual-PSBT claim copy still names Sparrow, which can't sign our timelock descriptor — flagged for a follow-up to point it at Bitcoin Core. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two route-level tests for the heir-holds-own-key path: - door_b_stores_no_heir_secret_or_claim_token: a create with sealed owner material but no sealed heir xprv and no claim token persists a real, parseable tr() timelock descriptor while leaving the heir xprv, at-rest claim token, and token hash all NULL (heir_derived=0, no vault_secret emitted). Proves the server holds nothing spendable. - sealed_heir_and_claim_token_must_be_coupled: both half-populated shapes (heir xprv without token, token without heir xprv) are rejected as validation errors. Server suite: 132 passed. The on-chain heir self-sign path these vaults rely on is already covered by the regtest_e2e tests (heir holds an independent key, claims the timelock branch), which pass against bitcoind v29. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The step-3 hand-off told every heir 'In Sparrow: File → Open Transaction → Sign', presenting Sparrow as the signer. The vault's heir branch is a Taproot timelock script-path spend; the restore drill found Sparrow (and Liana) refuse our descriptor shape, so naming it as the path is misleading. Make the instruction wallet-neutral: sign in the wallet that holds your key, using its PSBT/transaction signer. Does not touch the WalletGuide canSignPsbt list — whether common third-party wallets can sign our tapscript heir PSBT is unverified and needs a regtest signing test before any claim is made there. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ner completes it Reproduces the server's build_claim_psbt path (watch-only wallet, no keys) on regtest and proves, against bitcoind v29: - the returned PSBT is unsigned (the heir's wallet must sign it), - decodepsbt shows the input carries the tapscript leaf (taproot_scripts) and key-origin derivation (taproot_bip32_derivs), so the PSBT is self-contained — the only thing a signer must bring is the heir key plus the ability to satisfy our or_d(pk,and_v(v:pk,older)) tapscript, - a descriptor-aware signer holding the heir key (the model Bitcoin Core and the in-browser kit follow) finalizes that same unsigned PSBT and broadcasts it; the recipient receives the funds. Conclusion: the manual-PSBT claim's limiting factor is signer capability to satisfy our tapscript, NOT missing PSBT data. Wallets that refuse our descriptor (Sparrow/Liana, per the restore drill) cannot complete it, which the WalletGuide canSignPsbt list does not yet reflect. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The manual-PSBT claim is the only flow where the heir signs, and it's reached by Door B / own-key heirs and legacy vaults. Its heir branch is a Taproot timelock script; the restore drill found Sparrow and Liana refuse our descriptor, and the new core regtest test showed the unsigned PSBT is self-contained, so the barrier is purely signer capability. Yet the flow promised signing in Sparrow/Cake/BlueWallet: - Step 3 told every heir 'copy into your wallet's PSBT signer' as if any wallet works. - The no-wallet WalletGuide(requirePsbt) filtered the receive list to canSignPsbt and presented phone wallets as signers. Now: - Step 3 sets the honest expectation: this spends a Bitcoin timelock, needs a Taproot-script signer, most phone wallets can't, Bitcoin Core is the tested one, and the heir's own key-holding wallet works if it can sign it. - WalletGuide's signing branch points at Bitcoin Core instead of a consumer-wallet list that would dead-end the claim. The receive-only branch (password-vault / derived-heir farmer paths) is unchanged. - Dropped the now-dead canSignPsbt flag and updated the file-header doc. typecheck, lint, build, 5 web unit tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Door A (password) vaults must keep the heir's claim token at rest so the scheduler can embed it in the heir's link once the owner is gone. It was stored verbatim; now it's sealed under the per-vault XChaCha20-Poly1305 AEAD (the same key family used for contacts), so a DB-only dump — a leaked backup, a stolen SQLite file — no longer hands over the bearer credential. - crypto: seal_claim_token_at_rest / open_claim_token_at_rest pack nonce+ciphertext into the existing column as gk1.<nonce>.<ct> (no schema migration). The raw token is base64url so it can never contain a '.', making the marker unambiguous; legacy plaintext rows have no marker and pass through unchanged. - routes: seal the token at create time instead of storing it raw. - scheduler: decrypt at both delivery sites (trigger -> first heir link, and the claim-ready reminder) before building the URL. Honesty: this protects a DB-at-rest dump, NOT a running-server or master-key compromise — the server must be able to decrypt to deliver the link, by construction. It doesn't change the custody story; Door B (heir holds own key, no token at rest) remains the non-custodial path. Tests: crypto round-trip + legacy passthrough + cross-vault rejection; a scheduler integration test proving a sealed at-rest token is decrypted into the heir's email and the gk1. blob never reaches it. 136 server tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
broadcast_claim doesn't finalize in the heir's wallet: the heir signs in their own wallet and pastes the signed PSBT back, then the server assembles the witness with a key-less watch-only wallet (build_watch_only(...).finalize_psbt(...)). The other regtest tests sign and finalize in the same wallet, so they never exercised that split. New test: heir signs with try_finalize=false (asserts not finalized), a fresh watch-only wallet with no keys finalizes it, the tx broadcasts and pays the recipient. Closes the one server-specific claim seam reachable without an Esplora backend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Jolah1
added a commit
that referenced
this pull request
Jun 20, 2026
POST /vaults/guardian creates a guardian vault (heir + two guardians). - Migration 20260620000001: vault_guardian_keys table (one row per guardian: sealed xprv, sealed-at-rest claim token + hash, sealed contact, xpub fragments) + vault_kind column on vaults. - create_vault_guardian: builds the heir AND (g1 OR g2) descriptor via build_guardian_descriptor_pair, stores the heir inline (reusing every existing sealed-key / claim-token column), and writes two guardian rows. Reuses validate_timelock_blocks (144-block floor), rejects any two equal keys, seals all contacts + the personal opener at rest, and stores every claim token only as a hash + sealed-at-rest (never raw). - Post-#124 custody: all keys are browser-generated and browser-sealed; the server stores only ciphertext. No master-key derivation. Tests: guardian create persists heir inline + two guardian rows with distinct hashed tokens and a parseable or_b descriptor; duplicate-key requests are rejected. 144 server tests pass; fmt + clippy clean (new code). Not wired into web/claim yet (P2b/P3/P4). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Jolah1
added a commit
that referenced
this pull request
Jul 15, 2026
README listed, under "What works today", that the server derives a no-wallet heir's key from (master_key, heir_email, vault_id). That reads as how the product works now. It isn't: #124 killed exactly that backdoor, and heir_derivation is null on every new vault, in both PasswordSetupPortal and AddHeirPortal. The claim is the wrong way round to be wrong about. It tells a reader (or an auditor) that one master-key breach exposes every no-wallet heir key, which is the custody weakness the project deliberately removed. Describe what actually happens: the browser generates the heir key and seals it under the one-time claim token, and the old derivation path stays reachable only so pre-#124 vaults can still be claimed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Jolah1
added a commit
that referenced
this pull request
Jul 15, 2026
Same staleness the README had, but here it overstates live risk. The doc presents "F2 server-derived flow" as a current heir-key mode and A3.2 as a current blast radius. Since #124 no vault is created with heir_derivation, so the exposed set is exactly the F2 vaults that already existed, and it does not grow. An auditor reading this would model a blast radius that no new vault has. D1 was the sharper problem. It says an operator "cannot construct a valid witness without either the owner's xprv or the heir's xprv", and never cross-references A3.2, where the master key *derives* the heir xprv for F2 vaults. Both statements are individually true, so the gap only shows if you read them together. Now D1 says plainly that it does not hold for legacy F2 vaults. Also note the owner-xprv caveat under D1: an operator with the DB and the master key still has to break an Argon2id password KEK to spend as the owner (verified: deriveOwnerKek runs Argon2id over the password, then HKDF, never the master key). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Jolah1
added a commit
that referenced
this pull request
Jul 17, 2026
* docs: stop advertising the master-key heir derivation as current README listed, under "What works today", that the server derives a no-wallet heir's key from (master_key, heir_email, vault_id). That reads as how the product works now. It isn't: #124 killed exactly that backdoor, and heir_derivation is null on every new vault, in both PasswordSetupPortal and AddHeirPortal. The claim is the wrong way round to be wrong about. It tells a reader (or an auditor) that one master-key breach exposes every no-wallet heir key, which is the custody weakness the project deliberately removed. Describe what actually happens: the browser generates the heir key and seals it under the one-time claim token, and the old derivation path stays reachable only so pre-#124 vaults can still be claimed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(threat-model): scope F2 to legacy vaults, close the D1 gap Same staleness the README had, but here it overstates live risk. The doc presents "F2 server-derived flow" as a current heir-key mode and A3.2 as a current blast radius. Since #124 no vault is created with heir_derivation, so the exposed set is exactly the F2 vaults that already existed, and it does not grow. An auditor reading this would model a blast radius that no new vault has. D1 was the sharper problem. It says an operator "cannot construct a valid witness without either the owner's xprv or the heir's xprv", and never cross-references A3.2, where the master key *derives* the heir xprv for F2 vaults. Both statements are individually true, so the gap only shows if you read them together. Now D1 says plainly that it does not hold for legacy F2 vaults. Also note the owner-xprv caveat under D1: an operator with the DB and the master key still has to break an Argon2id password KEK to spend as the owner (verified: deriveOwnerKek runs Argon2id over the password, then HKDF, never the master key). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: state one licence, MIT, everywhere The repo claimed two different things. LICENSE and the README said MIT; every crate said "MIT OR Apache-2.0", and no Apache-2.0 text existed anywhere. So the crates offered users a licence whose terms the project never shipped. CONTRIBUTING was the part that actually mattered: it told every contributor their work was licensed "MIT or Apache-2.0 at the user's option". People have been agreeing to terms the project doesn't publish. Settle on MIT, which is what LICENSE, the README, and the public framing have said all along: - Cargo.toml -> license = "MIT" (all five inheriting crates follow) - the two lightning crates declared it directly; updated too - ghostkey-web/package.json had no license field; add "MIT" - CONTRIBUTING points at LICENSE instead of naming a second licence Worth knowing: MIT alone has no express patent grant, which is the one thing Apache-2.0 would have added. Going MIT-only is the smaller promise, and it's the one already being made. Verified: cargo metadata reports MIT for every crate, cargo check and npm run build pass, no "apache" reference left in the repo. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: dual licence under Apache-2.0 or MIT Restores what the crates always claimed, and ships the text to back it up. Before this, Cargo.toml offered users Apache-2.0 while the repo contained only MIT, so the second option had no terms behind it. - LICENSE -> LICENSE-MIT (git tracks the rename, history preserved) - LICENSE-APACHE: the canonical text, fetched verbatim from apache.org rather than reproduced from memory (11,358 bytes) - LICENSE.md: explains the choice, and states that contributions are dual licensed unless the contributor says otherwise - Cargo.toml and the two lightning crates back to "MIT OR Apache-2.0" - ghostkey-web/package.json likewise - README and CONTRIBUTING name both licences and point at LICENSE.md Why both rather than either alone: Apache-2.0 carries an express patent grant, which MIT lacks, and MIT is GPLv2-compatible, which Apache-2.0 is not. Offering the pair means a downstream project takes whichever it needs, which matters for a codebase we want other wallets to borrow the inheritance pattern from. Verified: cargo metadata reports "MIT OR Apache-2.0" for every crate, cargo check passes, package.json parses, and no doc still links the old ./LICENSE path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Makes GhostKey's heir custody as honest as the copy now claims, and gives owners a truly non-custodial option. Three plan steps plus the claim-flow honesty fixes that fell out of verifying them.
What changed
Step 1 — Door B: the heir holds their own key (
41afdd4,243a46b)An advanced per-heir option at setup: paste the heir's own origin-tagged xpub. The vault descriptor uses their key; the server stores no sealed heir secret and no claim token, so it holds nothing that can spend. The only genuinely non-custodial heir path. Guards block creation if Door B is on but the xpub is invalid (never silently falls back to generating a key); Block A and the video message are skipped (no heir secret to seal).
Step 2 — kill the master-key heir derivation (
a2c2c35)New vaults stop deriving the heir key from
GHOSTKEY_MASTER_KEY(a single backdoor over every such heir). They use the already-generated, already-sealed per-heir key like every other password vault. The legacyheir_derivationroute stays intact so existing F2 vaults keep claiming.Step 3 — encrypt the at-rest claim token (
2b846be)Door A vaults must keep the heir's claim token at rest (the scheduler embeds it in the heir's link once the owner is gone). It was stored verbatim; now it's sealed under the per-vault XChaCha20-Poly1305 AEAD, packed into the existing column as
gk1.<nonce>.<ct>(no schema migration). Legacy plaintext rows pass through unchanged. Honest scope: protects a DB-at-rest dump, not a running-server or master-key compromise.Claim-flow honesty (
2c32173,dd9eed7)The manual-PSBT claim (Door B / legacy) is the only path where the heir signs. Its heir branch is a Taproot timelock script that Sparrow/Liana refuse (restore drill) and most phone wallets can't sign. Stopped naming Sparrow as the signer and stopped recommending wallets that would dead-end the claim; point at Bitcoin Core, with an honest "your own key-holding wallet works if it can sign this" hedge for Door B. Farmer paths (derived-heir, password-vault) are untouched — they don't ask the heir to sign.
Verification
manual_psbt_claim_is_self_contained_and_signable— the server's unsigned claim PSBT carriestaproot_scripts+taproot_bip32_derivs; a descriptor-aware signer completes it.heir_signs_then_watch_only_finalizes_like_the_server— provesbroadcast_claim's split model: heir signs (no finalize), a key-less watch-only wallet finalizes, broadcast pays out.Not covered here (residual seam)
The Esplora HTTP transport in
build_claim_psbt(sync) andbroadcast_claim(POST /tx). Shape-agnostic plumbing, identical across vault types, already live in production. Everything it feeds and consumes is proven above. Planned to be exercised via the signet e2e harness post-merge.Backward compatibility
Existing F2 vaults still claim (legacy route kept). Legacy plaintext at-rest tokens pass through. No schema migration.
🤖 Generated with Claude Code