Repository navigation
Descriptor backup crypto: both on-chain formats, with conformance tests - #2
Open
benjamin-jarvie wants to merge 54 commits into
Open
benjamin-jarvie wants to merge 54 commits into
benjamin-jarvie wants to merge 54 commits into
Conversation
Adds the two encryption schemes behind the planned on-chain descriptor backup. Neither is wired to a page yet; this is the crypto and its checks. - crypto/descriptor.ts: the k-of-n scheme from joshdoman/multisig-backup (MIT), reproduced byte for byte so a client can paste our ciphertext into multisigbackup.com and recover with k of their extended public keys, with no Bitcoin Butlers software in the path. Two upstream quirks are pinned by tests because copying them is the whole point: a share index of 0 encodes to no bytes, and fingerprint pairs are ordered by JavaScript string comparison rather than byte order. - crypto/bip138.ts: the BIP-138 draft, where any ONE extended public key opens the backup. Written against bitcoin/bips#1951 at commit 5af62cb. Common account paths are dropped from the encoding, which is what the draft's own end-to-end vectors do. - Vectors: descriptor-vector.json was produced by running the upstream multisig-backup source with its secret stubbed, so our tests compare against a second implementation rather than against ourselves. The bip138/ vectors are the draft's own. - make test-ts type-checks and runs both suites, and CI runs it on every push. A client's fallback tool disagreeing with us is now a red build. - Measured sizes for a 2-of-3, which the relay research says must stay under 600 bytes: k-of-n 345, BIP-138 591, BIP-138 with the draft's suggested decoy secrets 655. Also fixes a type error in create-app.ts that was already failing lint on this branch, using the same BlobPart cast the rest of the file uses.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
Measured on Bitcoin Core v31.1 with default policy: maxdatacarriersize is 100,000 bytes, and payloads of 345 up to 99,000 bytes are all accepted as standard. The only cliff is at 83 bytes, where Knots and Core 29 and older stop relaying, and every format here is already past it. So the size difference between the two formats is a fee difference of about 2 sat per byte, not a relay risk. The test now pins the sizes and says so.
…s work Two decisions from Ben on 2026-09-10. Fingerprint tags stay on by default in the k-of-n format. They are the breadcrumb an heir uses when the written transaction id is lost. A new test does what a scanner would do: take two of the wallet's fingerprints, hash them the way the format does, and find the tag in the payload. BIP-138 backups now pad to seven individual-secret entries, so an onlooker cannot count the cosigners. Seven rather than the draft's suggested five, because it covers every wallet up to seven cosigners without jumping to ten, and the 128 extra bytes cost about 128 sat. The unusual bucket does not make our backups stand out, because a BIP-138 backup already begins with the ASCII text BIP138. Sizes for a 2-of-3 are pinned: k-of-n 345 bytes, BIP-138 591 bare, 719 as we ship it.
A multisig wallet needs its descriptor as well as its keys. Lose the descriptor and the keys alone will not open the wallet. This page encrypts the descriptor so the wallet's own keys unlock it, hands the client one line of text to put on chain, and reads it back again. - Protect: paste a descriptor, choose who can open it (the wallet's own threshold, or any single key), and get the text plus its size and fee. Two ways to publish it: opreturnbot.com, or a prefilled Bitcoin Core command. Both put the same text in one OP_RETURN output. - Recover: paste the text, or a transaction id and let the page fetch it from a public explorer, then paste the keys. The page names which format it found and how many keys that format needs. - The encrypt half never touches the network. The recover half touches it in one place, fetching by transaction id, and says so on screen. - Seven Playwright tests drive the real page: both formats round-trip, one key is refused where two are needed, a stranger's key opens nothing, and a malformed transaction id is caught before anyone is asked. The tests caught a display bug worth naming. The crypto layer's derivation path list is filtered the way upstream filters it, which leaves a fingerprint glued to the path when that fingerprint happens to be all digits, and drops the path entirely when it is not. Harmless for encryption, wrong on screen. The page now reads the origin fields itself.
The runbook, the estate insert and the annual drill now carry the on-chain step, and the public guide explains it to a client. - Placement runbook: a new session step. Read the descriptor back to the client before encrypting, let them choose who can open it, publish through opreturnbot.com with its Private box ticked, then read it back off the chain in front of them. A backup nobody has read back is a guess. A new rule says Butlers pay for it, never the client's coins, and never two clients in one transaction. - Estate insert: a block for the transaction id, block height, block hash, date and which format it uses, with instructions that work through any block explorer if our page is gone. - Annual drill: read the on-chain copy back every year. It checks the insert, the client's keys and the chain copy in one step, and it costs nothing. - Public guide: a section that says what goes on the chain, what stays private, which of the two formats to pick, and how to recover without us. - docs/descriptor-backup-vector.md publishes the threshold vector with the test mnemonics, so anyone can confirm our text decrypts at multisigbackup.com. The URL allowlist test caught the three new external links, which is what it is for. The new page is now covered by that test alongside every other page, and the three URLs are listed with the reason each one is there.
Ben's call, and he is right about the business side. The client-facing copy was handing a reader a rival's URL as a call to action at the exact moment they are using our page. The property still holds and is still stated: neither format is ours, both are open, and other software reads them. What changed is that the guide and the page no longer act as a referral. Where the explicit fallback tool stays, and why: - The estate insert keeps it. That page is read on the day Bitcoin Butlers is not here, by someone who has never met us. Naming the tool is the whole job of that document. - docs/descriptor-backup-vector.md keeps it. That file is developer documentation for auditors and reviewers, not client-facing. It is the receipt behind the claim that recovery does not need us. On the other objection: the vector contains no sensitive data. Those are the BIP-39 test mnemonics published in the BIP itself and used by every wallet's test suite. They hold nothing and never will.
Published through opreturnbot.com with the Private flag: 545 bytes in one OP_RETURN output, 671 vB, 3,355 sat at 5 sat/vB. Verified end to end against the chain rather than against ourselves. The bytes fetched from a public explorer match the published vector exactly, two of the three published keys rebuild the descriptor, and one key alone does not. The policy and derivation path read in clear text without any key, as designed, and the 12 bytes of lookup tags are present. Block height, block hash and the mining pool follow once it confirms.
Confirmed in block 966450, mined by ViaBTC in the first block after broadcast. That answers the last open relay question with our own evidence: a 545-byte data carrier reaches a miner through the general mempool, with no pool submission and no wait.
Publishing is not proof. The page had a note telling people to come back and check; now it has a step that does the checking. Step 4 takes a transaction id and does three things: reads the bytes back off the chain, confirms they are the bytes this page produced, and decrypts them to confirm they give back the original descriptor. The reader types nothing except the transaction id, because the keys are already in the descriptor they pasted at step 1. Then it writes a copy-ready block for the estate insert: transaction id, block height, block hash, date, and which format opens it. Publishing itself stays a hand-off. The bot's API allows browser calls only from its own origin, verified against its CORS headers, so our page cannot create the payment in place. The copy-and-open button plus a four line checklist is the honest version of that, and the checklist leads with ticking Private, which is the step people forget. mempool.space returns access-control-allow-origin for anyone, which is why the verify half works from any origin, including a copy saved to disk. Three tests cover step 4 with the explorer mocked: it goes green on the real bytes, it refuses loudly on different bytes, and it says so while the transaction is unconfirmed. A route glob bug in the first draft let the status call escape to the real network, which the tests caught.
The critical one was mine, and I had waved it off in a comment. The parser assigned its share offset instead of accumulating it, which I left matching upstream and called harmless "because this tool does not produce multi-group descriptors". The tool does not produce them; it accepts whatever a person pastes. A descriptor with two multisig groups encrypted without complaint and could then never be opened, by us or by the fallback tool. That is a permanent, public, useless backup. Fixed both ways: the encoder now refuses a multi-group descriptor outright, because a backup we cannot promise to open should never be written; and the parser accumulates, so we can still read such a blob if one exists. For a single group the two forms are identical, so byte compatibility is untouched, and a test pins that. The rest, all in the page: - Step 4 compared the rebuilt descriptor against the raw input. Encryption drops the checksum and Sparrow exports one, so a correct backup was reported as wrong. It now compares without checksums. - The one-key path tried only the first key in the descriptor, which may be one the format excludes. It tries each. - Step 4 depended on session state, so it could not work after a reload, which is exactly what the page tells people to do. It now checks the chain against the descriptor in step 1, and the card is always visible. - The explorer field was ignored and mempool.space was hardcoded, so a testnet descriptor always 404'd and a self-hosted user was silently sent to a third party. Step 4 has its own explorer field and defaults to the right network. - The page shipped with no Content-Security-Policy while its siblings have one. It now has one with a nonce. - A descriptor missing its fingerprints produces no search tags, which quietly removes a recovery route the guide describes. The page says so. Also: truncated text used to surface a raw DOM exception from atob. It now says what is wrong.
The guide showed Rememory's screenshots: their paper theme, their copy, and for the QR and manifest steps a browser permission bubble and an OS file picker. Every image now comes from this build. The ten guide images are regenerated by the existing generator. Three steps needed new shots, added to the same spec so they stay reproducible: the share step with the Scan QR code button, the scanner itself, and the manifest drop zone. The two dialog images are gone, because a page screenshot cannot contain a browser or OS dialog; the captions now describe what is shown and say the browser asks for permission next. The scanner shot launches its own Chromium with a fake camera fed from a rendered QR code on paper, at phone size, since the guide tells the reader to scan with a phone. The Open Graph image is regenerated at 1200x630. Taking the scanner shot exposed a defect: the modal was painted with var(--text) and written on in var(--paper-light), upstream's dark ink under light text, which on our palette inverted to a light box with a white hint nobody could read. A viewfinder is dark in every theme, so it now uses fixed colours.
Ben's call, 2026-09-11, for search and for a newcomer's understanding of what the tool is. "Bitcoin Inheritance" in titles and headings, "Inheritance" in short labels such as the page logo and the guide's home link. The te reo Māori name stays on the repository, the CLI binary, Go identifiers and code comments, where nobody a customer would call a reader ever sees it. Pages, guide, README, PDF bundle title and every translation string carry the new name. The recovery URL printed on every guardian PDF and the pages' own base URL move to /tools/inheritance, which the site redirects to permanently from the old path. The guide screenshots are regenerated, so the page logo in every image already reads Inheritance. The paragraph on the about page that explained the old name is rewritten around the fork's origin instead. Go tests and the 117 Playwright tests pass with the new strings.
Ben, 2026-09-11: Kaitiaki is to be removed everywhere but the repository name. The previous commit renamed what a customer sees; this one renames what a Butler or a developer sees. The CLI binary is now `inheritance`: `inheritance init`, `seal`, `bundle`, `recover`, `serve`, `html`, `demo`, `status`, `verify`. Its help text, examples, default data directory, man page title and the Makefile, .gitignore and Playwright setup follow. The bundle metadata key written into every README.txt and PDF is `inheritance-version`. The service runbooks, the guide, the README, CONTRIBUTING, NOTICE, AGENTS.md and every code comment say Bitcoin Inheritance. The constant behind the BIP-138 decoy count is INHERITANCE_SECRET_ENTRIES. Left alone on purpose: the repository name and its URL; the wayfinder decision records under docs/wayfinder, which are history; and older CHANGELOG entries, which describe releases as they shipped. A new changelog entry announces the rename. Go tests pass in all eleven packages; the 117 Playwright tests pass with the renamed binary.
The guide's "What Is Public" said the script type, threshold and derivation paths are readable. That is true of the threshold format, whose readable prefix is the descriptor with keys and fingerprints removed. It is false of the one-key format: BIP-138 encrypts the whole descriptor and leaves only the BIP138 marker, seven key slots and any uncommon paths in the clear. The section now names the OP_RETURN output and lists what an observer can read in each format. The descriptor page said "Two ways" above two collapsible options, one of them closed, so a reader saw one way. The intro now names both.
… blocks scroll on phones The guide (internal/html/docs-content/en.md) was upstream's text with the name swapped. It is now Bitcoin Butlers' own: what the tool is for (the instructions around the seeds, never the seeds), the two tools on this site, the real page labels, the estate insert, the annual drill, and the owner key and descriptor backup as full sections. Every h3 carries an anchor, so the contents list is complete. All 30 existing anchors are kept. Short sentences, no em dashes, no idiom. The descriptor claim was too loose in four places: "lose the descriptor and the keys alone will not open the wallet". With every key still in hand, the descriptor can be rebuilt. The loss that locks a wallet is one key together with the descriptor, even when the remaining keys are enough to sign. Fixed in the guide, the descriptor page intro, the estate insert template and the changelog entry. A wide <pre> made the whole page scroll sideways at 390px: the owner-key commands in the guide, and the Core command on the descriptor page. pre now scrolls inside its box, and the owner-key commands sit on shorter lines.
A two-axis review of the rewrite found statements that contradict the code. Each is now aligned with the source: - A browser-made bundle of 10 MB or less holds no separate MANIFEST.age; the archive sits inside recover.html (internal/wasm/create.go). The guide no longer tells guardians to keep or send a file they do not have. - BIP-138 entries are derived from keys, never the keys themselves, and padding to seven applies only below seven keys (bip138.ts). - The descriptor page reads back the threshold and the paths, not the script type (descriptor-app.ts). - The browser time lock takes a number and a unit; a date is CLI-only. - The recovery button reads "Unlock and Recover" at runtime (en.json). - The CLI has eleven commands; the guide names the six that matter and points at --help for the rest. - The page has no chain search of its own; a technical person can search for the fingerprint tag. - descriptor.html's own "How this works" bullet claimed the policy stays readable in both formats. It now says which format keeps what readable. Also from the review: instructions over 20 words split, passives given an actor, future tense removed, the last idioms replaced, "descriptor backup" used as CONTEXT.md defines it, "the chain" and "encrypted archive" used as the one word for each thing, and the estate letter told apart from the estate insert. The estate insert template carries the one canonical descriptor sentence.
Reverses the fixed seven from 89f7e07, on Ben's standing order of 2026-09-22 to ride the standards wherever one fits. BIP-138 tells an encoder to pad the secret count to a bucket, so that counting the entries does not count the cosigners. We wrote a fixed seven. It covered every wallet up to seven cosigners, which was the point, and it also marked our backups as ours among all BIP-138 backups. The buckets do the same job and blend in. A 2-of-3 now pads to five rather than seven, which is 64 bytes and about 64 sat cheaper: 655 bytes rather than 719. A 3-of-7 pads to ten and keeps its cover. This also closes a leak in the old code. padWithDecoys returned the list unpadded once the key count passed the target, so a wallet with eight or more keys published its exact cosigner count. The guide said so in plain words. Every wallet now lands on a bucket. The vector document loses its reference to the unmerged pull request. BIP-138 has a number and the status Draft. make test-ts passes 38 of 38, including the upstream byte-compatibility tests and every BIP-138 conformance vector. go test ./... passes.
addSection drew the heading with CellFormat, which does not wrap. A heading longer than the line was clipped at the right margin and the rest of the words were dropped. On page 1 of every bundle the English heading "OTHER GUARDIANS WHO HOLD PIECES (contact them to coordinate a recovery)" lost its closing bracket. MultiCell wraps and carries the grey fill onto the second line. The fix covers every section heading in every language, not only the one that was visibly broken. Found while prototyping where the recovery steps land in a bundle. Verified by regenerating a demo bundle and rasterising page 1.
The maker's WASM needs to write a chain payload, and the maker is Go. The two formats lived only in TypeScript, so this brings them across. New package internal/descriptorbackup: - BIP-138, encode and decode. Passes all six of the BIP's own vector files, 57 subtests: the decryption secret and every individual secret, derivation path encoding, individual secret encoding, content types, whole backups and the cipher itself. Entry counts pad to the standard buckets. - The threshold format, encrypt only. The heir-side recover page stays in TypeScript so a grieving heir never loads the maker's WASM to read a backup. Reproduces the published mainnet vector: stripped descriptor, encrypted key block, lookup tags and total length. The String content item needed a fix the round trip caught. The BIP says "For all TYPE values except 0x01, TYPE_LENGTH MUST be present", and the first version omitted it for 0x03. A TypeScript reader then failed to parse the payload, and because its decrypt loop wraps parsing in the same try as the cipher, it reported "none of the keys you supplied can open this backup". The key was fine. See the note in the wayfinder ticket. Two upstream quirks are reproduced on purpose and pinned by tests: NumberToBytes(0) writes no bytes, and sortsBefore compares JavaScript's string form of a byte array rather than its bytes. Both run on multisigbackup.com, so changing either would make our text unreadable there. make test-xlang is new and proves the two implementations open each other's backups, in both formats, including that a String note does not stop a descriptor-only reader. Run it after any change to either side. golang.org/x/crypto moves from indirect to direct. No new dependency: Shamir reuses hashicorp/vault, which was already here and was verified to be byte-compatible with the npm library the TypeScript uses. go test ./... and make test-ts both pass.
decryptBackup wrapped the cipher and the payload parser in one try, so a backup that decrypted correctly with the heir's key, but held contents this version cannot read, fell through every entry and ended at "None of the keys you supplied can open this backup". The key was fine. An heir reading that goes looking for keys they do not need, at the worst possible moment, and may decide the backup is lost. The two failures are now separate. A cipher failure still means a decoy or another key's entry, so the loop moves on. A failure after a successful decryption says what it is: "Your key opened this backup, but this page cannot read what is inside it", followed by the reason. Mirrored in the Go port, which propagated the parse error but did not say the key had worked. Found while porting the crypto, where a malformed String item produced exactly this message and hid the real cause. Three tests: contents this version refuses must not blame the key, a genuinely wrong key must still say so, and the same pair checked across both languages through make test-xlang, which now builds a backup whose key opens it and whose contents it cannot read. The threshold path in descriptor.ts was checked and needs no change. Its catch wraps the cipher alone, and it reports how many shares opened against how many are needed.
The maker is about to ask an owner for their recovery steps, their key
locations and a chain copy. This is the half of that with no pixels in it: the
config carries the three texts and each one lands where it was decided to go.
recovery steps README.txt and README.pdf, in the open
chain copy README.txt in full, README.pdf as a QR code,
with a blank line for the transaction id
key locations sealed inside the encrypted archive
The split is not arbitrary. README.txt is built to be forwarded: a guardian's
own copy tells them to send it to whoever asks for their piece. So the method
and the chain copy may sit in it, because a lone guardian may safely hold them
and an heir with one bundle and one key needs them early. Key locations may
not, so they become WHERE-THE-KEYS-ARE.txt inside the archive, which opens
only when enough guardians combine.
The chain copy is a QR code on paper because a 2-of-3 payload runs to about
876 characters and nobody types that back correctly. Verified by rendering the
page and scanning it: the code returns the payload exactly, and the guardian's
own piece QR still scans beside it.
Two layout fixes found by looking at the printed page rather than trusting the
code. A 70mm QR does not squeeze into whatever is left at the foot of a page,
and fpdf does not break for an image placed at an explicit y, so the block asks
for room first. It asks before the heading, not before the image, so the
heading, the explanation, the code and the transaction line stay together. A
heading stranded on the page before its QR is hard to follow, and this is a
document somebody reads on the worst day of their life.
New strings are English only. The lookup falls back to English, then to the
key, so every other language keeps working.
Six tests cover the placements, including the one that matters most: a README
must never carry key locations. One more checks the QR is a real image at a
size a phone can read.
No maker.html change and no create-app.ts change. That is the next ticket.
Step 3 of the maker, and the reason this whole effort started. A DM asked how
the method is preserved beside the key, because you cannot engrave the
instructions into steel.
Six named prompts in two groups, and each group says where its text goes.
Recovery steps chain and bundles
what kind of wallet, what opens it, how to sign and send
People and places bundles only
where each key is, who to call first, anything else (repeats)
Six small questions beat one big empty box. An owner given a single blank
field writes "the bitcoin is in the wallet" and stops. Each box carries a
worked example as placeholder text, which clears on the first keystroke and
comes back if the box is emptied. The example is never a pre-filled value: a
value gets left in, and an heir told "Hannah has key 2" when there is no
Hannah is worse off than one told nothing.
The split is not cosmetic. README.txt is built to be forwarded, and a
guardian's own copy tells them to send it to whoever asks for their piece. So
the method may sit in it and the key locations may not. The locations go to
createArchive instead and are sealed inside the encrypted archive, where they
appear only when enough guardians combine.
Empty boxes contribute nothing, so an owner who skips step 3 gets exactly the
bundle they would have got before it existed.
The permanence line sits under the group it applies to rather than in the
collapsed block at the top of the page, where it is read before the owner has
anything to say.
An e2e test asserts the thing that must never silently regress: the method is
in the README and the key locations are not.
Go suite, make test-ts 38 of 38, make test-xlang 5 of 5, and all 117 Playwright
tests pass.
The chain half is not here. The browser cannot make a chain payload at all
yet, because nothing in main_create.go reaches internal/descriptorbackup.
The last two commits taught the browser to carry the owner's recovery steps
and a chain copy. The command line could not, so a bundle made with
`inheritance bundle` silently lacked what the same project made in a browser
would hold. This closes that.
Prose lives in project.yml, because `bundle` is re-run and the text has to
survive:
recovery_steps: |
A 2 of 3. Any two of the three keys can spend.
The older Coldcard needs firmware 5.1.
chain_payload: QklQMTM4...
The transaction id is a flag, `--chain-txid`, because it only exists after the
chain copy is published, which is after the bundles were first made. The flag
beats the stored value: a stored id was true when it was written, one passed
on the command line is being told to us now.
Key locations get no flag and no field, on purpose. They must be sealed inside
the encrypted archive, and the command line already has a way to put something
there: it is a file you seal. The browser needed a textarea only because it has
no filesystem step. Putting them in project.yml would leave key locations
sitting in plain text next to the project, which is the thing the split was
drawn to prevent.
Verified with the real binary rather than a unit test: a demo project gained
the two fields, `bundle --chain-txid` re-ran, and the README carries the
method, the chain copy and the id from the flag. `verify-bundle` still accepts
the result.
go test ./... and make test-ts 38 of 38 pass.
The second thing Ben asked for. The landing page told a reader to try a drill in four lines of text and showed them nothing. The steps now match what the maker actually does, including the step that asks what your heirs need to know, which the old four skipped because it did not exist yet. A dropdown under them pairs each step with a picture, folded away so the page stays short for anyone who does not want it. Five figures, all generated from our own build by make screenshots. One is new: the owner's-words step, captured empty on purpose. Empty is the honest picture, because the examples live in the placeholder text and they are the point of the figure. The figures already changed under us. bundles.png, maker-overview.png and recovery-1.png all moved when the maker gained a step, so this regenerates them rather than shipping pictures of a page that no longer exists.
The runbook, the drill and the estate insert still described a service where the only thing that went on the chain was a descriptor. Runbook: - Step 1 now asks the client what their heirs need to know, and says where each half goes. This is the part that dies with them, and it is the reason they are in the room. The Butler reads the examples aloud and does not write the answers for them. - Step 4 checks the fee rate before publishing. Publish under 5 sat per vbyte. A chain backup is never urgent, so waiting a day costs nothing. - A new step 5: write the transaction id onto every printed README after publishing, because the transaction did not exist when the bundles were made. It is proof, not the way in. - The tradeoff paragraph no longer says "only their keys can open it". That sounds like "only you" and is not the same thing. If the client chose any-one-key, any ONE of their keys opens it, including one they later stop using. - The budget now states its fee-rate assumption. About 2,700 sat for a 2-of-3 with 250 bytes of method, AT 2 SAT PER VBYTE. The same transaction costs about 13,500 sat at 10. The old figure said 5,000 to 8,000 sat and never said at what rate, which quietly made every placement above roughly 4 sat per vbyte lose money on the publish. Annual drill: two reads, not one. The chain copy and the bundle's own copy of the same text, compared. If they differ the bundles are older, and the answer is to say which is current rather than leave two answers in the world. Estate insert: says plainly that the transaction id is not the only way in. Every bundle carries the same text. Prices are unchanged. The tool was always free and the service was always the practice.
Pieter Wuille argued against this exact tool, in public, on the thread that announced multisig-backup. A reader will find that eventually. Better they find it from us, with the answer beside it. The guide gains a section that quotes him, agrees with him, and then reads the rest of what he said. He proposed hiding the data in backups you keep anyway. That is what the guardian bundles are, and every bundle already carries the same text the chain does. So the ordinary recovery has nothing to do with the chain at all, and the chain copy answers only the question the bundles cannot: what happens when every bundle is gone. It also says the half of his position that a quote would hide. He argued in favour of dropping Core's limit on this data and against node operators deciding what belongs in a block. He thinks it is a poor use of the chain. He does not think anyone should stop you. Both quotations were checked verbatim against the research file rather than from memory. The runbook gains the line a Butler says before the client agrees, and an instruction to wait afterwards. A placement is complete without the chain copy. A client who was talked into a permanent public record did not consent to it. No recommendation either way. Whether the risk is worth it is the client's call, and the page says so.
An heir holding one bundle and one of the wallet's own keys could not read the chain copy from the bundle they were holding. The text was in their README and nothing on the page would open it. The recovery page now has a panel for it, outside the numbered steps, because it is a second way in rather than a later step of the first one. It takes the text from the README, or a scan of the printed QR code, and one or more of the wallet's public keys. It reads both formats: a threshold backup needs k keys, a BIP-138 backup needs one. The reader ships in EVERY bundle, not only in bundles made when a chain copy already existed. The weight argument does not survive measurement: 24 KB against 894 KB, under 3 percent. The argument that does survive is time. A bundle made today must still read a copy published in five years, and re-issuing bundles to guardians is the most expensive thing in this system. Four browser tests, all with every non-file:// request aborted, so a page that quietly depends on the network fails. They cover a threshold backup with two of three keys, a BIP-138 backup with one, wrong keys, and missing text. The last two check the page blames the right thing, which is the same failure the Go port turned up in decryptBackup. make test 0 failures, make test-ts 38 of 38, make test-xlang 5 of 5, make test-e2e 122 passed.
… stalls The chain reader landed at the bottom of the page, collapsed and unnumbered, underneath three numbered steps that assume bundles. So the heir it exists for, the one who cannot reach enough guardians, met it below the dead end they had just walked into. Two changes, neither of which asks them anything. The intro says there are two ways in, once, before they start. An heir who already knows they hold a key does not have to discover that halfway down. Then the page watches. Once somebody has gathered two pieces and still does not have enough, the chain panel opens itself and says why it is there. It never closes again on its own, because somebody reading it should not have it taken away. Two pieces rather than one, because a personalized bundle arrives with the holder's own piece already loaded. One piece means "I opened my bundle" and says nothing about whether the guardians can be reached. Two means somebody is gathering and coming up short. The first attempt fired on every page load and was wrong for exactly that reason. A browser test builds a real demo project, opens one guardian's bundle, adds a second guardian's piece against a threshold of three, and asserts the panel opens and the line appears. make test 0 failures, make test-ts 38 of 38, make test-e2e 123 passed.
The drill recovered a quorum every year and proved the wrong thing. A Butler driving the recovery proves a Butler can recover. That is not what the client is buying. They are buying the confidence that these people, on a bad day, without a Butler, can do it. So the same step, with the keyboard in a different pair of hands. The least technical guardian present drives. The Butler does not touch it and does not answer a question the first time it is asked, because a real recovery has nobody to ask. Answer on the second ask, and write down that you had to. This costs no extra minutes in an hour that was already full. The list of places they hesitated is the drill's real output. Most of those are fixed by a sentence in the estate insert or a clearer briefing sheet, not by teaching the guardian. If the same pause appears two years running, the document is wrong and the guardian is fine. Pass/fail changed to match. A recovery the Butler drove is a demonstration and does not count as a pass. The written widow test stays. It tests whether the insert reads well, which is a different question from whether a guardian can work the tool.
Nothing stopped an owner clicking past every prompt. The placeholders made the boxes easy to answer. They did not make anyone answer. Two halves, for two people, only one of whom is in the room. The owner gets one line above Generate, and it never blocks. A gate on a free tool is satisfied by typing a full stop, and then the heir holds a bundle that passed the check and still says nothing. The line appears while the boxes are empty and goes when one word is written. The heir gets the truth. When the owner wrote nothing, the README says so, and says the bundle is not missing a page. Somebody holding a bundle with no instructions cannot otherwise tell whether that was a decision or a lost file, and at the moment they are reading it that difference matters. The first version gave the empty case the heading "HOW TO SPEND THE BITCOIN (the owner wrote this)", which claims something untrue on a page an heir reads in the worst week of their life. A test caught it. The empty case has its own heading now. Three tests: the empty README says so, a README with words does not, and the browser warning appears, clears on one word, comes back when cleared, and never disables the button. make test 0 failures, make test-ts 38 of 38, make test-e2e 124 passed.
The last piece of what the DM asked for. Until now the browser could carry the
recovery steps into the bundles and nowhere else: nothing in main_create.go
reached internal/descriptorbackup, so the chain half existed in Go and was
unreachable from a page.
rememoryEncryptChainCopy exposes it. The format is chosen for the owner rather
than offered: recovery steps present means BIP-138, because only BIP-138
carries a String item beside the descriptor. A descriptor alone may use the
threshold format, which stays byte-compatible with multisigbackup.com.
The maker gains a destination choice, bundles only or bundles and the chain,
and it defaults to bundles only. Picking the chain reveals the descriptor field
and a live size worked out from the real payload, so the sat figure is what the
client actually pays rather than an estimate.
This also removes a promise the page could not keep. The recovery-steps label
said "goes on the chain and in the bundles" whatever the owner chose. It now
says "bundles only" until they ask for a chain copy. That label is why nothing
has shipped to the site for twelve commits.
A TYPECHECK HOLE, and it is the more important half of this commit.
make test-ts ran tsc against tsconfig.test.json, which includes only
src/**/*.test.ts plus two crypto files. create-app.ts and app.ts were never in
it, and esbuild strips types without checking them, so nothing in this repo
typechecked the code that actually ships in a page.
Running the real project turned up five errors, four of them mine from earlier
commits: rememoryCreateArchive called with two arguments against a one-argument
type, recoverySteps set on a config type that did not declare it, and
new Error(msg, { cause }) which needs the ES2022 lib against this project's
ES2020. All fixed, and the types now describe what the WASM exposes.
make test-ts now runs both projects, so this cannot come back.
make test 0 failures, make test-ts 38 of 38, make test-xlang 5 of 5,
make test-e2e 125 passed.
Making a backup moved to Create Bundles when the maker learned to put the owner's words on the chain. This page kept a protect half that duplicated it, and an heir reading a transaction id off an estate insert had to load both. The protect half is removed, not hidden. The tab bar, the panel, the protect function, confirmOnChain, estateBlock, and every helper that only served them are gone, along with the dozen browser tests that drove them. The page is 112 KB, down from 137 KB. THE FILENAME DOES NOT CHANGE, and that is the point. The estate insert is printed and filed with a will, and it names bitcoinbutlers.com/tools/inheritance/descriptor.html. Somebody may read that page twenty years from now. Keeping the name means no redirect has to survive that long, and every printed insert keeps working. What changed is the page's job, not its address. The page now says where to make a backup, and points an heir holding a bundle at recover.html, which reads the same text with no internet at all. The service docs and the guide follow: the runbook sets the destination in Create Bundles rather than opening this page to protect, and reads the backup back here afterwards. The estate insert needed no change, because it always pointed here for recovery. The e2e suite was rewritten rather than deleted. Four tests that cover what is left: the page is a reading page and says where to make one, two keys open a threshold backup and one does not, a stranger key opens nothing, and a mistyped transaction id is refused before anything leaves the machine. They use the published mainnet vector, so they read the same bytes that sit at block 966450. Removing the protect flow left a dozen dead declarations behind. The real typecheck added in the previous commit caught every one. make test 0 failures, make test-ts 38 of 38, make test-xlang 5 of 5, make test-e2e 115 passed.
Confirmed that Bitcoin Butlers pays whoever runs the session, now that other Butlers can. Adds the booking-time alert, and the warning that no alert does not mean go ahead, because fees move between the booking and the session.
An heir opening this page has never seen a backup come off the chain and has no reason to believe it works. One button now fills in our own published backup, fetches it, and leaves them one press from the descriptor. Our own backup on purpose: published on mainnet on 2026-09-10 and recorded in docs/descriptor-backup-vector.md. The seeds behind the keys are the BIP-39 test vectors every wallet ships with, so nothing here is secret and nothing holds coins. The page says so. It fills two keys, not three. The backup is a 2-of-3, and using exactly the threshold shows that it opens with the minimum rather than needing every key. Two friction points found when the flow was proved on production: The transaction-id path is two steps and nothing said so. It now says so, under the Fetch button. Pressing Rebuild before fetching said "Paste the backup text, or fetch it by transaction id", which states the position rather than pointing at the way out. On the transaction-id path it now names the Fetch button. The note after a recovery only appears for our published backup. An heir recovering their own wallet must not be told it was a demonstration. Verified in a browser against mainnet: 545 characters fetched, two keys opened it, and the descriptor matches the published vector exactly.
An owner who has never seen a recovery cannot tell a good bundle from a bad one. A test run makes throwaway bundles from a sample file so they can practise, and marks every one so a drill can never be mistaken for the real thing. An offer, not a gate. A required gate was rejected on the test-recovery ticket, because people fake a gate to get past it. The nudge appears once for an owner who has generated nothing on this browser, it never blocks Generate, and generating anything at all retires it. What a test run does NOT carry. Not the owner's files: a throwaway bundle holding real secrets could not be thrown away. Not the people and places either, which is the most sensitive thing an owner writes. The stamp is in two places. The project name is prefixed TEST, which is what README.txt, the printed PDF and METADATA.yaml all print, so a guardian sees it in the first lines. The saved filename is prefixed too, through one function shared by the list on screen and the download, because a test bundle that looked real in the list is exactly the mistake the stamp prevents. Verified in a browser by pulling a generated bundle apart: the README reads "recover files for: TEST recovery-2026-09-23", the file saves as TEST-bundle-alice.zip, and a secret typed into the owner's words is provably absent from the zip. Reloading shows no nudge and no test mode. make test-ts 38 passed, 12 Go packages passed, make build and make html clean.
Two real bugs the spec review found. The try-it flag survived a real fetch. The fetch button fills the textarea programmatically, which fires no input event, so the listener that clears the flag never ran. A reader who pressed Try it and then fetched their OWN transaction was told their recovery was a demonstration. The flag is now set from the transaction id that was actually fetched. The TEST filename stamp was read at click time. Generating a test run, turning it off, then downloading gave a list saying TEST- and a file saved without it. The stamp is now fixed when the bundles are generated and never recomputed. Standards misses. AGENTS.md requires a Playwright test for any change to maker.html and there was none: e2e/test-run.spec.ts covers the offer, the stamp, the absence of the owner's material, and the stamp surviving the mode being turned off. CHANGELOG.md was untouched and now records both features. Two rhetorical questions in the new copy are gone, against the standing rule. make test-ts 38 passed, 12 Go packages passed, 7 Playwright tests passed.
The chain copy cannot be rewritten, so an owner who revises their instructions leaves an older copy on the chain for good. An heir holding a bundle and a chain copy that disagree had no rule at all. Ben decided it: the bundle wins. The chain copy is the last resort for the day every bundle is gone, not the authority. Bundles carry created in METADATA and print it in the README footer, so the heir has a date to check rather than a rule to remember, and nobody is asked to compare a timestamp with a block height. Three places, each where a reader meets the second copy. The bundle README beside the chain payload. A standing line in the recover page's chain reader. And one line after a successful read, which appears only then: an heir holding one copy has nothing to reconcile, and a conflict they do not have is noise at the worst possible moment. A bundle with no chain copy says none of it. Two tests. A Go test covering the README in both states, and a Playwright test covering the line appearing on a read and going away on a failure. make test-ts 38, 12 Go packages, 119 Playwright, translations clean.
Step 4 had the diagnosis backwards. It assumed the bundles were the older copy when the two differ. It is the other way round: a bundle is re-issued whenever the owner revises anything, and a chain copy can never be re-written, so the chain copy is the one that goes out of date. Three outcomes now, and they are not the same problem. The words differ and the descriptor matches. Expected, and no action. The owner revised their instructions, the bundles carry the new ones, and the chain copy holds the words of the day it was published. That is the design. The descriptor differs. This is the failure. The chain copy names a wallet that no longer exists, so on the day every bundle is gone it leads an heir to nothing. Republish, write the new id on the insert, and say the old one stays on the chain forever and is now wrong. The only case that triggers a republish, and Bitcoin Butlers pays for it. The copy will not read at all. Not stale, broken. Repair it before leaving. Pass and fail now say which is which, so a Butler does not fail a client for the expected state. Step 5 gains the thing that bites: a re-seal re-issues EVERY bundle, because the maker generates a fresh secret and splits it anew, so one guardian's old piece and another's new piece cannot be combined. A client left holding a mix has no working plan and nothing on the outside of the bundles would show it. Collect and destroy the old media in the same session.
An owner whose plan changes had no service to book and no runbook to follow. Priced the way every Butler service is priced: the Butler's hourly rate times the session length. 120 minutes until enough have run to say otherwise, and the real elapsed time goes on the insert each time so the number is set by evidence rather than a guess. Shorter than a placement because the deciding is already done; the sealing, printing, insert and live test recovery are not. Ben settled the division of labour: Butlers help create the new bundles and the owner places them. That is the same rule as a placement. We never hold a bundle and never touch a guardian's media, the guardians are not in the room, and they will not all be reachable on one day, so a Butler cannot collect or destroy anything. What a Butler owes instead is the list. Each guardian, what they hold now, and what to swap it for, with the plain warning that an old bundle still opens, still looks right, and counts for nothing toward the threshold until it is swapped. The failure this exists to prevent: four guardians swap and the fifth does not, so a plan that reads 3-of-5 is really 3-of-4 and nobody finds out until an heir needs it. The drill catches it, so the session books one. The chain is republished only when the descriptor changed, per the revision rule. A words-only revision leaves it alone. The estate insert gains a line about struck-through rows, and the placement runbook now says why the media column exists: it is the retrieval list an owner needs the day they revise.
Ben, 2026-09-23: it is essentially the same as the first one, because you have to go through and test everything again anyway. I had written 120 minutes on the reasoning that the deciding is already done. The deciding is not the work. Sealing every bundle again, printing every README again, placing them again and test recovering them again is the work, and none of it gets shorter because the guardians were chosen last year. The runbook now says so, and says what a short revision actually costs: the thing that gets skipped is the test recovery.
Three things were stale, and one of them was dangerous. The guide's Creating Bundles had three steps. The maker has four, and the missing one is the whole reason this feature exists: What Your Heirs Need To Know. It is documented now, including why the two groups of questions travel differently. The method goes into every bundle and onto the chain if asked. Where the keys are goes into WHERE-THE-KEYS-ARE.txt inside the encrypted archive, and never onto the chain. How to Make One described a page that no longer exists. It walked through descriptor.html's protect flow, which was removed when that page became the heir's reading page. The descriptor backup is made in Create Bundles now, and the guide says so and points at the try-it button for reading one back. Keeping Bundles Current said old bundles cost nothing to leave lying around. That is the dangerous one. Every generate makes a new recovery key and splits it again, so an old piece and a new piece cannot combine. Three guardians swapping out of five leaves a 3 of 3, and nothing on the outside of a bundle shows it. An old bundle still opens, still looks right, and counts for nothing. It also conflated the two kinds of change. The chain copy is only republished when the WALLET changes. A words-only revision leaves it alone, because the chain copy still tells the truth about the method. And if a bundle and the chain copy ever disagree, the bundle wins. The README's bundle table never mentioned the owner's words or the chain copy, and never said where the key locations live. It does now. Adds the owners-words screenshot to the guide. It has existed since the feature shipped and nothing referenced it.
Two screenshots framed the wrong step, and one of them proved it: owners-words and tlock-setup were BYTE IDENTICAL. Both framed card index 2. When step 3 became What Your Heirs Need To Know on 2026-09-22, Generate Bundles moved to index 3 and neither figure followed it. So the guide showed the owner's-words card under the time lock heading, and again under the bundles heading. Nobody would have caught it by reading, because both pictures are of a real card. The indices are fixed, with the reason written beside them so the next step insertion does not repeat it. Regenerating also refreshed seven figures that had drifted from the current pages. The bundle contents section never mentioned the owner's words, so the guide told a reader to write them and then described a bundle that did not carry them. It now says README.txt holds the method and the chain copy, and that WHERE-THE-KEYS-ARE.txt is inside the encrypted archive rather than among the bundle's files, so a forwarded bundle reveals nothing about where the keys live. The README's maker caption named three steps when there are four. Every internal anchor in the guide resolves, and every figure it references exists. make test-ts 38, 12 Go packages, 119 Playwright.
Three checks on committed files, so they are deterministic and ride the go test ./... that CI already runs. No two figures may be byte identical. Two distinct captures producing the same bytes means they framed the same thing, which is exactly what happened when a step was inserted into maker.html and two screenshot tests kept framing the old card index. The guide showed the owner's-words card under the time lock heading for a day, and a reader could not tell, because it is a picture of a real card. Every figure the guide references must exist. The guide must document one step per step in the maker, numbered in order. It described three steps for a four-step page for a day, so a reader looked for a Generate button in the step that asks what their heirs need to know. Regenerating the figures and diffing them is NOT checked, and deliberately: five of the eleven differ between two runs with identical inputs, so that check would be flaky. The test comment says so, to stop someone adding it.
Every bundle used to list the other guardians by name and email, in README.txt, README.pdf and the personalised recover.html. One bundle in the wrong hands named the rest of the people to approach, and a README is built to be forwarded: its own text tells the holder to send it to whoever asks for their piece. Everything the owner writes now goes inside the encrypted archive, so it opens only when enough guardians combine their pieces: HOW-THE-WALLET-WORKS.txt, WHERE-THE-KEYS-ARE.txt and CHAIN-COPY.txt. The chain copy's transaction id travels with its ciphertext, because the id alone fetches the same payload off the chain. Publishing now happens BEFORE sealing. The archive is encrypted and its key split among the guardians at seal time, so an id discovered after that can never be added. The maker gained a field for it, and nothing had ever passed that argument, so every CHAIN-COPY.txt shipped blank. The quorum stays visible. With no roster, a number names nobody, and it tells an honest guardian when to stop asking. Anonymous mode is gone. It existed to stop guardians seeing each other, which every bundle now does by default. Removing it also fixed a bug it was hiding: a key rename left the PDF printing the literal text recover_anon_step3 where the recovery steps belong. The structs that build a README no longer have a field that could hold any of it, which is a stronger guarantee than a test. Sealing also stops writing the owner's words into their own manifest/ folder, where a re-seal used to overwrite an edit they made by hand. Guardrails: disclosure tests on all three surfaces including the PDF, which had none; a scan that every translation key the generators ask for exists; and a browser test that recovers a bundle and reads the sealed file, because asserting absence alone passes when the maker silently drops the owner's writing. Docs, both runbooks, the drill, the security review and ten screenshots brought in line. The placement runbook's step 5 and the annual drill's chain check were both backwards.
about.html told every reader "Every bundle includes contact details for the other guardians, so they can coordinate without you", and listed the contact list among a bundle's files. Both were live and both were the opposite of what the tool does. The maker's Recovery steps box said nothing about being sealed while the People and places box beside it did, so a client read the contrast and concluded their method was readable in a bundle. Both are sealed now, and the chain warning stops implying the chain is the only exposure. Found by reading the pages rather than grepping them. The grep sweep that preceded this deploy missed all three, because none of them used the phrases it searched for.
The runbook contradicted itself. Step 1 had the client drag files into manifest/ and then said the maker asks for their words. Step 2 put the descriptor on the chain "in Create Bundles, where you already are" and had a transaction id pasted into it. Step 3 then sealed with `inheritance init`. Those cannot both be true: the page makes the bundles, and the id is entered in the page. Ben's call: the browser makes the bundles, always. The command line makes the same bundles and stays for whoever wants it, but a session never mixes the two. The transparency moment changes with it. METADATA.yaml is written only by the command-line path, so step 3 now opens a bundle's README.txt and reads it aloud. That does more work than the old ritual: it is the page the guardian will actually read, so the client hears what that person can and cannot see, and the Butler points at what is absent by name. The CLI guide is deleted. Every one of its 282 commands named a binary that does not exist, and its install section pointed at upstream's releases and homebrew tap, so anyone following it installed a build with none of this work in it. The web guide's own CLI section already told the truth: built from source, no published binaries. That stays. Also corrected: step 1 still said the method "travels to the chain and into every bundle". Both halves are sealed now.
docker-compose.yml at the repo root pulled ghcr.io/eljojo/rememory:latest. Anyone who ran `docker compose up` here got upstream's build, whose bundles still name every guardian in every README with their contact details. The README sent people to eljojo.github.io for Create Bundles and Documentation, to upstream's homebrew tap and release binaries for the CLI, and to upstream's demo bundles to practise a recovery. There is now a Dockerfile that builds from source. It installs Go and Node in the build stage, compiles the TypeScript and the maker's WebAssembly, and ships the binary alone. Verified: the image builds, the container serves, and the compose file builds rather than pulls. The self-hosting guide, the README and the hide-quorum design doc name the `inheritance` binary. The serve command's environment variables are INHERITANCE_PORT, INHERITANCE_HOST, INHERITANCE_DATA and INHERITANCE_MAX_MANIFEST_SIZE. Ports bind to 127.0.0.1 with a note to put TLS in front. The comparison table used to read "Only eljojo/rememory includes contact details in each bundle". It now says this fork removed that, and why. Left alone deliberately: the rememoryParseShare family are live JavaScript globals the WASM module registers, not prose. Renaming them touches the recovery path and is its own change. Also the REMEMORY_TEST_ variables, which gate tests and CI. Also precise now: the runbook said mixing the page and the command line "cannot carry the transaction id". The page's Save project.yml writes name, threshold, language and guardians; seal reads recovery_steps, chain_payload and chain_txid, which it never writes. Bundles sealed that way carry no HOW-THE-WALLET-WORKS.txt and no CHAIN-COPY.txt at all.
Module path, command directory, seventeen JavaScript globals, the share markers, the compact prefix, the test and self-host environment variables, the release workflow's artefacts, the update nudge and the about page. Two formats are read by people and were renamed with care. A share now reads BEGIN INHERITANCE SHARE and its compact form starts IH, and BOTH parsers still accept the old spellings, in all three places they live. A guardian's README is not reissued because a project was renamed. Three copies of that format is what made this dangerous. The markers exist in internal/core/share.go, in crypto/share.ts, and a third time as a regex in app.ts. Renaming the first two left the browser unable to read a pasted README, and nothing failed until a paste came back empty in a browser test. TestEveryCopyOfTheShareFormatAgrees now pins all three, and was verified to fail when one half of one regex drifts. The release workflow's Homebrew step is gone: it published a formula to eljojo/homebrew-rememory, a repository we do not own, so it could never have worked here. Two live bugs fell out. The update nudge told anyone running a six-month-old saved copy to go and download upstream's release, and only suppressed itself on upstream's host. And the about page read "Bitcoin Inheritance is the te reo Maori word for a guardian", a rename artefact where Kaitiaki had been substituted, live on the site. Kept deliberately: NOTICE and LICENSE. Apache-2.0 section 4(c) requires retaining attribution notices in derivative works and 4(d) requires reproducing a NOTICE file's contents. Being free software is the reason that attribution stays, not a reason to drop it. The changelog entries below the fork also stay: they describe releases that genuinely were ReMemory, and rewriting them would falsify the record. Still there: eljojo/no-autopilot@v1, a third-party GitHub Action in the PR review workflow. That is a vendor choice, not a leftover reference.
"Open source (Apache-2.0), built on Rememory" was the one string the sweep missed, in both the markup and the translation file. Attribution stays where the licence requires it, in NOTICE.
NOTICE was eighteen lines with a list of fork changes that had gone stale months ago. Apache-2.0 section 4(b) wants modified files to carry notice that they changed; the Git history is that notice, and says it accurately. Eight lines now: our copyright, their copyright, the derivation, the licence. The README opened by saying 'Rememory does the heavy lifting; this fork adds a small set of changes', which stopped being true, then carried a second title and the upstream README wholesale. One <sub> line of attribution now, pointing at NOTICE. Removed rather than renamed: the 'Why I Built This' section. It is Jose's story about a documentary and his cycling concussions, in the first person. Renaming the product inside it would have put his motivation in our mouth, which is worse than leaving it.
The markers and the compact prefix were typed out three times, in two languages. Go now generates crypto/share-format.ts and both TypeScript files import it, so there is nothing left to type and nothing to drift. That retires the guardrail added a few commits ago. It read the TypeScript looking for the right strings, which only shouts after somebody has already typed the wrong thing, and it passed once while half of one regex was broken. It is replaced by one question: TestShareFormatTSIsCurrent regenerates and compares, and says how to fix it. Verified by changing the Go constant and watching it fail. The README is rewritten rather than patched. It opened by saying upstream did the heavy lifting and this was a small set of changes, carried a second title, a comparison of nine other Shamir tools, and a personal story that was not ours. 328 lines to 193. It now leads with the thing that makes this different: what a guardian can and cannot see. Then the sealed files by name, the publish-before- generate order for the chain copy, what a bundle holds, and what this does NOT protect against, which nothing in the old one said.
Nothing in the guide had a figure of the chain step, so the order that
matters most there, publish BEFORE you generate, was words only. And
every recovery figure used a fixture with no owner texts, so no picture
ever showed the sealed files by name, which is the point of sealing
them. Both exist now, and the guide shows them where a reader asks.
Taking the first picture found a live layout bug. #chain-fields had no
CSS of its own, and `label { display: block }` only applies inside
.form-group and .words-group, so the descriptor label, its box and its
hint ran together as one line of prose on the shipped page. It has been
like that as long as the field has existed. Styled to match
.words-group, which is the same kind of form in the same card.
Every figure regenerated against this build, and a stray duplicate
</p> removed from the bundle-contents section.
The guardian PDF still carried the upstream palette: a dusty-blue band, pale green panels and a lavender recovery-rule box. It is the Bitcoin Butlers warm family now, deepened for ink, because the web gold #FBDC7B is too light to read as a band on white paper. It stays light rather than going to our dark mode. This document is made to be printed: it carries a QR code to scan off paper and tells its holder they can post it as a letter. A dark page prints as a black page. Each guardian still gets their own colour, so a stack of printed pages can be told apart. Also removed: the paragraphs in the Dockerfile, the compose file, the self-hosting guide and the README explaining why not to pull upstream's image. Ours is the only instruction now. A reader does not need a second rule about a thing we are not asking them to do. Verified: the image still builds and the container still serves.
The docs told an owner to put the list in their will and never said why that is safe. It is safe for the same reason it is not in the bundles: on its own it is names and nothing else. To an heir it is the only route to the bundles. Added to the guide, the README and the estate insert.
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.
Stacked on #1. When that merges, GitHub retargets this to
main.This is the encryption layer for the planned on-chain descriptor backup. No page uses it yet, so nothing a client sees changes.
Two formats, because they answer different questions
crypto/descriptor.tscrypto/bip138.tsThe relay research put the reliable OP_RETURN budget at 600 data bytes, so those sizes are pinned by a test rather than left to be discovered by a transaction that will not relay.
Why the tests are the point
The promise to a client is that recovery never needs Bitcoin Butlers. That only holds while our bytes match the tool they would fall back to, so both suites compare against a second implementation:
testdata/descriptor-vector.jsonwas generated by running the upstream multisig-backup source with its 16-byte secret stubbed. Our encoder reproduces its encrypted block and lookup tags exactly, and our decoder opens its blob with any 2 of 3 keys. The Shamir share bytes are not reproducible because splitting draws fresh randomness, so those are covered by decryption instead.testdata/bip138/holds the draft's own vectors, including its deliberate-failure cases.Two upstream quirks are reproduced on purpose and pinned by tests, because "fixing" either one would make our ciphertext unreadable in the fallback tool:
Checks
make test-tstype-checks and runs both suites, and CI now runs it on every push.make test-tsgo test ./...make test-e2emake lintThe lint fix in
create-app.tswas already failing on this branch before these changes. It uses the sameBlobPartcast the rest of that file uses.