Skip to content

Non-custodial heir track: Door B, kill master backdoor, seal at-rest token, honest claim#124

Merged
Jolah1 merged 8 commits into
mainfrom
feat/door-b-heir-own-key
Jun 19, 2026
Merged

Non-custodial heir track: Door B, kill master backdoor, seal at-rest token, honest claim#124
Jolah1 merged 8 commits into
mainfrom
feat/door-b-heir-own-key

Conversation

@Jolah1

@Jolah1 Jolah1 commented Jun 19, 2026

Copy link
Copy Markdown
Owner

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 legacy heir_derivation route 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

  • 136 server tests, full web typecheck/lint/build, 5 web unit tests — all green.
  • Regtest e2e against bitcoind v29 (5 tests, all pass):
    • manual_psbt_claim_is_self_contained_and_signable — the server's unsigned claim PSBT carries taproot_scripts + taproot_bip32_derivs; a descriptor-aware signer completes it.
    • heir_signs_then_watch_only_finalizes_like_the_server — proves broadcast_claim's split model: heir signs (no finalize), a key-less watch-only wallet finalizes, broadcast pays out.
    • Plus existing owner-checkin/heir-claim and offline-sweep tests.
  • New unit/integration tests: Door B stores no heir secret/token; coupling validation; at-rest token round-trip / legacy passthrough / cross-vault rejection; scheduler decrypts a sealed at-rest token into the heir's link.

Not covered here (residual seam)

The Esplora HTTP transport in build_claim_psbt (sync) and broadcast_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

Jolah1 and others added 8 commits June 18, 2026 22:31
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>
@vercel

vercel Bot commented Jun 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ghost-key Ready Ready Preview, Comment Jun 19, 2026 7:28am

@Jolah1
Jolah1 merged commit 6236af0 into main Jun 19, 2026
8 checks passed
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>
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.

1 participant