diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0c98fb9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +node_modules +dist +demo-recovery +demo-tlock +test-results +playwright-report +inheritance +inheritance-cjk +inheritance-data +*.zip diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b598165..fa1b27f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,9 @@ jobs: if: steps.playwright-cache.outputs.cache-hit == 'true' run: npx playwright install-deps + - name: Test descriptor backup formats + run: make test-ts + - name: Run Playwright E2E tests run: make test-e2e diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 784ae3c..7e25de1 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -48,7 +48,7 @@ jobs: run: make build - name: Generate HTML files - run: ./rememory html site -o _site + run: ./inheritance html site -o _site - name: Add Google site verification file run: | diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml index 31a27b6..73dfff1 100644 --- a/.github/workflows/pr-review.yml +++ b/.github/workflows/pr-review.yml @@ -14,4 +14,4 @@ jobs: steps: - uses: eljojo/no-autopilot@v1 with: - guidelines-url: 'https://github.com/eljojo/rememory/blob/main/AGENTS.md' + guidelines-url: 'https://github.com/Bitcoin-Butlers/kaitiaki/blob/main/AGENTS.md' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f364169..9b631a4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,12 +68,12 @@ jobs: - name: Generate standalone HTML files run: | - ./dist/rememory-linux-amd64 html create > dist/maker.html - ./dist/rememory-linux-amd64 html recover > dist/recover.html + ./dist/inheritance-linux-amd64 html create > dist/maker.html + ./dist/inheritance-linux-amd64 html recover > dist/recover.html - name: Generate demo bundles run: | - ./dist/rememory-linux-amd64 demo demo + ./dist/inheritance-linux-amd64 demo demo cd demo/output/bundles zip -r ../../../dist/demo-bundles.zip *.zip @@ -82,53 +82,6 @@ jobs: cd dist sha256sum * > checksums.txt - - name: Generate Homebrew formula - run: | - VERSION="${{ github.ref_name }}" - VERSION_NUM="${VERSION#v}" - SHA_DARWIN_ARM64=$(grep 'rememory-darwin-arm64$' dist/checksums.txt | awk '{print $1}') - SHA_DARWIN_AMD64=$(grep 'rememory-darwin-amd64$' dist/checksums.txt | awk '{print $1}') - SHA_LINUX_ARM64=$(grep 'rememory-linux-arm64$' dist/checksums.txt | awk '{print $1}') - SHA_LINUX_AMD64=$(grep 'rememory-linux-amd64$' dist/checksums.txt | awk '{print $1}') - cat > dist/rememory.rb << FORMULA - class Rememory < Formula - desc "A digital safe with multiple keys, held by people you trust" - homepage "https://github.com/eljojo/rememory" - version "${VERSION_NUM}" - license "Apache-2.0" - - on_macos do - on_arm do - url "https://github.com/eljojo/rememory/releases/download/${VERSION}/rememory-darwin-arm64" - sha256 "${SHA_DARWIN_ARM64}" - end - on_intel do - url "https://github.com/eljojo/rememory/releases/download/${VERSION}/rememory-darwin-amd64" - sha256 "${SHA_DARWIN_AMD64}" - end - end - - on_linux do - on_arm do - url "https://github.com/eljojo/rememory/releases/download/${VERSION}/rememory-linux-arm64" - sha256 "${SHA_LINUX_ARM64}" - end - on_intel do - url "https://github.com/eljojo/rememory/releases/download/${VERSION}/rememory-linux-amd64" - sha256 "${SHA_LINUX_AMD64}" - end - end - - def install - bin.install Dir.glob("rememory-*").first => "rememory" - end - - test do - assert_match version.to_s, shell_output("#{bin}/rememory --version") - end - end - FORMULA - - name: Upload dist artifacts uses: actions/upload-artifact@v7 with: @@ -168,9 +121,9 @@ jobs: VERSION="${{ github.ref_name }}" # Push architecture-specific images - docker tag rememory:latest "${IMAGE}:${VERSION}-amd64" + docker tag inheritance:latest "${IMAGE}:${VERSION}-amd64" docker push "${IMAGE}:${VERSION}-amd64" - docker tag rememory:latest-arm64 "${IMAGE}:${VERSION}-arm64" + docker tag inheritance:latest-arm64 "${IMAGE}:${VERSION}-arm64" docker push "${IMAGE}:${VERSION}-arm64" # Create and push multi-arch manifests @@ -202,32 +155,18 @@ jobs: run: | # Create draft release with assets (drafts can be modified) gh release create ${{ github.ref_name }} \ - dist/rememory-linux-amd64 \ - dist/rememory-linux-arm64 \ - dist/rememory-darwin-amd64 \ - dist/rememory-darwin-arm64 \ - dist/rememory-windows-amd64.exe \ + dist/inheritance-linux-amd64 \ + dist/inheritance-linux-arm64 \ + dist/inheritance-darwin-amd64 \ + dist/inheritance-darwin-arm64 \ + dist/inheritance-windows-amd64.exe \ dist/demo-bundles.zip \ dist/checksums.txt \ dist/maker.html \ dist/recover.html \ - dist/rememory.rb \ --generate-notes \ - --notes "**You can use ReMemory without installing anything.** Download \`maker.html\` from the assets below, save it to your desktop, and open it in any browser to create bundles." \ + --notes "**You can use Bitcoin Inheritance without installing anything.** Download \`maker.html\` from the assets below, save it to your desktop, and open it in any browser to create bundles." \ --draft # Publish the release (now it becomes immutable) gh release edit ${{ github.ref_name }} --draft=false - - name: Update Homebrew tap - env: - TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} - run: | - git clone https://x-access-token:${TAP_TOKEN}@github.com/eljojo/homebrew-rememory.git /tmp/tap - mkdir -p /tmp/tap/Formula - cp dist/rememory.rb /tmp/tap/Formula/rememory.rb - cd /tmp/tap - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add Formula/rememory.rb - git commit -m "Update rememory to ${{ github.ref_name }}" - git push diff --git a/.gitignore b/.gitignore index d2c78e4..ed193cf 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ /internal/html/assets/tlock-recover.js /internal/html/assets/app-selfhosted.js /internal/html/assets/create-app-selfhosted.js +/internal/html/assets/descriptor-app.js /internal/html/assets/app-tlock.js /node_modules/ /e2e/playwright-report/ @@ -22,5 +23,6 @@ /maker.html .claude /rememory-data -/kaitiaki -/kaitiaki-cjk +/inheritance +/inheritance-cjk +/.test-build/ diff --git a/AGENTS.md b/AGENTS.md index 62ba585..12556b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,9 +2,9 @@ This file provides guidance for contributors and coding agents in this repository. -## What is Kaitiaki +## What is Bitcoin Inheritance -Kaitiaki encrypts files with [age](https://github.com/FiloSottile/age), splits the decryption key among trusted friends using Shamir's Secret Sharing (via HashiCorp Vault's implementation), and gives each friend a self-contained offline recovery tool (`recover.html`) that works in any browser without servers or internet. +Bitcoin Inheritance encrypts files with [age](https://github.com/FiloSottile/age), splits the decryption key among trusted friends using Shamir's Secret Sharing (via HashiCorp Vault's implementation), and gives each friend a self-contained offline recovery tool (`recover.html`) that works in any browser without servers or internet. ## Ownership Mindset @@ -144,7 +144,7 @@ For `recover.html`, no WASM is needed — all crypto is native JavaScript bundle ### Bundle generation -Each friend's ZIP bundle contains: `README.txt`, `README.pdf`, `MANIFEST.age`, a personalized `recover.html` (with their share pre-loaded and contact list embedded), and `OWNER.age` when the creator supplied an owner key (the passphrase encrypted to their age recipient — see `internal/core/owner.go`). Generated by `internal/bundle/`. +Each friend's ZIP bundle contains: `README.txt`, `README.pdf`, `MANIFEST.age`, a personalized `recover.html` (with their share pre-loaded), and `OWNER.age` when the creator supplied an owner key (the passphrase encrypted to their age recipient — see `internal/core/owner.go`). Generated by `internal/bundle/`. The owner key is currently browser-only: the maker creates `OWNER.age` and `recover.html` accepts the owner identity, but `cmd/rememory` has no owner-key flag on create or recover. CLI recovery works with a stock `age` binary (see `docs/independent-recovery.md`). Adding CLI flags is a known, deliberate gap. @@ -237,7 +237,7 @@ See `CONTRIBUTING.md` for the full contribution guidelines, including the AI usa ### Changelog -`CHANGELOG.md` entries should focus on **what changed for the person using Kaitiaki**, not on implementation details. Lead with the user-facing outcome, then explain just enough context for it to make sense. +`CHANGELOG.md` entries should focus on **what changed for the person using Bitcoin Inheritance**, not on implementation details. Lead with the user-facing outcome, then explain just enough context for it to make sense. - **Good:** "Encrypted archives up to 10 MB are now embedded directly in `recover.html`. More people will be able to recover by just opening the HTML file." - **Bad:** "Raised `MaxEmbeddedManifestSize` from 5 MB to 10 MB." diff --git a/CHANGELOG.md b/CHANGELOG.md index d651b1e..b76deea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,80 @@ # Changelog -All notable changes to ReMemory are documented here. +## Unreleased + +- Sealing no longer writes the owner's own words into their project folder. + They are built in memory and handed straight to the archive. Before this, an + owner who edited `manifest/HOW-THE-WALLET-WORKS.txt` by hand lost the edit on + the next `seal`, and their words sat in plaintext on disk afterwards. + +- The guide, the README, the CLI guide, the security review and the three + service runbooks now describe the tool as it is. Twenty places said a bundle + lists the other guardians, that the owner's words sit in the open, or that + the chain copy rides in every README. A new section, **Who Can Read What**, + states the three tiers of access in one table, so there is one place to check + a claim against. +- **Publish the chain copy before you generate, and paste its transaction id + into the page.** A new field sits under the descriptor. This is the only way + the id reaches your guardians: the archive is encrypted and its key split + among them the moment you generate, so an id found afterwards can never be + added. Write it on your estate page as well. The guide and the placement + runbook used to say a missing id cost an heir nothing, because the bundles + carried the chain copy in the open. They no longer do. +- The guide's screenshots are regenerated. They showed the Named and Anonymous + tabs, and a contact list that no longer exists. + +- Anonymous mode is gone. It existed so an owner could keep guardians from + seeing each other's names, and every bundle now does that by default. What + was left of it was a second way to do the same thing, with its own tab, its + own share-count field, its own CLI flags and its own recovery instructions. + Guardians are named in the maker, and their names go nowhere but their own + bundle. `inheritance init` loses `--anonymous` and `--shares`. + +- A guardian's bundle no longer tells them who the other guardians are. The + `README.txt`, the `README.pdf` and the personalised `recover.html` listed + every other guardian by name and email, so one bundle in the wrong hands + named the rest of the people to approach. All three surfaces now name their + own holder and nobody else. Who holds a piece belongs in the owner's will or + their estate insert, where estate practice already keeps it. +- Everything the owner writes is now sealed inside the encrypted archive, so it + opens only when enough guardians combine their pieces. Their method for + spending and the encrypted chain copy used to sit in the open beside the + roster, where a single guardian read both. They now arrive as + `HOW-THE-WALLET-WORKS.txt` and `CHAIN-COPY.txt`, next to + `WHERE-THE-KEYS-ARE.txt`. The published transaction id travels with the + chain copy, because the id alone fetches the same payload off the chain. + A bundle README says only whether the owner wrote anything at all. +- The `--chain-txid` flag on `bundle` is gone. Bundles no longer carry a + transaction id, so there was nothing for it to set. + +- The descriptor page can open our own published backup. One button fills the + transaction id and two of the three keys from the mainnet backup recorded in + `docs/descriptor-backup-vector.md`, fetches it, and leaves the reader one + press from the descriptor. It stops there on purpose, because the + transaction-id path takes two steps and nothing used to say so. The page now + says so, and pressing Rebuild too early names the Fetch button instead of + restating the problem. The note explaining what happened appears only for our + own backup, so an heir recovering their own wallet is never told it was a + demonstration. +- The maker offers a test run. It makes throwaway bundles from a sample file so + an owner can practise a recovery before making the real ones. A test bundle + carries none of the owner's own material, neither their files nor the people + and places, and every one is stamped TEST in the project name and in the + saved filename. The offer appears once, never repeats, and never blocks + Generate. Covered by `e2e/test-run.spec.ts`. + +- Kaitiaki is now Bitcoin Inheritance. Pages, guide, README, the PDF bundle + title, and every translation say the new name; short labels such as the page + logo say Inheritance. The CLI binary is now `inheritance` (`inheritance init`, + `inheritance seal`, `inheritance recover`), the bundle metadata key is + `inheritance-version`, and the printed recovery URL moved to + bitcoinbutlers.com/tools/inheritance/recover.html, which the site redirects + to permanently from the old path. Only the repository name keeps the old word. + +All notable changes to Bitcoin Inheritance are documented here. Entries below +the 2026 fork describe releases of ReMemory, the project this one is built +from, and name its binary and commands as they were at the time. They are a +record, not instructions. ## Unreleased @@ -19,6 +93,14 @@ All notable changes to ReMemory are documented here. active label used to be white on white. - The "See how it works" walkthrough now builds a test set in the maker instead of pointing at a demo download this fork does not publish. +- New page: **Descriptor Backup**. A multisig wallet needs its descriptor as + well as its keys. Losing the descriptor together with one key can cost you + the wallet, even when the keys you still hold are enough to sign. The page encrypts the descriptor so that your own + keys unlock it, and gives you one line of text to put on Bitcoin. Choose + who can open it: the same threshold your wallet spends with, or any single + key. Recovery reads it back from a transaction id or from pasted text. + Nothing is uploaded, and the encrypted text can also be recovered with + other people's tools if this project disappears. - The guardian README.txt and README.pdf speak for Bitcoin Butlers. The title is now KAITIAKI GUARDIAN BUNDLE, the instructions are shorter and plainer, and the fallback links point at bitcoinbutlers.com and at this diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6ff6ed5..d0b89bc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ -# Contributing to Kaitiaki +# Contributing to Bitcoin Inheritance -Kaitiaki is a project where quality matters more than usual. A recovery bundle might sit in a drawer for ten years, then be opened by someone who just lost a loved one. The code, the copy, the design — it all has to hold up. Contributions should reflect that care. +Bitcoin Inheritance is a project where quality matters more than usual. A recovery bundle might sit in a drawer for ten years, then be opened by someone who just lost a loved one. The code, the copy, the design — it all has to hold up. Contributions should reflect that care. We welcome contributions. Here's how to make them count. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7137f9e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +# Bitcoin Inheritance, built from this repository. +# +# The build needs Node as well as Go: the pages are TypeScript compiled by +# esbuild, and the maker's create.wasm is Go compiled for js/wasm. + +FROM golang:1.25-bookworm AS build + +RUN apt-get update \ + && apt-get install -y --no-install-recommends nodejs npm \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /src + +# Dependencies first, so a source edit does not refetch them. +COPY go.mod go.sum ./ +RUN go mod download +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . +ENV PATH="/src/node_modules/.bin:${PATH}" +RUN make build + +FROM debian:bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --system --create-home --uid 10001 inheritance + +COPY --from=build /src/inheritance /usr/local/bin/inheritance + +USER inheritance +WORKDIR /data +VOLUME ["/data"] +EXPOSE 8080 + +# 0.0.0.0 because the default 127.0.0.1 is unreachable from outside the +# container. Put it behind a reverse proxy with TLS. +ENTRYPOINT ["inheritance", "serve", "--host", "0.0.0.0", "--port", "8080", "--data", "/data"] diff --git a/Makefile b/Makefile index 06541b0..03f02f2 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,13 @@ -.PHONY: build test test-tlock test-e2e test-e2e-headed lint clean install wasm wasm-cjk build-cjk ts build-all bump man html serve demo demo-tlock generate-fixtures full update-pdf-png screenshots release check-translations +.PHONY: build test test-ts test-tlock test-e2e test-e2e-headed lint clean install wasm wasm-cjk build-cjk ts build-all bump man html serve demo demo-tlock generate-fixtures full update-pdf-png screenshots release check-translations -BINARY := kaitiaki +BINARY := inheritance VERSION := $(shell cat VERSION 2>/dev/null || echo "dev") BUILD_DATE := $(shell date -u +%Y-%m-%d) LDFLAGS := -ldflags "-s -w -X main.version=$(VERSION) -X main.buildDate=$(BUILD_DATE)" # Build WASM module first, then the main binary build: wasm - go build $(LDFLAGS) -o $(BINARY) ./cmd/rememory + go build $(LDFLAGS) -o $(BINARY) ./cmd/inheritance # Compile TypeScript to JavaScript (bundled as IIFE for inline use) # Uses --loader:.txt=text to bundle BIP39 wordlists as strings @@ -29,6 +29,7 @@ ts: esbuild internal/html/assets/src/app.ts --bundle --format=iife --define:__TLOCK__=false --minify-syntax --outfile=internal/html/assets/app.js --target=es2020 --loader:.txt=text --conditions=zbar-inlined esbuild internal/html/assets/src/app.ts --bundle --format=iife --define:__TLOCK__=true --minify-syntax --outfile=internal/html/assets/app-tlock.js --target=es2020 --loader:.txt=text --conditions=zbar-inlined esbuild internal/html/assets/src/create-app.ts --bundle --format=iife --define:__SELFHOSTED__=false --minify-syntax --outfile=internal/html/assets/create-app.js --target=es2020 + esbuild internal/html/assets/src/descriptor-app.ts --bundle --format=iife --minify-syntax --outfile=internal/html/assets/descriptor-app.js --target=es2020 @echo "Compiling selfhosted TypeScript variant..." esbuild internal/html/assets/src/create-app.ts --bundle --format=iife --define:__SELFHOSTED__=true --minify-syntax --outfile=internal/html/assets/create-app-selfhosted.js --target=es2020 @@ -58,17 +59,42 @@ wasm-cjk: ts # Build CLI that generates CJK-capable maker.html (requires 'make wasm-cjk' first). # The resulting binary produces a ~21 MB maker.html with Chinese/Japanese/Korean support. -# Usage: make wasm-cjk && make build-cjk && ./rememory-cjk html create > maker-cjk.html +# Usage: make wasm-cjk && make build-cjk && ./inheritance-cjk html create > maker-cjk.html build-cjk: wasm-cjk - go build $(LDFLAGS) -tags cjk -o $(BINARY)-cjk ./cmd/rememory + go build $(LDFLAGS) -tags cjk -o $(BINARY)-cjk ./cmd/inheritance install: wasm - go install $(LDFLAGS) ./cmd/rememory + go install $(LDFLAGS) ./cmd/inheritance test: @test -f internal/html/assets/app.js && test -f $(BINARY) || $(MAKE) build go test -v ./... +# Run the TypeScript crypto tests: both on-chain descriptor backup formats. +# The k-of-n suite checks our bytes against a vector generated by the upstream +# multisig-backup source; the BIP-138 suite checks them against that draft's +# own vectors. Either one failing means a client's fallback tool has stopped +# agreeing with us, so this runs in CI on every push. +test-xlang: + @if [ ! -d node_modules ]; then echo "Run 'npm install' first"; exit 1; fi + @mkdir -p .test-build + go run ./internal/descriptorbackup/conformance .test-build/go-backups.json + npx esbuild internal/html/assets/src/crypto/conformance.check.ts --bundle --format=esm --platform=node --outfile=.test-build/conformance.check.mjs --log-level=warning + node .test-build/conformance.check.mjs .test-build/go-backups.json + +test-ts: + @if [ ! -d node_modules ]; then echo "Run 'npm install' first"; exit 1; fi + @mkdir -p .test-build + # Two projects, and both matter. tsconfig.test.json covers the test files + # and the crypto they import. tsconfig.json covers everything that actually + # ships in a page, including create-app.ts and app.ts. esbuild strips types + # without checking them, so nothing else catches those. + npx tsc --noEmit --project internal/html/assets/tsconfig.json + npx tsc --noEmit --project internal/html/assets/tsconfig.test.json + npx esbuild internal/html/assets/src/crypto/descriptor.test.ts --bundle --format=esm --platform=node --outfile=.test-build/descriptor.test.mjs --log-level=warning + npx esbuild internal/html/assets/src/crypto/bip138.test.ts --bundle --format=esm --platform=node --outfile=.test-build/bip138.test.mjs --log-level=warning + node --test .test-build/descriptor.test.mjs .test-build/bip138.test.mjs + test-cover: go test -coverprofile=coverage.out ./... go tool cover -html=coverage.out -o coverage.html @@ -76,21 +102,21 @@ test-cover: # Run Playwright e2e tests (requires npm install first) test-e2e: build @if [ ! -d node_modules ]; then echo "Run 'npm install' first"; exit 1; fi - REMEMORY_BIN=./$(BINARY) npx playwright test + INHERITANCE_BIN=./$(BINARY) npx playwright test # Run e2e tests with visible browser test-e2e-headed: build @if [ ! -d node_modules ]; then echo "Run 'npm install' first"; exit 1; fi - REMEMORY_BIN=./$(BINARY) npx playwright test --headed + INHERITANCE_BIN=./$(BINARY) npx playwright test --headed # Run tlock integration tests (requires internet; drand network access) test-tlock: build - REMEMORY_TEST_TLOCK=1 go test -v -run TestTlock ./... + INHERITANCE_TEST_TLOCK=1 go test -v -run TestTlock ./... @if [ ! -d node_modules ]; then echo "Run 'npm install' first"; exit 1; fi - REMEMORY_TEST_TLOCK=1 REMEMORY_BIN=./$(BINARY) npx playwright test + INHERITANCE_TEST_TLOCK=1 INHERITANCE_BIN=./$(BINARY) npx playwright test # Clean rebuild + all tests (unit + e2e + tlock) -full: clean build test test-e2e test-tlock lint +full: clean build test test-ts test-e2e test-tlock lint lint: go vet ./... @@ -100,15 +126,15 @@ lint: clean: rm -f $(BINARY) $(BINARY)-cjk coverage.out coverage.html rm -f internal/html/assets/recover.wasm internal/html/assets/create.wasm internal/html/assets/create-cjk.wasm - rm -f internal/html/assets/app.js internal/html/assets/app-tlock.js internal/html/assets/create-app.js internal/html/assets/shared.js internal/html/assets/types.js internal/html/assets/create-app-selfhosted.js - rm -rf dist/ man/ + rm -f internal/html/assets/app.js internal/html/assets/app-tlock.js internal/html/assets/create-app.js internal/html/assets/shared.js internal/html/assets/types.js internal/html/assets/create-app-selfhosted.js internal/html/assets/descriptor-app.js + rm -rf dist/ man/ .test-build/ go clean -testcache # Generate man pages man: build @mkdir -p man ./$(BINARY) doc man - @echo "View with: man ./man/rememory.1" + @echo "View with: man ./man/$(BINARY).1" # Generate standalone HTML files for static hosting html: build @@ -133,7 +159,7 @@ demo-tlock: build # Check that all languages have the same translation keys as English check-translations: - REMEMORY_CHECK_TRANSLATIONS=1 go test -v -run TestAllLanguagesHaveSameKeys ./internal/translations/ + INHERITANCE_CHECK_TRANSLATIONS=1 go test -v -run TestAllLanguagesHaveSameKeys ./internal/translations/ # Regenerate golden test fixtures (one-time, output is committed) generate-fixtures: @@ -142,11 +168,11 @@ generate-fixtures: # Cross-compile for all platforms (used by CI) build-all: wasm @mkdir -p dist - GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o dist/rememory-linux-amd64 ./cmd/rememory - GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -o dist/rememory-linux-arm64 ./cmd/rememory - GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o dist/rememory-darwin-amd64 ./cmd/rememory - GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o dist/rememory-darwin-arm64 ./cmd/rememory - GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -o dist/rememory-windows-amd64.exe ./cmd/rememory + GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o dist/inheritance-linux-amd64 ./cmd/inheritance + GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -o dist/inheritance-linux-arm64 ./cmd/inheritance + GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o dist/inheritance-darwin-amd64 ./cmd/inheritance + GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o dist/inheritance-darwin-arm64 ./cmd/inheritance + GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -o dist/inheritance-windows-amd64.exe ./cmd/inheritance # Stamp CHANGELOG, update VERSION, commit. Asks which bump type. release: @@ -193,15 +219,13 @@ bump: update-pdf-png: build @rm -rf demo-recovery ./$(BINARY) demo - @mkdir -p docs/screenshots/demo-pdf docs/screenshots/demo-pdf-es - @rm -f docs/screenshots/demo-pdf/*.png docs/screenshots/demo-pdf-es/*.png + @mkdir -p docs/screenshots/demo-pdf + @rm -f docs/screenshots/demo-pdf/*.png @unzip -o demo-recovery/output/bundles/bundle-alice.zip README.pdf -d demo-recovery/output/bundles/bundle-alice/ - @unzip -o demo-recovery/output/bundles/bundle-camila.zip LEEME.pdf -d demo-recovery/output/bundles/bundle-camila/ pdftoppm -png -r 200 demo-recovery/output/bundles/bundle-alice/README.pdf docs/screenshots/demo-pdf/page - pdftoppm -png -r 200 demo-recovery/output/bundles/bundle-camila/LEEME.pdf docs/screenshots/demo-pdf-es/page - @echo "Generated PDF page screenshots in docs/screenshots/demo-pdf/ (English) and docs/screenshots/demo-pdf-es/ (Spanish)" + @echo "Generated PDF page screenshots in docs/screenshots/demo-pdf/" # Generate localized guide screenshots via Playwright (en, es, de, fr) screenshots: build @if [ ! -d node_modules ]; then echo "Run 'npm install' first"; exit 1; fi - REMEMORY_BIN=./$(BINARY) REMEMORY_TEST_SCREENSHOTS=1 npx playwright test --project=chromium + INHERITANCE_BIN=./$(BINARY) INHERITANCE_TEST_SCREENSHOTS=1 npx playwright test --project=chromium diff --git a/NOTICE b/NOTICE index e726116..9dac95a 100644 --- a/NOTICE +++ b/NOTICE @@ -1,18 +1,8 @@ -Kaitiaki +Bitcoin Inheritance Copyright 2026 Bitcoin Butlers -This product is a fork of Rememory (https://github.com/eljojo/rememory), -created by José Tomás Albornoz (eljojo) and contributors, and is -distributed under the Apache License, Version 2.0. We thank the -Rememory authors for their excellent work; all credit for the original -design and implementation belongs to them. - -Changes made in this fork (Kaitiaki): -- docs/independent-recovery.md: recovery procedure with independent - tools (stock age + contrib/combine.py), proven against the golden - fixtures in internal/core/testdata/. -- contrib/combine.py: standalone GF(2^8) Lagrange share combiner. -- Each bundle ZIP gains a machine-readable METADATA.yaml. -- New `--hide-quorum` flag on init: shares and bundle documents can - omit the total/threshold values (disclosure stays the default). -- NOTICE and README updated to state these changes. +Portions copyright José Tomás Albornoz and the Rememory contributors. +This work is a modified derivative of Rememory +(https://github.com/eljojo/rememory), used under the Apache License, +Version 2.0. Files throughout this repository have been changed; see the +Git history for what and when. diff --git a/README.md b/README.md index 0b453f3..3d38e3a 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,67 @@ -# Kaitiaki +# Bitcoin Inheritance -Kaitiaki is a Bitcoin Butlers fork of -[Rememory](https://github.com/eljojo/rememory) by -[eljojo](https://github.com/eljojo). Rememory does the heavy lifting; -this fork adds a small set of changes for our recovery service. See -the `NOTICE` file for the list of changes and full attribution -(Apache-2.0). +**Your heirs will inherit your keys. They will not inherit what you know about them.** -Fork additions: -- [docs/independent-recovery.md](docs/independent-recovery.md) — recover with stock `age` and `contrib/combine.py`, no project code. -- `METADATA.yaml` in every bundle ZIP. -- `--hide-quorum` on `init` (see [docs/hide-quorum-design.md](docs/hide-quorum-design.md)). +Built by Bitcoin Butlers. A modified derivative of +[Rememory](https://github.com/eljojo/rememory), Apache-2.0. See [NOTICE](NOTICE). -The original Rememory README follows. +A hardware wallet in a drawer is not a plan. Neither is a seed phrase on steel. +Both survive you. What dies with you is everything around them: which wallet +this is, how many keys it takes, which software opens it, where the other keys +live, and who to call first. + +This tool takes what you know, encrypts it, and splits the key among people you +trust. No one of them can open it. Enough of them together can. + +**It is free, it runs in your browser, and nothing you type reaches us.** + +- **Create Bundles** — +- **Recover** — +- **The guide** — --- -# 🧠 ReMemory +## What a guardian can and cannot see + +This is the part that makes it different, so it goes first. + +A guardian holds one bundle. Opening it, alone, they see **their own piece and +their own name**. That is all. + +They do not see who the other guardians are. They do not see how your wallet +works. They do not see where a key is kept. A bundle is built to be forwarded, +so it carries nothing that would help the person it is forwarded to. + +Everything you write is sealed inside the encrypted archive and opens only when +enough guardians come together: -**A digital safe with multiple keys, held by people you trust.** +| Sealed file | What you wrote | +|---|---| +| `HOW-THE-WALLET-WORKS.txt` | how it opens, how many keys, which software | +| `WHERE-THE-KEYS-ARE.txt` | where each key is kept | +| `CHAIN-COPY.txt` | the chain copy and its transaction id, if you made one | -ReMemory protects your files and divides the key among people you choose. You decide how many must come together to open it. Each person gets a self-contained recovery tool that works offline, in any browser.* +**Who holds a piece belongs in your will**, not in the thing they are holding. +To anyone who reads that list early it is names and nothing else. To your +heirs it is the only way to find the bundles. +Two guardians who know each other can agree between themselves. Two guardians +who do not, cannot. -* [Time-locked](#time-delayed-recovery-experimental) archives need a brief internet connection at recovery time. +--- ## Recovery works without this project -Each person receives a bundle containing `recover.html` — a browser-based recovery tool. No servers. No dependencies. No need for this project to exist when recovery happens. +Each guardian receives a bundle containing `recover.html`, a recovery tool that +runs in any browser. No servers. No dependencies. Nothing of ours has to exist +when recovery happens. -**[Download demo bundles](https://github.com/eljojo/rememory/releases/latest/download/demo-bundles.zip)** to try the recovery process yourself. +Run `make demo` to build a set and try a recovery yourself. ```mermaid graph TB subgraph seal["① SEAL (you do this once)"] - A[Your Files] --> B[Encrypt with age] - B --> C[Split key into 5 pieces] + A[Your files and your words] --> B[Encrypt with age] + B --> C[Split the key into 5 pieces] C --> D1[Alice's bundle] C --> D2[Bob's bundle] C --> D3[Camila's bundle] @@ -42,13 +69,13 @@ graph TB C --> D5[Elias's bundle] end - subgraph recover["② RECOVER (friends do this together)"] - R1[Alice opens recover.html] --> R2[Her piece is pre-loaded] - R2 --> R3[Drags Bob's file] - R3 --> R4[Drags Camila's file] + subgraph recover["② RECOVER (guardians do this together)"] + R1[Alice opens recover.html] --> R2[Her piece is already loaded] + R2 --> R3[She adds Bob's README] + R3 --> R4[She adds Camila's README] R4 --> R5{3 of 5 pieces} - R5 -->|Threshold met| R6[Files unlocked] - R6 --> R7[Download files] + R5 -->|Enough| R6[Files unlocked] + R6 --> R7[Download] end D1 -.-> R1 @@ -56,287 +83,111 @@ graph TB D3 -.-> R4 ``` -Any 3 pieces can reconstruct the key, but a single piece reveals nothing — not "very little," mathematically zero information. +Any 3 pieces rebuild the key. A single piece reveals nothing: not "very +little", mathematically zero. -The number of people and the threshold are up to you: 2-of-3 for a small circle, 3-of-5 for a wider group, or 2-of-2 for a couple. +The number of guardians and the threshold are yours. 2 of 3 for a small +circle, 3 of 5 for a wider one, 2 of 2 for a couple. --- -## Two Ways to Use ReMemory +## How to use it -### 🌐 Web UI (recommended) +**Open [Create Bundles](https://www.bitcoinbutlers.com/tools/inheritance/maker.html) and work down the page.** -Create bundles in your browser — no installation required. - -| | | -|---|---| -| **Create Bundles** | [eljojo.github.io/rememory/maker.html](https://eljojo.github.io/rememory/maker.html) | -| **Documentation** | [eljojo.github.io/rememory/docs.html](https://eljojo.github.io/rememory/docs.html) | +1. **Guardians.** Name each person and set how many must agree. Their names + stay on your machine. No bundle carries them. +2. **Files.** Drag in what your heirs need: a wallet descriptor, a letter, a + list of accounts. **Never seed words.** A bundle that holds your seed is a + copy of your wallet. +3. **Your words.** The page asks what your heirs need to know, in two parts: + how the wallet works, and where the keys are. Answer in your own words. + Both are sealed. +4. **Generate**, then give each guardian their bundle. -Everything runs locally. Your files never leave your device. +Then write the guardian list on one page and keep it with your will. That page +is how your heirs find them, and it is the only copy. -![The bundle creator — add friends, add files, generate](docs/screenshots/en/maker-overview.png) +### Putting the descriptor on Bitcoin -### 💻 CLI and Docker +A multisig wallet needs its descriptor as well as its keys. Lose one key and +the descriptor together, and the keys you still hold cannot rebuild the +wallet, even when they are enough to sign. -For automation, scripting, or if you prefer the terminal. - -```bash -# macOS (Homebrew) -brew install eljojo/rememory/rememory - -# Linux (x86_64) -curl -Lo rememory https://github.com/eljojo/rememory/releases/latest/download/rememory-linux-amd64 -chmod +x rememory -sudo mv rememory /usr/local/bin/ - -# Docker (self-hosted) -docker run -d \ - --name rememory \ - -p 8080:8080 \ - -v rememory-data:/data \ - ghcr.io/eljojo/rememory:latest - -# Nix -nix run github:eljojo/rememory -``` +Choose "Bundles and the chain" in step 3 and paste your descriptor. The page +encrypts it so that only your own keys open it, and hands you one line of text +for a single `OP_RETURN` output. **Publish it before you generate**, then paste +the transaction id back into the page: the archive is sealed when you generate, +so an id found afterwards can never get in. -See the **[CLI User Guide](docs/guide.md)** or the **[Self-Hosted Guide](docs/selfhosted.md)** for complete documentation. +Write that transaction id on your estate page too. It is what an heir uses when +they cannot gather enough guardians. --- -## Try It First +## What is in a bundle -Before protecting real secrets, try the recovery process: - -1. **[Download demo bundles](https://github.com/eljojo/rememory/releases/latest/download/demo-bundles.zip)** (5 friends, any 3 can recover) -2. Open `bundle-alice/recover.html` in your browser -3. Alice's piece is pre-loaded — drag two more README files onto the page. Dragging an entire bundle works too. -4. When enough pieces are combined, the files unlock - -This is the closest thing to what a real recovery feels like. +| File | What it is | +|---|---| +| `README.txt`, `README.pdf` | what this is, their piece, and where to look for the others | +| `recover.html` | the recovery tool. For archives of 10 MB or less, the encrypted data is inside it | +| `MANIFEST.age` | the encrypted archive, separate only when it is large | +| `METADATA.yaml` | what the bundle is, in plain text. Command line only | +| `OWNER.age` | optional. Lets you recover alone with your own key | --- -## What Friends Receive - -Each friend gets a ZIP bundle containing: - -| File | Purpose | -|------|---------| -| `README.txt` | Instructions, their unique piece, contact list | -| `README.pdf` | Same content, formatted for printing | -| `MANIFEST.age` | Your encrypted files (only included separately when over 10 MB) | -| `recover.html` | Recovery tool (~300 KB), runs in any browser. For smaller archives, everything is embedded — just open this file | - -**A single piece reveals nothing.** But tell your friends to keep their bundle somewhere safe — it's their responsibility to you. - -![Example README PDF — page 1](docs/screenshots/demo-pdf/page-1.png) +## What this does not protect against -
-More pages - -![Example README PDF — page 2](docs/screenshots/demo-pdf/page-2.png) -![Example README PDF — page 3](docs/screenshots/demo-pdf/page-3.png) - -
+- **A guardian who loses their bundle.** That is why the threshold is below the + total. Run a drill once a year. +- **Old bundles.** Regenerating makes a brand new key. An old piece and a new + piece cannot be combined, and nothing on the outside of a bundle shows it. + When you hand out new bundles, say plainly: delete the old one. +- **Seed words you put in anyway.** The tool asks you not to. It cannot stop + you. +- **A compromised machine.** The bundles are made on yours. --- -## FAQ - -
-Why ReMemory? - -We all have digital secrets that matter: password manager recovery codes, cryptocurrency seeds, important documents, instructions for loved ones. What happens to these if you're suddenly unavailable? - -Traditional approaches fail: -- **Give one person everything** → Single point of failure and trust -- **Split files manually** → Confusing, error-prone, no encryption -- **Use a password manager's emergency access** → Relies on company existing -- **Write it in a will** → Becomes public record, slow legal process - -ReMemory takes a different approach: -- **No single point of failure** — requires multiple people to cooperate -- **No trust in any one person** — even your most trusted friend can't access secrets alone -- **Offline and self-contained** — recovery works without internet or servers* -- **Designed for non-technical people** — clear instructions, not cryptographic puzzles - -
+## Build it -
-Why I Built This +Build from this repository. -Two things drove me to create ReMemory. - -First, I watched [a documentary about Clive Wearing](https://www.youtube.com/watch?v=k_P7Y0-wgos), a man who has lived with a 7-second memory since 1985. Seeing how fragile memory can be made me think about what would happen to my digital life if something similar happened to me. - -Second, I've had several concussions from cycling accidents. Each time, I've been lucky to recover fully. But each time, I've been reminded that our brains are more fragile than we like to think. - -ReMemory is my answer: a way to ensure the people I trust can access what matters, even if I can't help them. - -
- -
-Threat Model - -ReMemory assumes: -- Your friends will only cooperate when needed -- At least *threshold* friends will keep their bundle safe -- Your device is trusted when you create bundles -- The browser used for recovery is not compromised - -ReMemory does NOT rely on: -- Any server or cloud service -- Any ReMemory website or infrastructure -- Any long-term availability of this project -- The internet during recovery - -See the **[Security Review](docs/security-review.md)** for details. - -
- -
-Cryptographic Guarantees - -| Component | Algorithm | -|-----------|-----------| -| Encryption | [age](https://github.com/FiloSottile/age) (scrypt passphrase mode) | -| Key derivation | scrypt (N=2²⁰, r=8, p=1) | -| Secret sharing | Shamir's Secret Sharing over GF(2⁸) | -| Integrity | SHA-256 checksums | -| Passphrase | 256 bits from crypto/rand | -| Time lock (optional) | [drand](https://www.cloudflare.com/en-ca/leagueofentropy/) tlock (BLS12-381 IBE, inner layer) | - -**A single piece reveals nothing about your secret.** This is a mathematical guarantee of Shamir's Secret Sharing — any fewer than *threshold* pieces contain zero information about the original secret. - -
- -
-Time-Delayed Recovery (Experimental) - -You can set a waiting period when creating bundles. Even with enough pieces, the files stay locked until the date you chose — for example, 30 days, 6 months, or a specific date. - -This uses the [League of Entropy](https://www.cloudflare.com/en-ca/leagueofentropy/) (drand), a distributed randomness beacon run by organizations around the world. At recovery time, a brief internet connection is needed — not to send data, but to verify that enough time has passed. - -**CLI:** `rememory seal --timelock 30d` (or `6m`, `1y`, `2027-06-15T00:00:00Z`) -**Web:** Enable under "Advanced options" in the [bundle creator](https://eljojo.github.io/rememory/maker.html). - -**Important caveats:** -- Recovery requires internet access (to check the drand beacon) -- If the League of Entropy stops operating before your time lock expires, recovery won't work -- Without the time lock, recovery works fully offline — the time lock adds this one dependency - -
- -
-Failure Scenarios - -| What if... | Result | -|------------|--------| -| A friend loses their bundle? | Fine, as long as threshold friends remain | -| A friend leaks their piece publicly? | Harmless without threshold-1 other pieces | -| ReMemory disappears in 10 years? | `recover.html` still works — it's self-contained | -| Browsers change dramatically? | Pure JavaScript with no external dependencies | -| You forget how this works? | Each bundle's README.txt explains everything | -| Some friends can't be reached? | That's why you set threshold below total friends | -| Time lock used, but no internet at recovery? | Wait and try again — data is safe, just needs the beacon check | -| League of Entropy shuts down? | Time-locked archives become unrecoverable — only a risk if you use the time lock feature | - -
+```bash +npm install && make build # needs Go and Node +./inheritance --help -
-Development +make test # Go tests +make test-e2e # browser tests +make html # the static pages, into dist/ +``` -```bash -# Using Nix (recommended) -nix develop +Self-hosting a recovery server is in [docs/selfhosted.md](docs/selfhosted.md). -# Install dependencies -npm install +--- -# Build -make build +## The service -# Run tests -make test # Unit tests -make test-e2e # Browser tests (requires: npm install) +The tool is free and always will be. Bitcoin Butlers sells the time around it: +a guided placement session, an annual drill, and putting a descriptor on the +chain. A Butler never holds a bundle, a piece, a key or a file, and the client +does everything on their own machine. -# Preview website locally -make serve # Serves at http://localhost:8000 -``` + -
- -
-Other Similar Tools - -ReMemory isn't the first tool to use Shamir's Secret Sharing. Its focus is making recovery possible for non-technical people, without installing anything. - -#### Shamir's Secret Sharing tools - -| Tool | Type | Input | Splitting Method | Output | Non-technical Recovery | Offline | Contact Details | -|------|------|-------|-----------------|--------|----------------------|---------|-----------------| -| **[eljojo/rememory](https://github.com/eljojo/rememory)** | CLI + Web | Files & folders | Shamir's SSS | ZIP bundles with PDF instructions, `recover.html`, encrypted archive | Yes — open HTML in browser | Yes | Yes — included in each bundle | -| **[jesseduffield/horcrux](https://github.com/jesseduffield/horcrux)** | CLI | Files | Shamir's SSS | Encrypted file fragments | No — requires CLI | Yes | No | -| **[jefdaj/horcrux](https://github.com/jefdaj/horcrux)** | CLI | Files (GPG) | Shamir's SSS (via `ssss`) | `.key` + `.sig` files, steganography in images/audio | No — requires CLI + GPG | Yes (TAILS recommended) | No | -| **[paritytech/banana_split](https://github.com/paritytech/banana_split)** | Web app | Text only | Shamir's SSS + NaCl | Printable QR codes | Partial — scan QR + type passphrase | Yes (self-contained HTML) | No | -| **[cyphar/paperback](https://github.com/cyphar/paperback)** | CLI | Files | Shamir's SSS in GF(2^32) | Printable PDFs with QR codes + text fallback | Partial — scan QR or type text | Yes | No | -| **[simonfrey/s4](https://github.com/simonfrey/s4)** ([site](https://simon-frey.com/s4/)) | Web GUI + Go lib | Text/bytes | Shamir's SSS + AES | Text shares | No — copy/paste shares | Yes (save HTML locally) | No | -| **[xkortex/passcrux](https://github.com/xkortex/passcrux)** | CLI | Text/passphrases | Shamir's SSS | Text shares (hex/base32/base64) | No — requires CLI | Yes | No | -| **[ssss](http://point-at-infinity.org/ssss/)** | CLI | Text (128 char max) | Shamir's SSS | Text shares | No — requires CLI | Yes | No | -| **[cedws/amnesia](https://github.com/cedws/amnesia)** | CLI | Text/data streams | Shamir's SSS + argon2id | JSON file (Q&A-based, single user) | No — requires CLI | Yes | No | -| **[henrysdev/Haystack](https://github.com/henrysdev/Haystack)** | CLI | Files | Shamir's SSS | Encrypted file fragments | No — requires CLI | Yes | No | -| **[antonio-ivanovski/shared-secret-encrypt](https://github.com/antonio-ivanovski/shared-secret-encrypt)** ([site](https://shared-secret-encrypt.tote.mk/)) | Web app | Text only | Shamir's SSS + AES-GCM | Base58-encoded shares + encrypted message | Partial — web UI for decrypt | Yes (client-side, can save HTML) | No | -| **[MinorGlitch/ethernity](https://github.com/MinorGlitch/ethernity)** | CLI (Python) | Files | Shamir's SSS + AES-256-GCM | Printable PDFs with QR codes + text fallback, bundled browser recovery kit | Partial — scan QR or type text | Yes | No | - -#### Other approaches - -| Tool | Type | Input | Method | Output | Non-technical Recovery | Offline | Contact Details | -|------|------|-------|--------|--------|----------------------|---------|-----------------| -| **[msolomon/keybearer](https://github.com/msolomon/keybearer)** ([site](https://michael-solomon.net/keybearer)) | Web app | Files | Layered encryption | Encrypted file download | Partial — web UI for decryption | Yes (client-side JS) | No | -| **[RobinWeitzel/secret_sharer](https://github.com/RobinWeitzel/secret_sharer)** ([site](https://robinweitzel.de/secret_sharer/)) | Web app | Text only | Split-key AES-256 (fixed 2-of-2) | PDF with 2 QR codes + security code | Yes — scan QR codes | Yes (client-side) | No | -| **[Bitwarden Emergency Access](https://bitwarden.com/help/emergency-access/)** | Web service | Vault items + attachments | RSA key exchange (1-of-1) | Live vault access (no file output) | Yes — web UI | No (server required) | Via Bitwarden accounts | -| **[Apple Digital Legacy](https://support.apple.com/en-us/102631)** | Built-in (Apple) | Apple Account data | Legacy Contact designation | iCloud data access (3-year window) | Yes — Apple handles it | No (Apple servers required) | Via Apple Account | -| **[potatoqualitee/eol-dr](https://github.com/potatoqualitee/eol-dr)** | Guide/checklist | N/A | N/A (not a tool) | [Printable checklist](https://github.com/potatoqualitee/eol-dr/blob/main/checklist.md) covering accounts, finances, subscriptions, devices | N/A | Yes (print it) | Template fields | - -**Key takeaways:** - -- Most tools only handle **text or passphrases** — [eljojo/rememory](https://github.com/eljojo/rememory), both horcrux projects, [henrysdev/Haystack](https://github.com/henrysdev/Haystack), [cyphar/paperback](https://github.com/cyphar/paperback), [MinorGlitch/ethernity](https://github.com/MinorGlitch/ethernity), and [msolomon/keybearer](https://github.com/msolomon/keybearer) are the few that handle actual files. -- Only [eljojo/rememory](https://github.com/eljojo/rememory) generates a **self-contained recovery tool** (`recover.html`) bundled with each piece — no installation, no internet, no CLI needed. -- Only [eljojo/rememory](https://github.com/eljojo/rememory) includes **contact details** in each bundle so friends know how to reach each other during recovery. -- [paritytech/banana_split](https://github.com/paritytech/banana_split) and [cyphar/paperback](https://github.com/cyphar/paperback) output **QR codes** for printing, which is great for paper-based backups of short secrets. -- **Bitwarden Emergency Access** is fundamentally different — it delegates vault access to one trusted person (not M-of-N splitting) and requires an online service. -- **Apple Digital Legacy** only activates after death (requires proof of death documents) — it does not cover incapacity, memory loss, or other scenarios. Limited to Apple ecosystem (iCloud data, not Keychain passwords). Access expires after 3 years. -- [potatoqualitee/eol-dr](https://github.com/potatoqualitee/eol-dr) is not a tool but a valuable **end-of-life planning [checklist](https://github.com/potatoqualitee/eol-dr/blob/main/checklist.md)** covering accounts, finances, subscriptions, and devices — complementary to any tool here. -- [ssss](http://point-at-infinity.org/ssss/) is the classic Unix implementation but is limited to 128 ASCII characters and requires a terminal. -- GitHub offers a [successor feature](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/repository-access-and-collaboration/maintaining-ownership-continuity-of-your-personal-accounts-repositories) for maintaining ownership continuity of repositories — useful for ensuring your code projects remain accessible. - -
+--- ## License -Apache-2.0 — Copyright 2026 José Albornoz - -## Credits - -Built on: -- [age](https://github.com/FiloSottile/age) — Modern file encryption by Filippo Valsorda -- [age-encryption](https://github.com/FiloSottile/typage) — TypeScript age implementation, also by Filippo Valsorda -- [shamir-secret-sharing](https://github.com/privy-io/shamir-secret-sharing) — Audited Shamir's Secret Sharing by Privy (browser recovery) -- [HashiCorp Vault's Shamir implementation](https://github.com/hashicorp/vault/blob/main/shamir/shamir.go) — Shamir's Secret Sharing (CLI) -- [fflate](https://github.com/101arrowz/fflate) — Fast JavaScript compression -- [tarparser](https://github.com/highercomve/tarparser) — Tar archive extraction -- [tlock](https://github.com/drand/tlock) — Time-lock encryption via drand -- [Cobra](https://github.com/spf13/cobra) — CLI framework - -Translations by: -- Slovenščina — [@h200101](https://github.com/h200101) -- Português — [@Kasama](https://github.com/Kasama) -- 中文(台灣)— [@JasonHK](https://github.com/JasonHK) -- Catalan — [@xcxtxsx](https://github.com/xcxtxsx) -- Dutch — [@idebeijer](https://github.com/idebeijer) -- Italian — [@xushidev](https://github.com/xushidev) -- Turkish - [@FrustT](https://github.com/FrustT) - -The protocol was [originally designed in a Google Doc](https://docs.google.com/document/d/1B4_wIN3fXqb67Tln0v5v2pMRFf8v5umkKikaqCRAdyM/edit?usp=sharing) in 2023. +Apache-2.0. Copyright 2026 Bitcoin Butlers, with portions copyright José Tomás +Albornoz and the Rememory contributors. See [NOTICE](NOTICE). + +Built on [age](https://github.com/FiloSottile/age) and +[typage](https://github.com/FiloSottile/typage) by Filippo Valsorda, +[HashiCorp Vault's Shamir implementation](https://github.com/hashicorp/vault/blob/main/shamir/shamir.go), +[shamir-secret-sharing](https://github.com/privy-io/shamir-secret-sharing) by Privy, +[fflate](https://github.com/101arrowz/fflate), +[tarparser](https://github.com/highercomve/tarparser), +[tlock](https://github.com/drand/tlock) and +[Cobra](https://github.com/spf13/cobra). diff --git a/cmd/rememory/main.go b/cmd/inheritance/main.go similarity index 75% rename from cmd/rememory/main.go rename to cmd/inheritance/main.go index 575a817..ffdbd24 100644 --- a/cmd/rememory/main.go +++ b/cmd/inheritance/main.go @@ -3,7 +3,7 @@ package main import ( "os" - "github.com/eljojo/rememory/internal/cmd" + "github.com/Bitcoin-Butlers/kaitiaki/internal/cmd" ) var version = "dev" diff --git a/contrib/combine.py b/contrib/combine.py index 9c2cb97..f32eb06 100644 --- a/contrib/combine.py +++ b/contrib/combine.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Standalone Shamir combiner for Kaitiaki / Rememory shares. +# Standalone Shamir combiner for Bitcoin Inheritance / Rememory shares. # # Share format (hashicorp/vault shamir, as used by this project): # - Field: GF(2^8) with the AES polynomial 0x11b. @@ -17,7 +17,7 @@ # Input files are the "-----BEGIN REMEMORY SHARE-----" text files. # The script reads the base64 body of each file. It prints the # recovered passphrase on stdout. Feed that passphrase to stock -# `age -d` to decrypt MANIFEST.age. No Kaitiaki/Rememory code runs. +# `age -d` to decrypt MANIFEST.age. No Bitcoin Inheritance/Rememory code runs. # # If you give fewer shares than the threshold, the output is garbage # and age rejects it. That is expected and safe. diff --git a/docker-compose.yml b/docker-compose.yml index 5e47c0a..6259987 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,19 @@ -# ReMemory — https://eljojo.github.io/rememory/ -# Self-hosting guide: https://github.com/eljojo/rememory/blob/main/docs/selfhosted.md +# Bitcoin Inheritance, self-hosted. +# +# docker compose up --build services: - rememory: - image: ghcr.io/eljojo/rememory:latest + inheritance: + build: . ports: - - "8080:8080" + # 127.0.0.1 on purpose. Put a reverse proxy with TLS in front of it + # before you expose this to a network. + - "127.0.0.1:8080:8080" volumes: - - rememory-data:/data + - inheritance-data:/data restart: unless-stopped # environment: - # REMEMORY_MAX_MANIFEST_SIZE: 200MB + # INHERITANCE_MAX_MANIFEST_SIZE: 200MB volumes: - rememory-data: + inheritance-data: diff --git a/docs/descriptor-backup-vector.md b/docs/descriptor-backup-vector.md new file mode 100644 index 0000000..3369e94 --- /dev/null +++ b/docs/descriptor-backup-vector.md @@ -0,0 +1,132 @@ +# Descriptor backup test vector + +The descriptor backup page encrypts a multisig descriptor so that the +wallet's own keys unlock it. This file lets anyone check that our bytes +are the same bytes another implementation produces and reads. + +**These wallets are BIP-39 test mnemonics. They hold nothing. Never use +them.** + +## Threshold format (any k of n keys) + +This format is the scheme published by +[joshdoman/multisig-backup](https://github.com/joshdoman/multisig-backup) +(MIT), reproduced byte for byte on purpose, so a client can recover at +multisigbackup.com with no Bitcoin Butlers software in the path. + +### Inputs + +Mnemonics, each with no passphrase: + +``` +abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about +legal winner thank year wave sausage worth useful legal winner thank yellow +letter advice cage absurd amount doctor acoustic avoid letter advice cage above +``` + +Account path for all three: `m/48'/0'/0'/2'` + +Master fingerprints: `73c5da0a, b8688df1, 28645006` + +Extended public keys: + +``` +xpub6DkFAXWQ2dHxq2vatrt9qyA3bXYU4ToWQwCHbf5XB2mSTexcHZCeKS1VZYcPoBd5X8yVcbXFHJR9R8UCVpt82VX1VhR28mCyxUFL4r6KFrf +xpub6FQya7zGhR92kacYsNnjreouvnHJMpXYsUXnW6NJJAJRCKsa26TzDy4LdnGhEurr3d6y1J8PJ7EEMKQp74XTqYvmGJNogYXSKDszYHtF8mX +xpub6DnEBNkSJKBYQmsbhS1sP9cNdtU5c9PLFGCjTJmxicxc13WB8zNNGQazabQpyFAGW5bV9tMko4uBxDxjUKL6dSAcx1tEbgEHtgSqyRsekh6 +``` + +Descriptor: + +``` +wsh(sortedmulti(2,[73c5da0a/48h/0h/0h/2h]xpub6DkFAXWQ2dHxq2vatrt9qyA3bXYU4ToWQwCHbf5XB2mSTexcHZCeKS1VZYcPoBd5X8yVcbXFHJR9R8UCVpt82VX1VhR28mCyxUFL4r6KFrf/<0;1>/*,[b8688df1/48h/0h/0h/2h]xpub6FQya7zGhR92kacYsNnjreouvnHJMpXYsUXnW6NJJAJRCKsa26TzDy4LdnGhEurr3d6y1J8PJ7EEMKQp74XTqYvmGJNogYXSKDszYHtF8mX/<0;1>/*,[28645006/48h/0h/0h/2h]xpub6DnEBNkSJKBYQmsbhS1sP9cNdtU5c9PLFGCjTJmxicxc13WB8zNNGQazabQpyFAGW5bV9tMko4uBxDxjUKL6dSAcx1tEbgEHtgSqyRsekh6/<0;1>/*)) +``` + +### Output + +Encrypted text, 345 bytes of encrypted data after the +readable part: + +``` +wsh(sortedmulti(2,[48h/0h/0h/2h]<0;1>/*,[48h/0h/0h/2h]<0;1>/*,[48h/0h/0h/2h]<0;1>/*))CCQDZ+maZ+y+OQ3xN/t/RZCLXHg0rDBPAPxLFXpWi+D8rqeGVJ+QlP/v3vh84/7D71chAqsOuYfY72DZ7rC7ObyBg9YPUbUgXmW66Jn6iIbr4t39jhDoCVGG0a29hLZ5W9qzH9/HrHyt+BXgawtDuAl/E5Q4F+ZfORyTT+cZOTnND2uQQ6MVPUTOhyII4ixLC/y4ESC9EjzU4nTjtjTTdsijdeFRICvQY5dePPA5ZhqevBwi2OBkmV/FgPJichRTkIl7+vjbuWKwd7ylbnqK8q1L3v/yCtFzohW9rXEoBZGVKI63kdiDjHtNyaeTZ3eurWwihnPtAYsBhvxgpoM/9p2iU/11IpSTqkWc41MeO0mMIDXEi8TsoCzBLc8kqn+9HaVpJxoXovivKh70SuzUQXcMtRU9GLgiE+TI3HG5oNP50zeAwpbZLHXDDetqL5dIt//lNdXEWciU +``` + +The encrypted data in this vector was produced with a fixed 16-byte +secret, `0102030405060708090a0b0c0d0e0f10`, so that it is reproducible. In real use +the secret is fresh entropy, so two runs of the same descriptor produce +different text. Both open with the same keys. + +### It is on the chain + +This exact text was published on Bitcoin mainnet on 2026-09-10, so the +whole path can be checked by anyone with no software from us: + +| | | +|---|---| +| Transaction | `4801ea9c10e14a5ea5c0e5e68bfe08fd2422005ea0a3a9631fead29ce910a4df` | +| Published through | opreturnbot.com, Private flag set | +| Payload | 545 bytes in one OP_RETURN output | +| Transaction size | 671 vB, 3,355 sat at 5 sat/vB | +| Confirmed in | block 966450, `00000000000000000000e747012e57d01eda2fa303a2a5da6c32eaadbfd493c7` | +| Mined by | ViaBTC, in the first block after broadcast | + +Read the transaction on any explorer, take the OP_RETURN bytes as text, +and decrypt them with any two of the three keys above. You get the +descriptor above. + +### Verify it independently + +1. Open [multisigbackup.com](https://multisigbackup.com). +2. Go to the recover step and paste the encrypted text above. +3. Paste any two of the three extended public keys. +4. It prints the original descriptor. + +That is the whole promise: the text survives us. + +### Byte layout + +After the readable descriptor comes unpadded base64 of, in order: + +| Field | Size | Note | +|---|---|---| +| One sealed share per key | 33 bytes each | 32 when the wallet is 1 of n | +| Fingerprints, then key bodies | 4 and 74 bytes each | one ChaCha20 block | +| Lookup tags | 4 bytes per pair of fingerprints | optional, used to find the backup | + +For this 2 of 3: 3 x 33 + (3 x 4 + 3 x 74) + 3 x 4 = 345 bytes. + +Keys: the secret is 16 bytes of entropy; the data key is +HKDF-SHA256 of it with an empty salt and empty info; each share is sealed +with ChaCha20-Poly1305 under SHA-256(key body, ciphertext, share index). +Every nonce is zero, which is safe here because no two keys repeat. + +## One-key format (BIP-138) + +The one-key format follows +[BIP-138](https://github.com/bitcoin/bips/blob/master/bip-0138.md), which +has a number and the status Draft. It uses a random nonce and random decoy +entries, so there is no fixed output to publish. Check it against the BIP's +own vectors, which this repository runs in its test suite: +`internal/html/assets/src/crypto/testdata/bip138/`. + +Two things about our encoder are worth stating: + +- We drop the common account paths from the encoding, because a + recovering wallet tries them anyway. The draft's end-to-end vectors do + the same. +- We pad the entry list to the buckets the BIP asks for, 5, 10 and 20, so + that counting the entries does not count the cosigners. A 2-of-3 pads to + five and a 3-of-7 pads to ten. We wrote a fixed seven until 2026-09-22. + That number covered every wallet up to seven cosigners, and it also marked + our backups as ours among all BIP-138 backups. A 2-of-3 is 64 bytes + smaller on the bucket: 655 bytes rather than 719. + +## Running the checks + +``` +make test-ts +``` + +That type-checks and runs both suites: the threshold format against the +vector above, and the one-key format against every vector the BIP-138 +draft ships. diff --git a/docs/guide.md b/docs/guide.md deleted file mode 100644 index 9ef46b4..0000000 --- a/docs/guide.md +++ /dev/null @@ -1,618 +0,0 @@ -# Kaitiaki User Guide - -This guide walks you through using Kaitiaki to create encrypted recovery bundles for your trusted friends. - -> **Prefer a browser?** This guide focuses on the CLI tool. If you'd rather create bundles in your browser without installing anything, see the [web-based guide](https://eljojo.github.io/rememory/docs.html). - -## Table of Contents - -- [Overview](#overview) -- [Installation](#installation) -- [Creating Your First Project](#creating-your-first-project) -- [Adding Your Secrets](#adding-your-secrets) -- [Sealing the Project](#sealing-the-project) -- [Creating Distribution Bundles](#creating-distribution-bundles) -- [Distributing to Friends](#distributing-to-friends) -- [What Your Friends Receive](#what-your-friends-receive) -- [Recovery Process](#recovery-process) -- [Verifying Bundles](#verifying-bundles) -- [Best Practices](#best-practices) -- [Project Structure](#project-structure) -- [Commands Reference](#commands-reference) -- [Revoking Access](#revoking-access) -- [Advanced: Anonymous Mode](#advanced-anonymous-mode) -- [Advanced: Multilingual Bundles](#advanced-multilingual-bundles) -- [Self-Hosting](#self-hosting) - -## Overview - -Kaitiaki is a digital safe with multiple keys. It protects your files and divides the key among people you trust. You choose how many must come together to open it. - -Under the hood: - -1. Files are encrypted with [age](https://github.com/FiloSottile/age) (strong, modern cryptography) -2. The key is split using [Shamir's Secret Sharing](https://en.wikipedia.org/wiki/Shamir%27s_secret_sharing) -3. Each person gets a self-contained bundle for recovery - -Recovery works **entirely offline in a browser** — no servers, no need for Kaitiaki to exist when recovery happens.* - -* [Time-locked](https://eljojo.github.io/rememory/docs#timelock) archives need a brief internet connection at recovery time. - -## Installation - -### macOS (Homebrew) - -```bash -brew install eljojo/rememory/rememory -``` - -### Linux - -Download the binary, make it executable, and move it to your path. - -**x86_64:** - -```bash -curl -Lo rememory https://github.com/eljojo/rememory/releases/latest/download/rememory-linux-amd64 -chmod +x rememory -sudo mv rememory /usr/local/bin/ -``` - -**ARM64:** - -```bash -curl -Lo rememory https://github.com/eljojo/rememory/releases/latest/download/rememory-linux-arm64 -chmod +x rememory -sudo mv rememory /usr/local/bin/ -``` - -Binaries for all platforms are available on the [Releases](https://github.com/eljojo/rememory/releases) page. - -### Nix - -Run directly without installing: - -```bash -nix run github:eljojo/rememory -``` - -
-Install permanently - -Add to your flake inputs: - -```nix -{ - inputs.rememory.url = "github:eljojo/rememory"; - inputs.rememory.inputs.nixpkgs.follows = "nixpkgs"; -} -``` - -Then include in your NixOS configuration: - -```nix -# configuration.nix -{ inputs, ... }: -{ - environment.systemPackages = [ inputs.rememory.packages.${system}.default ]; -} -``` - -Or in home-manager: - -```nix -# home.nix -{ inputs, ... }: -{ - home.packages = [ inputs.rememory.packages.${system}.default ]; -} -``` - -
- -### Man pages (optional) - -```bash -mkdir -p ~/.local/share/man/man1 -rememory doc ~/.local/share/man/man1 -``` - - -## Creating Your First Project - -Start by creating a new project: - -```bash -rememory init my-recovery-2026 -cd my-recovery-2026 -``` - -You'll be prompted to configure your recovery scheme: - -``` -How many friends will hold shares? [5]: 5 -How many shares needed to recover? [3]: 3 - -Friend 1: - Name: Alice - Contact info (optional): alice@example.com - -Friend 2: - Name: Bob - Contact info (optional): - -Friend 3: - Name: Carol - Contact info (optional): carol@example.com - -... -``` - -### Choosing the Right Numbers - -| Friends | Recommended Threshold | Notes | -|---------|----------------------|-------| -| 3 | 2 | Minimum viable setup | -| 5 | 3 | Good balance of security and availability | -| 7 | 4-5 | Higher security, requires more coordination | - -**Rule of thumb:** Set threshold high enough that casual collusion is unlikely, but low enough that recovery is possible if 1-2 friends are unavailable. - -## Adding Your Secrets - -Place your sensitive files in the `manifest/` directory: - -```bash -# Copy important files -cp ~/Documents/recovery-codes.txt manifest/ -cp ~/Documents/crypto-seeds.txt manifest/ -cp ~/Documents/important-passwords.txt manifest/ - -# Or create files directly -echo "The safe combination is 12-34-56" > manifest/notes.txt -echo "Bank account: 123456789" >> manifest/notes.txt -``` - -You can organize files in subdirectories: - -```bash -mkdir -p manifest/crypto -mkdir -p manifest/accounts -cp ~/wallets/*.txt manifest/crypto/ -cp ~/passwords/*.txt manifest/accounts/ -``` - -### What to Include - -Good candidates for Kaitiaki: -- Password manager recovery codes -- Cryptocurrency seeds/keys -- Important account credentials -- Instructions for loved ones -- Legal document locations -- Safe combinations - -### What NOT to Include - -- Files that change frequently (use Kaitiaki for static secrets) -- Extremely large files (bundles become unwieldy) -- Anything already backed up elsewhere with good recovery options - -## Sealing the Project - -Once your secrets are in place, seal the project: - -```bash -rememory seal -``` - -This: -1. Generates a random 256-bit passphrase -2. Encrypts all files in `manifest/` using age encryption -3. Splits the passphrase into shares using Shamir's Secret Sharing -4. Verifies that recovery works correctly -5. Generates distribution bundles for each friend - -``` -Archiving manifest/ (3 files, 1.2 KB)... -Encrypting with age... -Splitting into 5 shares (threshold: 3)... -Verifying reconstruction... OK - -Sealed: - ✓ output/MANIFEST.age - ✓ output/shares/SHARE-alice.txt - ✓ output/shares/SHARE-bob.txt - ✓ output/shares/SHARE-carol.txt - ✓ output/shares/SHARE-david.txt - ✓ output/shares/SHARE-eve.txt - -Generating bundles for 5 friends... - -Bundles ready to distribute: - ✓ bundle-alice.zip (5.4 MB) - ✓ bundle-bob.zip (5.4 MB) - ✓ bundle-carol.zip (5.4 MB) - ✓ bundle-david.zip (5.4 MB) - ✓ bundle-eve.zip (5.4 MB) - -Saved to: output/bundles -``` - -Each bundle is ~5 MB because it includes the complete recovery tool. - -### Regenerating Bundles - -If you need to regenerate bundles (e.g., you lost them or want to update `recover.html`): - -```bash -rememory bundle -``` - -## Distributing to Friends - -Send each friend their specific bundle. Methods: - -- **Email** — Attach the ZIP file -- **Cloud storage** — Share via Dropbox, Google Drive, etc. -- **USB drive** — Physical handoff -- **Encrypted messaging** — Signal, WhatsApp, etc. - -Tell your friends: -1. Keep the bundle somewhere safe (cloud backup, USB drive, etc.) -2. They cannot use it alone—they'll need to coordinate with others -3. A single share reveals nothing, but they should still keep it private - -## What Your Friends Receive - -Each bundle contains: - -| File | Purpose | -|------|---------| -| `README.txt` | Instructions + their unique share + contact list for other holders | -| `README.pdf` | Same content, formatted for printing | -| `MANIFEST.age` | Your encrypted secrets (same in all bundles) | -| `recover.html` | **Personalized** browser-based recovery tool (~300 KB, self-contained) | - -**What makes each bundle unique:** -- The `recover.html` is personalized for each friend: - - Their share is pre-loaded automatically - - Shows a contact list with other friends' info - - If the encrypted manifest is 10 MB or less, it's also embedded in `recover.html`—so friends only need to collect shares from others to complete recovery - - For larger manifests, they'll also need to load the separate `MANIFEST.age` file - -The README.txt includes: - -``` -================================================================================ - REMEMORY RECOVERY BUNDLE - For: Alice -================================================================================ - -!! YOU CANNOT USE THIS FILE ALONE - You will need help from other friends listed below. - -!! CONFIDENTIAL - DO NOT SHARE THIS FILE - This document contains your secret share. Keep it safe. - - NOTA PARA HISPANOHABLANTES: - Si no entiendes inglés, puedes usar ChatGPT u otra inteligencia artificial - para que te ayude a entender estas instrucciones y recuperar los datos. - --------------------------------------------------------------------------------- -WHAT IS THIS? --------------------------------------------------------------------------------- -This bundle allows you to help recover encrypted secrets. -You are one of 5 trusted friends who hold pieces of the recovery key. -At least 3 of you must cooperate to decrypt the contents. - --------------------------------------------------------------------------------- -OTHER SHARE HOLDERS (contact to coordinate recovery) --------------------------------------------------------------------------------- -Bob - bob@example.com - 555-2345 -Carol - carol@example.com -David - david@example.com - 555-4567 -Eve - eve@example.com - --------------------------------------------------------------------------------- -HOW TO RECOVER (PRIMARY METHOD - Browser) --------------------------------------------------------------------------------- -1. Open recover.html in any modern browser -2. Drag and drop this README.txt file -3. Collect shares from other friends (they drag their README.txt too) -4. Once you have enough shares, the tool will decrypt automatically -5. Download the recovered files - -Works completely offline — no internet required.* - --------------------------------------------------------------------------------- -YOUR SHARE --------------------------------------------------------------------------------- ------BEGIN REMEMORY SHARE----- -Version: 1 -Index: 1 -Total: 5 -Threshold: 3 -Holder: Alice -... ------END REMEMORY SHARE----- -``` - -## Recovery Process - -### Browser Recovery (Recommended) - -When your friends need to recover your secrets: - -1. **One friend opens `recover.html`** from their bundle in any modern browser - - Their share is **automatically pre-loaded** (the tool is personalized!) - - They'll see a **contact list** showing other friends who hold shares - -2. **Load the encrypted manifest** - - For small manifests (≤ 10 MB), this step is automatic—the manifest is embedded in `recover.html` - - Otherwise, drag and drop `MANIFEST.age` from the bundle onto the manifest area, or click to browse - -3. **Coordinate with other friends** - - The contact list shows names, emails, and phone numbers - - Reach out and ask them to send their `README.txt` file - -4. **Add shares from other friends** - - Drag and drop their `README.txt` files onto the page, OR - - Click the 📋 clipboard button to paste share text directly - - As each share is added, a ✓ checkmark appears next to that friend's name - -5. **Recovery happens automatically** - - Once threshold is met (e.g., 2 of 3 shares), decryption starts immediately - - The input steps collapse to show the recovery progress - - No need to click any buttons! - -6. **Download the recovered files** - -**Key points:** -- Works completely offline — no internet required* -- No data leaves the browser -- Works on Chrome, Firefox, Safari, Edge -- Friends can be in different locations; they just need to share their README.txt files -- Each friend's `recover.html` is personalized with their share pre-loaded - -### CLI Recovery (Fallback) - -If the browser tool doesn't work: - -```bash -# Download rememory from GitHub releases, then: -rememory recover bundle-alice.zip bundle-bob.zip bundle-carol.zip -``` - -You can also pass extracted share files and a manifest separately: - -```bash -rememory recover SHARE-alice.txt SHARE-bob.txt SHARE-carol.txt -m MANIFEST.age -``` - -## Verifying Bundles - -Before distributing, verify your bundles are valid: - -```bash -rememory verify-bundle output/bundles/bundle-alice.zip -``` - -This checks: -- All required files are present -- Checksums match -- The embedded share is valid - -You can also verify bundles you receive from others to ensure they haven't been corrupted. - -## Best Practices - -### Choosing Friends - -- **Longevity** — Pick people likely to be reachable in 5-10+ years -- **Geographic diversity** — Don't put all friends in the same disaster zone -- **Technical ability** — Mix is fine; the tool is designed for everyone -- **Relationships** — Consider if they'll cooperate with each other -- **Trust** — While a single share reveals nothing, you're trusting them with responsibility - -### Security Considerations - -- **Keep your sealed project secure** — The passphrase is stored in project.yml after sealing -- **Delete the manifest after sealing** — Or keep it somewhere very secure -- **Don't keep all bundles together** — That defeats the purpose of splitting -- **Consider printing README.pdf** — Paper backups survive digital disasters - -### Rotation - -Consider creating a new project every 2-3 years: -- Friends' contact info changes -- You may want to update secrets -- Relationships change -- New cryptographic best practices emerge - -You can copy friend configuration: - -```bash -rememory init new-project --from old-project -``` - -### Revoking Access - -There is no way to remotely revoke a share once it has been distributed. This is by design — the system is offline and serverless, so there is no central authority that can invalidate a share. - -If you need to remove someone from your recovery group (e.g., a falling out, or you simply want to change who holds shares), the only option is: - -1. **Create a new project** with a new set of friends and a fresh passphrase -2. **Send new bundles** to the friends you still trust -3. **Ask every remaining friend to delete their old bundle** and replace it with the new one - -This last step is critical. Old shares can still decrypt old manifests, so friends must not keep old bundles "just in case." When you send someone a new bundle, be clear: **delete the old one, keep only the new one.** No version history, no archives — just the latest bundle. - -The same applies when you update your secrets (e.g., a password changed). Sealing a new project generates a completely new passphrase and new shares. The old shares become useless for the new manifest, but they still work with the old `MANIFEST.age`. Make sure friends aren't holding on to old copies. - -## Project Structure - -After running all commands, your project looks like: - -``` -my-recovery-2026/ -├── project.yml # Configuration (friends, threshold, checksums) -├── manifest/ # Your secret files (ADD FILES HERE) -│ ├── README.md # Default instructions file -│ ├── recovery-codes.txt -│ └── notes.txt -└── output/ - ├── MANIFEST.age # Encrypted archive of manifest/ - ├── shares/ # Individual share files - │ ├── SHARE-alice.txt - │ ├── SHARE-bob.txt - │ └── ... - ├── bundles/ # Distribution packages - │ ├── bundle-alice.zip - │ ├── bundle-bob.zip - │ └── ... - └── pages/ # Static hosting (with --pages) - ├── recover.html - └── MANIFEST.age -``` - -## Commands Reference - -| Command | Description | -|---------|-------------| -| `rememory init ` | Create a new project | -| `rememory demo [dir]` | Create a demo project with sample data (great for testing!) | -| `rememory seal` | Encrypt manifest, create shares, and generate bundles. `--pages` also generates a static hosting directory | -| `rememory bundle` | Regenerate bundles (if lost or need updating). `--pages` also generates a static hosting directory | -| `rememory status` | Show project status and summary | -| `rememory verify` | Verify integrity of sealed files | -| `rememory verify-bundle ` | Verify a bundle's integrity | -| `rememory recover` | Recover secrets from shares | -| `rememory doc ` | Generate man pages | - -For detailed help on any command: - -```bash -rememory --help -``` - -## Advanced: Anonymous Mode - -For situations where you don't want shareholders to know each other's identities, Kaitiaki offers an **anonymous mode**. In this mode: - -- Friends are labeled generically as "Share 1", "Share 2", etc. -- No contact information is collected or stored -- READMEs skip the "Other Share Holders" section -- Bundle filenames use numbers instead of names (`bundle-share-1.zip`, etc.) - -### When to Use Anonymous Mode - -Anonymous mode is useful when: -- You want to distribute shares to people who shouldn't know each other -- You're testing the system quickly without entering contact details -- You have a separate out-of-band method for coordinating recovery -- Privacy is a higher priority than ease of coordination - -### Creating an Anonymous Project - -```bash -# Create an anonymous project with 5 shares, threshold 3 -rememory init my-recovery --anonymous --shares 5 --threshold 3 -``` - -You can also run it interactively: - -```bash -rememory init my-recovery --anonymous -# Prompts: How many shares? and What threshold? -``` - -The resulting `project.yml` will look like: - -```yaml -name: my-recovery -threshold: 3 -anonymous: true -friends: - - name: Share 1 - - name: Share 2 - - name: Share 3 - - name: Share 4 - - name: Share 5 -``` - -### Recovery in Anonymous Mode - -Recovery works the same way, but: -- The contact list section won't appear in `recover.html` -- Share holders will need to coordinate through other means -- Shares show generic labels like "Share 1" instead of names - -Since there's no built-in contact list, make sure share holders know how to reach each other (or you) when recovery is needed. - -## Advanced: Multilingual Bundles - -Each friend can receive their bundle (README.txt, README.pdf, and recover.html) in their preferred language. Kaitiaki supports 5 languages: English (en), Spanish (es), German (de), French (fr), and Slovenian (sl). - -### CLI Usage - -Set the project-level default language with `--language`: - -```bash -# All bundles in Spanish -rememory init my-recovery --language es - -# Per-friend language customization -rememory init my-recovery --language es \ - --friend "Alice,alice@example.com,en" \ - --friend "Roberto,roberto@example.com,es" \ - --friend "Hans,hans@example.com,de" -``` - -The `--friend` flag now accepts an optional third field for language: `"Name,contact,lang"`. - -### project.yml Format - -You can also set languages directly in `project.yml`: - -```yaml -name: my-recovery-2026 -threshold: 3 -language: es # default bundle language (optional, defaults to "en") -friends: - - name: Alice - contact: alice@example.com - language: en # override per friend - - name: Roberto - contact: roberto@example.com - # uses project language (es) - - name: Hans - contact: hans@example.com - language: de -``` - -### Web UI - -In the web-based bundle creator (maker.html), each friend entry has a **Bundle language** dropdown. The default is the current UI language. Friends can always switch languages in recover.html regardless of the bundle default. - -### What Gets Translated - -- **README.txt**: All instructions, warnings, and section headings -- **README.pdf**: Same content as README.txt in PDF format -- **recover.html**: Opens in the friend's language by default (they can still switch) - -## Self-Hosting - -If you'd rather give friends a URL than a ZIP file, there are two options: - -**Static pages** — generate a folder and upload it anywhere: - -```bash -rememory seal --pages -# or, after sealing: -rememory bundle --pages -``` - -This creates `output/pages/` with `recover.html` and `MANIFEST.age`. Drop that folder on GitHub Pages, Netlify, or any web server — friends visit the URL, and the page fetches the manifest automatically. They still need their shares to decrypt. - -**Full server** — run `rememory serve` for bundle creation, storage, and recovery all from a browser. See the [self-hosting guide](selfhosted.md) for setup instructions and deployment examples. diff --git a/docs/hide-quorum-design.md b/docs/hide-quorum-design.md index d5453fb..7b51ce5 100644 --- a/docs/hide-quorum-design.md +++ b/docs/hide-quorum-design.md @@ -2,7 +2,7 @@ ## What ships now -`rememory init --hide-quorum` stores `hide_quorum: true` in +`inheritance init --hide-quorum` stores `hide_quorum: true` in `project.yml`. On seal: - Share headers omit `Total:` and `Threshold:` (encoded as 0 @@ -13,7 +13,7 @@ - `project.yml` keeps the real values. It stays outside the bundles, so the owner still knows the quorum. -CLI recovery works by try-decrypt: `rememory recover` combines the +CLI recovery works by try-decrypt: `inheritance recover` combines the shares you give it and attempts decryption. With too few shares the combined passphrase is garbage and age rejects it (proven in the 2026-08-18 evaluation). Add a share and try again. @@ -38,7 +38,7 @@ privacy; most users should not hide it. not the quorum. A future format bump could drop the fields. 3. **`verify` command and translated templates.** Audit - `rememory verify` / `verify-bundle` output and the non-English + `inheritance verify` / `verify-bundle` output and the non-English readme strings for stray "N of M" phrasing with zero values. 4. **Tests.** Add an e2e test: init --hide-quorum, seal, assert no diff --git a/docs/owner-key-vector.md b/docs/owner-key-vector.md index 547b064..2d3d94e 100644 --- a/docs/owner-key-vector.md +++ b/docs/owner-key-vector.md @@ -17,7 +17,7 @@ age-keygen -o owner-key.txt Derivation: BIP39 mnemonic (+ optional passphrase) -> 64-byte BIP39 seed (PBKDF2-HMAC-SHA512, 2048 rounds, salt "mnemonic"+passphrase) -> -SLIP-21 node m/"kaitiaki" -> the node's key (bytes 32..64) is the +SLIP-21 node m/"inheritance" -> the node's key (bytes 32..64) is the X25519 scalar of the age identity. SLIP-21 (https://github.com/satoshilabs/slips/blob/master/slip-0021.md): @@ -33,7 +33,7 @@ key(node) = node[32:64] ``` mnemonic = "all all all all all all all all all all all all" passphrase = "" -label = "kaitiaki" +label = "inheritance" identity = AGE-SECRET-KEY-1E3PUXA5R3R9Y3R9DPTF57F8HD4YXL7DNWWGUJMRMTFWNARP3KQFSJ2M754 recipient = age17pv0xledcth6mtfpgmtaxc3gahxdkt5ad79cxwxtgj966kqxq9fs3m8ced diff --git a/docs/roadmap.md b/docs/roadmap.md index 7cd42c4..0ff11cb 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -11,5 +11,4 @@ Future ideas for the self-hosted mode. These are possibilities, not commitments. - **Backup/export** — Download the entire data directory as a single archive - **OIDC/OAuth** — Built-in authentication instead of relying on auth proxies - **PDF-only bundles** — An advanced option in the bundle creator: instead of a full ZIP with recover.html, just produce the PDF with the share. For self-hosted setups where recovery happens through the server, the ZIP and offline tool aren't needed — the PDF alone is enough to hand someone -- **Contact list on the home page** — Show each friend's contact info on the self-hosted home page, so anyone in the family can see who holds a piece and how to reach them - **Custom branding** — Upload a logo, set a project name shown in the header diff --git a/docs/screenshots/.gitkeep b/docs/screenshots/.gitkeep index 90cb4d5..1185862 100644 --- a/docs/screenshots/.gitkeep +++ b/docs/screenshots/.gitkeep @@ -2,6 +2,7 @@ # Used by docs.html: # - friends.png (Step 1: Add friends) # - files.png (Step 2: Add files) -# - bundles.png (Step 3: Generate bundles) +# - bundles.png (Step 4: Generate bundles) +# - owners-words.png (Step 3: What your heirs need to know) # - recovery-1.png (Recovery process - collecting shares) # - recovery-2.png (Recovery process - decryption complete) diff --git a/docs/screenshots/demo-pdf/page-1.png b/docs/screenshots/demo-pdf/page-1.png index 979ce72..bf21e25 100644 Binary files a/docs/screenshots/demo-pdf/page-1.png and b/docs/screenshots/demo-pdf/page-1.png differ diff --git a/docs/screenshots/demo-pdf/page-2.png b/docs/screenshots/demo-pdf/page-2.png index f492cd5..aeb331e 100644 Binary files a/docs/screenshots/demo-pdf/page-2.png and b/docs/screenshots/demo-pdf/page-2.png differ diff --git a/docs/screenshots/demo-pdf/page-3.png b/docs/screenshots/demo-pdf/page-3.png index b78dbf4..3cabe03 100644 Binary files a/docs/screenshots/demo-pdf/page-3.png and b/docs/screenshots/demo-pdf/page-3.png differ diff --git a/docs/screenshots/en/bundles.png b/docs/screenshots/en/bundles.png index 6090ca0..c12ab98 100644 Binary files a/docs/screenshots/en/bundles.png and b/docs/screenshots/en/bundles.png differ diff --git a/docs/screenshots/en/chain-copy.png b/docs/screenshots/en/chain-copy.png new file mode 100644 index 0000000..1f300d8 Binary files /dev/null and b/docs/screenshots/en/chain-copy.png differ diff --git a/docs/screenshots/en/files.png b/docs/screenshots/en/files.png index 34966ca..ac98669 100644 Binary files a/docs/screenshots/en/files.png and b/docs/screenshots/en/files.png differ diff --git a/docs/screenshots/en/friends.png b/docs/screenshots/en/friends.png index 6601f12..4f68e6c 100644 Binary files a/docs/screenshots/en/friends.png and b/docs/screenshots/en/friends.png differ diff --git a/docs/screenshots/en/maker-overview.png b/docs/screenshots/en/maker-overview.png index 671f1a5..e6319dc 100644 Binary files a/docs/screenshots/en/maker-overview.png and b/docs/screenshots/en/maker-overview.png differ diff --git a/docs/screenshots/en/owners-words.png b/docs/screenshots/en/owners-words.png new file mode 100644 index 0000000..56746e3 Binary files /dev/null and b/docs/screenshots/en/owners-words.png differ diff --git a/docs/screenshots/en/recovery-1.png b/docs/screenshots/en/recovery-1.png index 49ed6fd..0028fd1 100644 Binary files a/docs/screenshots/en/recovery-1.png and b/docs/screenshots/en/recovery-1.png differ diff --git a/docs/screenshots/en/recovery-2.png b/docs/screenshots/en/recovery-2.png index 76a284c..35fd54c 100644 Binary files a/docs/screenshots/en/recovery-2.png and b/docs/screenshots/en/recovery-2.png differ diff --git a/docs/screenshots/en/recovery-words-recognized.png b/docs/screenshots/en/recovery-words-recognized.png index 10d30c4..8a7d37a 100644 Binary files a/docs/screenshots/en/recovery-words-recognized.png and b/docs/screenshots/en/recovery-words-recognized.png differ diff --git a/docs/screenshots/en/recovery-words-typing.png b/docs/screenshots/en/recovery-words-typing.png index d565f54..1609049 100644 Binary files a/docs/screenshots/en/recovery-words-typing.png and b/docs/screenshots/en/recovery-words-typing.png differ diff --git a/docs/screenshots/en/sealed-files.png b/docs/screenshots/en/sealed-files.png new file mode 100644 index 0000000..3eee60e Binary files /dev/null and b/docs/screenshots/en/sealed-files.png differ diff --git a/docs/screenshots/en/tlock-setup.png b/docs/screenshots/en/tlock-setup.png index 202e491..8d6a807 100644 Binary files a/docs/screenshots/en/tlock-setup.png and b/docs/screenshots/en/tlock-setup.png differ diff --git a/docs/screenshots/en/tlock-waiting.png b/docs/screenshots/en/tlock-waiting.png index 78e2692..62bc0ca 100644 Binary files a/docs/screenshots/en/tlock-waiting.png and b/docs/screenshots/en/tlock-waiting.png differ diff --git a/docs/screenshots/friends.png b/docs/screenshots/friends.png index 8b08c7c..4f68e6c 100644 Binary files a/docs/screenshots/friends.png and b/docs/screenshots/friends.png differ diff --git a/docs/screenshots/manifest-drop-zone.png b/docs/screenshots/manifest-drop-zone.png new file mode 100644 index 0000000..9b324d9 Binary files /dev/null and b/docs/screenshots/manifest-drop-zone.png differ diff --git a/docs/screenshots/manifest-file-picker.png b/docs/screenshots/manifest-file-picker.png deleted file mode 100644 index 7923272..0000000 Binary files a/docs/screenshots/manifest-file-picker.png and /dev/null differ diff --git a/docs/screenshots/qr-camera-permission.png b/docs/screenshots/qr-camera-permission.png deleted file mode 100644 index 966cdf4..0000000 Binary files a/docs/screenshots/qr-camera-permission.png and /dev/null differ diff --git a/docs/screenshots/qr-scanning.png b/docs/screenshots/qr-scanning.png index 0bf4cff..807fef7 100644 Binary files a/docs/screenshots/qr-scanning.png and b/docs/screenshots/qr-scanning.png differ diff --git a/docs/screenshots/recovery-1.png b/docs/screenshots/recovery-1.png index 5360cb8..e89aac0 100644 Binary files a/docs/screenshots/recovery-1.png and b/docs/screenshots/recovery-1.png differ diff --git a/docs/screenshots/scan-qr-button.png b/docs/screenshots/scan-qr-button.png new file mode 100644 index 0000000..614cb42 Binary files /dev/null and b/docs/screenshots/scan-qr-button.png differ diff --git a/docs/security-review.md b/docs/security-review.md index 45f4b3a..a90cdb0 100644 --- a/docs/security-review.md +++ b/docs/security-review.md @@ -186,7 +186,7 @@ grep -rn "math/rand" --include="*.go" . | grep -v _test.go The CLI makes zero network requests during seal and bundle operations. The only networking code is in: - `internal/core/tlock.go` — drand beacon fetching for tlock decryption (CLI `recover` command with tlock bundles) -- `internal/serve/` — the optional self-hosted server (`rememory serve`) +- `internal/serve/` — the optional self-hosted server (`inheritance serve`) **Enforced by tests, not just grep.** The test suite blocks all network access at the transport level and fails if any unexpected connection is attempted: @@ -333,11 +333,18 @@ No custom cryptographic primitives are used anywhere. All cryptography is compos |------|----------|---------| | README.txt / README.pdf | Instructions, share in PEM format, share as BIP39 words, QR code | The friend's single share + scheme parameters (N, K) | | MANIFEST.age | age-encrypted archive (optionally with tlock inner layer) | Nothing (encrypted with 256-bit passphrase) | -| recover.html | Self-contained recovery tool + personalization JSON | Friend names/contact info (unless anonymous mode), the friend's pre-loaded share, and optionally the embedded manifest | +| recover.html | Self-contained recovery tool + personalization JSON | The holder's own name, their pre-loaded share, and optionally the embedded manifest | -**Personalization data** embedded in `recover.html` at [`bundle.go:91-99`](https://github.com/eljojo/rememory/blob/80cebf2/internal/bundle/bundle.go#L91-L99) includes: +**Changed 2026-09-24.** The three surfaces above used to carry the whole +guardian roster, with names and contact details, and the README also carried +the owner's own text and the encrypted chain copy. One bundle in the wrong +hands therefore named every other person to approach, and a README is built to +be forwarded. All of it now goes inside the encrypted archive, which needs the +threshold. The structs that build a README no longer have a field that could +hold any of it. + +**Personalization data** embedded in `recover.html` includes: - The holder's name and share (necessary for recovery) -- Other friends' names and contact info (necessary for coordinating recovery — empty in anonymous mode) - Threshold and total (necessary for recovery instructions) - Optionally the base64-encoded MANIFEST.age if <= 10 MiB (convenience for self-contained recovery) @@ -496,16 +503,16 @@ Since the previous audit, the recovery path has moved from WASM to native JavaSc | Function | Direction | Validates? | |----------|-----------|-----------| -| `rememoryParseShare` | string → share object | Argument count; checksum verified in Go | -| `rememoryCombineShares` | share array → passphrase | Argument count; version consistency | -| `rememoryDecryptManifest` | Uint8Array + string → Uint8Array | Argument count | -| `rememoryExtractArchive` | Uint8Array → file array | Argument count; path traversal + size limits | -| `rememoryExtractBundle` | Uint8Array → share + manifest | Argument count; checksum verified | -| `rememoryParseCompactShare` | string → share object | Format + checksum validated | -| `rememoryDecodeWords` | string array → data + index | Checksum validated | -| `rememoryCreateArchive` | file list → ZIP bytes | Filename validation | -| `rememoryCreateBundlesFromArchive` | archive + config → bundles | Config validation | -| `rememoryParseProjectYAML` | string → config | YAML parsing | +| `inheritanceParseShare` | string → share object | Argument count; checksum verified in Go | +| `inheritanceCombineShares` | share array → passphrase | Argument count; version consistency | +| `inheritanceDecryptManifest` | Uint8Array + string → Uint8Array | Argument count | +| `inheritanceExtractArchive` | Uint8Array → file array | Argument count; path traversal + size limits | +| `inheritanceExtractBundle` | Uint8Array → share + manifest | Argument count; checksum verified | +| `inheritanceParseCompactShare` | string → share object | Format + checksum validated | +| `inheritanceDecodeWords` | string array → data + index | Checksum validated | +| `inheritanceCreateArchive` | file list → ZIP bytes | Filename validation | +| `inheritanceCreateBundlesFromArchive` | archive + config → bundles | Config validation | +| `inheritanceParseProjectYAML` | string → config | YAML parsing | **Data marshaling:** Binary data crosses the boundary as `Uint8Array` using `js.CopyBytesToGo()` / `js.CopyBytesToJS()` — these are memory copies, not shared references. The passphrase is returned as a JavaScript string from `combineSharesJS`. @@ -657,7 +664,7 @@ This means an attacker needs both the passphrase (from K shares) AND the drand b **Server-side HTML generation:** [`internal/html/recover.go:136-142`](https://github.com/eljojo/rememory/blob/80cebf2/internal/html/recover.go#L136-L142) -Personalization data (friend names, contact info, share content) is embedded in `recover.html` via `json.Marshal()`. Go's `json.Marshal` HTML-escapes `<`, `>`, `&` by default (as `\u003c`, `\u003e`, `\u0026`), preventing `` injection. +Personalization data (the holder's own name and share, and nothing about any other guardian since 2026-09-24) is embedded in `recover.html` via `json.Marshal()`. Go's `json.Marshal` HTML-escapes `<`, `>`, `&` by default (as `\u003c`, `\u003e`, `\u0026`), preventing `` injection. **CSP as defense-in-depth:** Even if an XSS vector were found, the nonce-based CSP would prevent execution of injected scripts that don't carry the correct nonce. diff --git a/docs/selfhosted.md b/docs/selfhosted.md index 235225b..f3d3eef 100644 --- a/docs/selfhosted.md +++ b/docs/selfhosted.md @@ -1,93 +1,97 @@ -# Hosting Kaitiaki +# Hosting Bitcoin Inheritance -There are two ways to host Kaitiaki for your friends: **static pages** (simplest) and a **self-hosted server** (full-featured). +There are two ways to host Bitcoin Inheritance for your guardians: **static pages** (simplest) and a **self-hosted server** (full-featured). ## Static pages The lightest option. Generate a folder with `recover.html` and `MANIFEST.age`, then upload it anywhere that serves files — GitHub Pages, Netlify, an S3 bucket, any web server. ```bash -rememory seal --pages +inheritance seal --pages # or after sealing: -rememory bundle --pages +inheritance bundle --pages ``` -This creates `output/pages/` in your project. The `recover.html` page fetches `MANIFEST.age` from the same directory automatically. Friends visit the URL, add their shares, and recover. No server-side code runs — it's just static files. +This creates `output/pages/` in your project. The `recover.html` page fetches `MANIFEST.age` from the same directory automatically. Guardians visit the URL, add their shares, and recover. No server-side code runs — it's just static files. Works well when: -- You want to give friends a URL instead of (or alongside) a ZIP file +- You want to give guardians a URL instead of (or alongside) a ZIP file - You don't need the ability to create bundles from the browser - You don't want to run a server Limitations: -- Friends still need their shares (from their bundles or README.txt files) +- Guardians still need their shares (from their bundles or README.txt files) - No admin interface — you manage files directly -- No bundle creation in the browser (use the CLI or [maker.html](https://eljojo.github.io/rememory/maker.html)) +- No bundle creation in the browser (use [Create Bundles](https://www.bitcoinbutlers.com/tools/inheritance/maker.html), or the command line) ## Self-hosted server -Run Kaitiaki as a web app on your own server — create bundles, store encrypted archives, and recover, all from a browser. +Run Bitcoin Inheritance as a web app on your own server — create bundles, store encrypted archives, and recover, all from a browser. ### Docker -A pre-built image is published to GitHub Container Registry on every release. +Build the image from this repository: ```bash +docker build -t inheritance:local . docker run -d \ - --name rememory \ - -p 8080:8080 \ - -v rememory-data:/data \ - ghcr.io/eljojo/rememory:latest + --name inheritance \ + -p 127.0.0.1:8080:8080 \ + -v inheritance-data:/data \ + inheritance:local ``` -Visit `http://localhost:8080` to set up. The first page asks you to choose an admin password for deleting bundles. +The build needs no toolchain on your machine. It installs Go and Node inside +the image, compiles the TypeScript and the maker's WebAssembly, and ships only +the binary in the final layer. -To pin a specific version: +Visit `http://localhost:8080` to set up. The first page asks you to choose an +admin password for deleting bundles. -```bash -docker run -d \ - --name rememory \ - -p 8080:8080 \ - -v rememory-data:/data \ - ghcr.io/eljojo/rememory:v0.0.16 -``` +The port binds to `127.0.0.1` on purpose. Put a reverse proxy with TLS in front +of it before you expose it to a network. See **Reverse proxy** below. **Docker Compose:** ```yaml services: - rememory: - image: ghcr.io/eljojo/rememory:latest + inheritance: + build: . ports: - - "8080:8080" + - "127.0.0.1:8080:8080" volumes: - - rememory-data:/data + - inheritance-data:/data restart: unless-stopped # environment: - # REMEMORY_MAX_MANIFEST_SIZE: 200MB + # INHERITANCE_MAX_MANIFEST_SIZE: 200MB volumes: - rememory-data: + inheritance-data: ``` -The container is a single static binary with no dependencies. Data lives in `/data` — mount a volume there to persist across restarts. +The final image carries the binary and a CA bundle, nothing else. Data lives in `/data` — mount a volume there to persist across restarts. ### Without Docker -If you have the CLI installed: +Build the binary, then run it: ```bash -rememory serve +npm install +make build +./inheritance serve ``` +`make build` needs Go and Node. It compiles the TypeScript, builds the maker's +WebAssembly, then the binary. + ### Options | Flag | Env var | Default | Description | |------|--------|---------|-------------| -| `--port, -p` | `REMEMORY_PORT` | `8080` | Port to listen on | -| `--host` | `REMEMORY_HOST` | `127.0.0.1` | Host to bind to | -| `--data, -d` | `REMEMORY_DATA` | `./rememory-data` | Data directory for bundles and config | -| `--max-manifest-size` | `REMEMORY_MAX_MANIFEST_SIZE` | `50MB` | Maximum MANIFEST.age size (e.g. `50MB`, `1GB`) | +| `--port, -p` | `INHERITANCE_PORT` | `8080` | Port to listen on | +| `--host` | `INHERITANCE_HOST` | `127.0.0.1` | Host to bind to | +| `--data, -d` | `INHERITANCE_DATA` | `./inheritance-data` | Data directory for bundles and config | +| `--max-manifest-size` | `INHERITANCE_MAX_MANIFEST_SIZE` | `50MB` | Maximum MANIFEST.age size (e.g. `50MB`, `1GB`) | Flags take precedence over environment variables. @@ -99,7 +103,7 @@ Put the server behind a reverse proxy with TLS. **Caddy:** ``` -rememory.example.com { +inheritance.example.com { reverse_proxy localhost:8080 } ``` @@ -108,7 +112,7 @@ rememory.example.com { ```nginx server { listen 443 ssl; - server_name rememory.example.com; + server_name inheritance.example.com; location / { proxy_pass http://localhost:8080; @@ -135,14 +139,14 @@ The admin password only protects bundle deletion. For access control, use an aut - The admin password uses age's scrypt-based passphrase encryption. Choose a strong one. - Put the server behind HTTPS and authentication appropriate for your threat model. -Friends still get self-contained offline bundles. The server is a convenience — if it goes away, they can recover without it. +Guardians still get self-contained offline bundles. The server is a convenience — if it goes away, they can recover without it. ## Data directory The data directory contains: ``` -rememory-data/ +inheritance-data/ admin.age # Admin password (age-encrypted) bundles/ / diff --git a/docs/service/annual-drill.md b/docs/service/annual-drill.md index 760d5e3..d46faea 100644 --- a/docs/service/annual-drill.md +++ b/docs/service/annual-drill.md @@ -1,8 +1,8 @@ -# Kaitiaki Annual Drill — Butler runbook + client sheets +# Bitcoin Inheritance Annual Drill, Butler runbook + client sheets **Remote, global. USD 195/year.** Once a year. One hour plus guardian coordination. The drill is the service: it finds rot while rot is -cheap — before the funeral, never at it. Guardians join by video from +cheap, before the funeral, never at it. Guardians join by video from wherever they are; the rehearsal recovery runs on a guardian's own computer with the Butler directing by voice only. @@ -16,24 +16,98 @@ computer with the Butler directing by voice only. the recovery) and confirms README + recover.html open. USB sticks rot; this catches it. Rotate media every 5 years or at first read error. -3. **Quorum recovery rehearsal.** k guardians (rotate WHICH k each - year) perform a recovery: preferred via recover.html offline on a - guardian's own computer; every third year, run the independence - path instead — contrib/combine.py + stock age per - docs/independent-recovery.md — so the client re-proves the tool - is not a dependency. -4. **Payload freshness.** Has the wallet changed (new cosigner, new +3. **Quorum recovery rehearsal. The guardian drives, and you stay + silent.** k guardians (rotate WHICH k each year) perform a recovery: + preferred via recover.html offline on a guardian's own computer; + every third year, run the independence path instead, + contrib/combine.py + stock age per docs/independent-recovery.md, so + the client re-proves the tool is not a dependency. + + **Hand the keyboard to the least technical guardian present.** You do + not touch it. You do 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. + + A Butler who drives this step proves the Butler can recover. That is + not the thing the client is buying. The client is buying the + confidence that these people, on a bad day, without you, can do it. + + **The list of places they hesitated is the drill's real output.** + Every pause, every reread, every wrong click. Most of them are fixed + by a sentence in the estate insert or a clearer guardian briefing, + not by teaching the guardian. If the same pause appears two years + running, the document is wrong and the guardian is fine. +4. **Read the on-chain descriptor back.** Only where the client has one. + Take the transaction id from the estate insert, open the descriptor + page's Recover panel, and rebuild the descriptor with the client's own + keys. This checks three things at once: the estate insert still holds + the right id, the client can still produce enough keys, and the chain + copy still reads. It costs nothing and it takes two minutes. + + **Then read the bundle's copy too, and compare.** The drill already + opens the archive, so the bundle's copy is there: `CHAIN-COPY.txt`, + beside `HOW-THE-WALLET-WORKS.txt`. Check the text matches what came off + the chain. Two reads, not one. + + Changed 2026-09-24. This copy used to sit in the open in every README, + where a lone guardian could read it. It is sealed now, which means the + transaction id on the estate insert is the only route for an heir who + cannot gather enough guardians. **Check that id reads correctly every + drill.** It is no longer a convenience. + + **If the two differ, find out WHICH part differs. They are not the same + problem.** Corrected 2026-09-23: this step used to assume the bundles were + the older copy. It is the other way round. A bundle is re-issued whenever + the owner revises anything, and the chain copy can never be re-written, so + the chain copy is the one that goes out of date. + + **The words differ, the descriptor matches. Expected. Do nothing.** + The owner revised their instructions and the bundles carry the new ones. + The chain copy holds the words as they were on the day it was published, + and it always will. That is the design, not a fault. Tell the client the + bundle is the copy that counts and move on. + + **The descriptor differs. This is the failure.** The chain copy names a + wallet that no longer exists, so the day every bundle is gone, it leads an + heir to nothing. Publish a new chain copy, write the new transaction id on + the insert, and say plainly that the old one stays on the chain forever and + is now wrong. This is the only case that triggers a republish, and Bitcoin + Butlers pays for it. + + **The chain copy will not read at all.** Not stale. Broken, or the client + cannot produce enough keys. Treat it as a failed drill and repair it before + you leave. + + Whichever it is, the rule the heir follows never changes and the client + should hear it in these words: if the bundle and the chain copy disagree, + use the bundle. The bundle is newer and it carries its own date. + +5. **Payload freshness.** Has the wallet changed (new cosigner, new descriptor, moved funds structure)? Stale payload = failed drill → re-seal session. -5. **Sign the drill record** in the estate insert: date, guardians + + A re-seal re-issues EVERY bundle, not the ones that changed. The maker + generates a fresh secret and splits it anew, so a guardian's old piece and + another guardian's new piece cannot be combined at all. 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. +6. **Sign the drill record** in the estate insert: date, guardians confirmed, quorum used, recovery verified, payload version. ## Pass / fail -PASS: every guardian confirmed AND one quorum recovered the payload AND -payload matches current wallet reality. Anything less is a FAIL with a +PASS: every guardian confirmed AND one quorum recovered the payload +WITH A GUARDIAN DRIVING AND THE BUTLER SILENT AND payload matches +current wallet reality. A recovery the Butler drove is a demonstration +and does not count as a pass. Anything less is a FAIL with a named repair action and a booked follow-up. A failed drill is the -service working — say so to the client. +service working, say so to the client. + +A chain copy whose WORDS are out of date does not fail a drill. It is the +expected state of a copy that cannot be re-written, and the bundles carry the +current words. A chain copy whose DESCRIPTOR is out of date does fail it, +because it points an heir at a wallet that no longer exists. ## Widow test diff --git a/docs/service/estate-insert.md b/docs/service/estate-insert.md index 9abb0e9..427d7e1 100644 --- a/docs/service/estate-insert.md +++ b/docs/service/estate-insert.md @@ -1,33 +1,81 @@ -# Estate pack insert — template +# Estate pack insert, template *One printed page, kept with the will. Fill in by hand at the placement session. This page contains no secrets: it is the map to the map.* +> **This page is the only copy of three things.** The bundles name nobody, so +> the guardian list below exists nowhere else. The transaction id below is the +> only pointer to the chain copy. And a guardian who is asked for their piece +> checks this page before they send it. Lose it and the bundles cannot find +> each other. Keep it with the will. + --- ## Recovery of our family's Bitcoin records -Our important Bitcoin paperwork (wallet descriptors and instructions — +Our important Bitcoin paperwork (wallet descriptors and instructions , NOT the keys themselves) is protected by a guardianship scheme called -Kaitiaki. It was set up on ______ with Bitcoin Butlers -(bitcoinbutlers.com/tools/kaitiaki — the tool is free and works +Bitcoin Inheritance. It was set up on ______ with Bitcoin Butlers +(bitcoinbutlers.com/tools/inheritance, the tool is free and works without them). **___ of the ___ guardians below, together, can recover everything.** No guardian can read anything alone. -| Guardian | Where their bundle lives | Media | Placed | +| Guardian | How to reach them | Where their bundle lives | Placed | |---|---|---|---| -| ________ | ______________________ | ____ | ____ | -| ________ | ______________________ | ____ | ____ | -| ________ | ______________________ | ____ | ____ | +| ________ | ____________________ | ______________________ | ____ | +| ________ | ____________________ | ______________________ | ____ | +| ________ | ____________________ | ______________________ | ____ | + +**No bundle names anyone but its own holder.** A guardian does not know who the +others are, and that is deliberate: it means no two of them can agree to open +anything behind your back. This table is how your heirs find them. To +anyone who reads it early it is names and nothing else. + +If a row is struck through, that bundle was replaced. It will still open and +it no longer works with the others, so it counts for nothing. Use the rows +that are not struck through. + +**Show this page to each guardian you approach.** They were told to expect it, +and that a real request comes with the estate papers. It names them, which is +how they know the request is genuine. **To recover:** bring the required number of bundles to one computer. -Open the file called `recover.html` inside any bundle — it works in any +Open the file called `recover.html` inside any bundle, it works in any web browser, with no internet. Follow the on-screen steps. If the page will not open, any technical person can follow the printed instructions inside the bundle (README) using free standard tools. +## The wallet's map is also on Bitcoin + +Our wallet needs a **descriptor** as well as its keys. Lose one key and the +descriptor together, and the keys you still hold cannot rebuild the wallet, +even when they are enough to sign. An encrypted copy of that descriptor is +on the Bitcoin blockchain. Nobody can delete it and nobody can lose it. + +| | | +|---|---| +| Transaction id | ______________________________________ | +| Block height | ____________ Block hash | ______________ | +| Written on | ____________ Opens with | ______________ | + +**To read it:** open +bitcoinbutlers.com/tools/inheritance/descriptor.html, choose Recover, and +enter the transaction id above. Then enter the wallet's public keys, which +any of the signing devices can produce. The page rebuilds the descriptor. + +**Write the transaction id above and keep it.** The bundles do not carry it. +They hold the chain copy inside the encrypted part, which opens only when +enough guardians come together, so a reader who cannot reach enough guardians +has this id and nothing else. Without it they would have to search the whole +chain. + +If that page is gone, the same text can be read from any Bitcoin block +explorer by searching the transaction id, and decrypted at +multisigbackup.com. Nothing about this depends on Bitcoin Butlers still +existing. + **The keys to the money are NOT in these bundles.** They are stored separately as instructed in section ___ of this estate pack. diff --git a/docs/service/placement-runbook.md b/docs/service/placement-runbook.md index b6843b3..c7f29a8 100644 --- a/docs/service/placement-runbook.md +++ b/docs/service/placement-runbook.md @@ -1,4 +1,4 @@ -# Kaitiaki Placement Session — Butler runbook +# Bitcoin Inheritance Placement Session, Butler runbook **Remote-first, offered globally. USD 495 (bundled free into the multisig concierge package). One session, 2–3 hours, over video with @@ -18,8 +18,8 @@ placed. Nothing about this session is technical from the client's side. ## Before the visit - [ ] Client intake: what the payload is (descriptor, cosigner xpubs, - wallet exports, estate letter). Confirm NO seed words in payload — - seeds are steel/codex32-kit territory; refuse them into Kaitiaki. + wallet exports, estate letter). Confirm NO seed words in payload , + seeds are steel/codex32-kit territory; refuse them into Bitcoin Inheritance. - [ ] Quorum worksheet done with the client in advance: guardians named, k-of-n chosen. Defaults that work: couple + lawyer = 2-of-3; whānau trust = 3-of-5. One guardian should be outside the household @@ -27,34 +27,153 @@ placed. Nothing about this session is technical from the client's side. - [ ] Decide disclose vs --hide-quorum with the client (default: disclose; hide only when a stolen bundle must reveal nothing about the scheme's shape). +- [ ] For a multisig client: confirm they can export the wallet + descriptor, and tell them Butlers pay the chain fee for putting it + on Bitcoin. They need no node, no wallet software and no sats. - [ ] Client prep sheet sent ahead: install/download checklist - (kaitiaki binary or the web maker page saved locally), 3–5 blank + (inheritance binary or the web maker page saved locally), 3–5 blank USB sticks or microSD cards purchased by the client, printer for the estate insert. Remote rule: everything runs on the CLIENT's machine; the Butler never receives a file. ## The session -1. **Assemble the payload together.** Client drags files into - manifest/. Read the manifest back aloud — what is here, what is - deliberately not (no seeds). -2. **Seal.** `kaitiaki init` (k, n, guardian names) → `seal`. Show the - client the bundles appearing; open one METADATA.yaml and read it — - this is the transparency moment. -3. **Live test recovery, before anything is placed.** Recover with k - bundles on the spot (`kaitiaki recover` or one recover.html). The - client watches their own files come back. Never skip this; it is the +1. **Assemble the payload together.** The whole session runs in Create + Bundles (`maker.html`) on the CLIENT's machine. The client drags their + files into step 2 of the page. Read the list back aloud, what is here, + what is deliberately not (no seeds). + + Decided 2026-09-24: the browser makes the bundles, always. The command + line makes the same bundles and is there for whoever wants it, but a + placement never mixes the two, and this is why. + + The page's Save project.yml writes four things: name, threshold, + language and the guardian list. The command line's `seal` reads the + owner's texts from three OTHER fields in that file, `recovery_steps`, + `chain_payload` and `chain_txid`, which the page never writes. Seal + skips an empty field, so bundles sealed that way come out with no + `HOW-THE-WALLET-WORKS.txt` and no `CHAIN-COPY.txt` at all. Not a + missing transaction id. The owner's words and the whole chain copy, + gone, in bundles that otherwise look finished. + + **Then ask them what their heirs need to know.** The maker asks this in + its own step: the method in one group, the people and places in another. + Do not write it for them. Read each example aloud, then let them answer + in their own words. This is the part that dies with them, and it is the + reason they are in the room. + + Say where each half goes, because the page says it and they should hear + it too: both halves are sealed inside the encrypted archive and open + only when enough guardians come together. The method may ALSO go on the + chain if they ask for it. The people and places never do. +2. **Put the wallet's descriptor on the chain, BEFORE you seal.** Only for a client with a + multisig wallet. In Create Bundles, where you already are, set the + destination to "Bundles and the chain" and paste their descriptor. Read + back what the page says it is (2 of 3, and the derivation path) before + going on. The page picks the format: their words on the chain means any + ONE of their keys opens it. Say that out loud. + + **Check the fee rate before you publish.** Open mempool.space. Publish + when the rate is under 5 sat per vbyte. A chain backup is never urgent, + so waiting a day costs nothing and it can save several thousand sat. + + You may already know. From 2026-09-23 the site estimates this cost when + the client books, and emails Bitcoin Butlers when publishing that day + would cost more than 21 US dollars. No alert does not mean go ahead: fees + move between the booking and the session, so check mempool.space anyway. + The alert warns, it never blocks, and the answer to an expensive day is + always the same. Wait. + + Butlers pay opreturnbot.com to publish it, with its Private box ticked. + Take the transaction id, the block height and the block hash. + + **Paste the transaction id back into the maker before you generate.** + The field sits under the descriptor. This is the whole reason this step + comes before the seal: the archive is encrypted and its key is split into + the guardians' pieces at seal time, so an id discovered afterwards can + never be added to it. Publish first and the id is sealed inside + CHAIN-COPY.txt as well as written on the insert. Publish after and the + insert is the only copy there will ever be. + + Reordered 2026-09-24. This step used to come after the seal, which is why + no bundle ever carried an id. + + **Then read it back, in front of them.** Open + bitcoinbutlers.com/tools/inheritance/descriptor.html, + paste the transaction id, add the client's keys, and watch the + descriptor come back. A backup nobody has read back is a guess. Write + the four details onto the estate insert. + + Say the tradeoff plainly: this copy is public and permanent. Anyone can + see that a wallet backup exists. Say who can open it in the same breath, + because "only their keys" sounds like "only you" and is not the same + thing. If they chose any-one-key, ANY one of their keys opens it, now or + in twenty years, including a key they later stop using. That is the + price of a copy that outlives Bitcoin Butlers and outlives the guardians. + + Tell them what the page put on the chain: the wallet's method, never the + people or the places. Those stayed sealed inside the guardian bundles. + + **Say the argument against it, out loud, before they agree.** Something + like: "Some very good Bitcoin engineers think this is the wrong use of + the chain. Every node stores it forever and nobody but you needs it. + Their alternative is to hide it in backups you keep anyway, and that is + what your guardians are holding. This copy is for the day all of those + are gone. You can skip it and I will not think less of the plan." + + Then wait. If they skip it, the placement is still complete. A client who + was talked into a permanent public record did not consent to it. + +3. **Generate.** Press Generate in Create Bundles. Show the client the + bundles appearing, then open one bundle's `README.txt` and read it + aloud. This is the transparency moment, and it does more work than it + used to: it is the page their guardian will actually read, so the + client hears exactly what that person can and cannot see. Point at + what is NOT there, by name. No other guardian. Nothing the client + wrote. Those are sealed. +4. **Live test recovery, before anything is placed.** Open one bundle's + `recover.html` and add k bundles on the spot. The client watches their + own files come back, and sees the sealed files arrive with them: + `HOW-THE-WALLET-WORKS.txt`, `WHERE-THE-KEYS-ARE.txt`, and + `CHAIN-COPY.txt` if they published one. Never skip this; it is the product. -4. **Place each bundle.** USB stick or archival microSD per guardian, +5. **Write the transaction id on the estate insert too, and check it twice.** + The bundles carry it only if you pasted it in at step 2. Either way the + insert gets it, because the copy inside the archive needs enough + guardians to open, and an heir who cannot reach that many has the insert + and nothing else. `descriptor.html` cannot search the chain for a lost + id. **A mistyped id costs that heir the chain copy.** Read it back to + the client digit by digit. + + Changed 2026-09-24. The printed README used to carry the id on a blank + line for you to fill in. That is gone: nothing an owner writes goes in a + file that can be forwarded. + +6. **Place each bundle.** USB stick or archival microSD per guardian, labeled with the guardian's name and year only (never "BITCOIN"). - Record in the estate insert: guardian, location, date, media. + Record in the estate insert: guardian, location, date, media. The media + column is not bookkeeping. It is the retrieval list the owner needs the day + they revise, because a revision replaces every bundle and they have to know + what to swap. See `revision-session.md`. Bundles that leave the session travel with the client or by the - guardian's own hand — Butlers never retain a copy. Say this out loud. -5. **Guardian briefing sheets.** One per guardian (template below): + guardian's own hand, Butlers never retain a copy. Say this out loud. +7. **Guardian briefing sheets.** One per guardian (template below): what they hold, what it cannot do alone, what to do when contacted, and that the recovery page inside works offline in any browser. -6. **Estate insert into the client's documents.** Where the will lives. -7. **Book the first annual drill before leaving.** + + **Say plainly that they will not know the other guardians.** A guardian + who expects a contact list and finds none will think the bundle is + broken. Tell them it is deliberate, and that it is what stops any two + of them agreeing to open the client's backup between themselves. Tell + them how a real request will reach them: with the client's estate + papers, on a page that names them. Nothing else is proof. +8. **Estate insert into the client's documents.** Where the will lives. + Before it goes in, say out loud what it is now the only copy of: the + guardian list, the transaction id, and the page a guardian checks + before releasing a piece. No bundle holds any of those. Lose the insert + and the bundles cannot find each other. The client chose this over a + second copy; make sure they chose it knowingly. +9. **Book the first annual drill before leaving.** ## Rules that make it a Butlers service @@ -62,7 +181,21 @@ placed. Nothing about this session is technical from the client's side. choreography, not a guardian. State it in session, print it in the insert. - The client's k and n, guardian names, and locations exist only in the - client's estate insert — not in Butlers records. Our file holds: date, + client's estate insert, not in Butlers records and, since 2026-09-24, + not in any bundle either. Our file holds: date, drill schedule, and payload CATEGORIES only. - If the client wants a Butler as a guardian: decline; offer to help them choose a professional (lawyer/accountant) instead. +- Butlers pay for the on-chain descriptor copy through opreturnbot.com, + from the Butlers account. Budget about 2,700 sat for a 2-of-3 carrying + 250 bytes of method, AT 2 SAT PER VBYTE. State the rate whenever you + state the budget: the same transaction costs about 13,500 sat at 10 sat + per vbyte, which is why the fee check in step 4 exists. Never fund it from a client's coins, because + that would tie their wallet to their own backup on the chain. Never put + two clients in one transaction, because that states on the chain that + they are one set. +- This rule did not change when other Butlers could run a placement. + Confirmed 2026-09-23: Bitcoin Butlers pays, whoever runs the session. The + client pays their Butler's hourly rate for the length of the session and + nothing else, and no Butler needs sats of their own to take this work. + The alert in step 4 exists because Bitcoin Butlers carries the cost. diff --git a/docs/service/revision-session.md b/docs/service/revision-session.md new file mode 100644 index 0000000..6b92fe8 --- /dev/null +++ b/docs/service/revision-session.md @@ -0,0 +1,91 @@ +# Bitcoin Inheritance Revision Session, Butler runbook + +An owner's plan changes. A guardian moves, a key moves, the wallet gains a +cosigner, or the owner simply writes better instructions than they did a year +ago. + +## Why this is not a small job + +**A revision re-issues EVERY bundle.** The maker generates a fresh secret and +splits it anew, so one guardian's old piece and another guardian's new piece +cannot be combined at all. There is no such thing as updating two bundles out +of five. + +A client left holding a mix has no working plan, and **nothing on the outside +of a bundle shows which set it belongs to.** Say this out loud early. An owner +who thinks they are changing one line is about to replace their whole set. + +## What this session is + +**A placement, run again.** Same length, same price: the Butler's hourly rate +times three hours, like every other Butler service. + +It is tempting to sell it short, because the deciding is already done. The +quorum worksheet exists, the guardians are chosen, the payload categories are +settled. None of that is the work. The work is sealing every bundle again, +printing every README again, placing them again, and **test recovering them +again.** Ben, 2026-09-23: "it's essentially the same as the first one because +you have to go through and test everything again anyway." + +A revision sold as a short visit is a revision where something gets skipped, +and the thing that gets skipped is the test recovery. + +## What Butlers do, and what the owner does + +**Butlers help create the new bundles. The owner places them.** That is the +same division as a placement. We never hold a bundle and we never touch a +guardian's media, so we cannot collect or destroy the old ones. The guardians +are not in the room, and they will not all be reachable on the same day. + +What a Butler owes the owner instead is **the list**. + +## The session + +1. **Read back what exists.** Open the estate insert. Every guardian, their + location, the date, and the media. This is the retrieval list, and it is + the reason the insert records media at all. + +2. **Say what changes and what does not.** The threshold and the guardians can + stay exactly as they are. The bundles cannot. Confirm the owner understands + that every guardian gets a new one. + +3. **Ask what the heirs need to know, again.** The maker's step 3 holds their + previous words only if the owner kept the file. Most will not have. Read + the prompts and let them answer fresh. + +4. **Seal and print,** as in the placement runbook, steps 2 and 3, including + the live test recovery. A revision that is not test-recovered is a guess. + +5. **The chain copy. Only if the DESCRIPTOR changed.** + A words-only revision does not touch the chain. The chain copy carries the + wallet's method, the bundles carry the words, and the old chain copy stays + correct about the method. Republish only when the wallet itself has moved + on, and then follow the placement runbook's step 4 in full, fee check + included. + +6. **Write the retrieval list.** Hand the owner one page: each guardian, what + they hold now, and what to swap it for. Say plainly that an old bundle + still opens, still looks right, and no longer works with the others. Until + it is swapped, that guardian counts for nothing toward the threshold. + +7. **Update the estate insert** with the new date and media, and keep the old + rows struck through rather than deleted. An heir reading the insert should + be able to tell a bundle that was replaced from one that was never made. + +8. **Book the next drill.** The drill is where an unswapped bundle is found. + Tell the owner that is what it is for. + +## The failure this session exists to prevent + +An owner revises, four guardians swap their media, and the fifth never does. +The plan reads as 3-of-5 and is really 3-of-4. Nobody finds out until an heir +needs it. + +The drill catches it. Say so, and book one. + +## Rules carried from the placement runbook + +- Butlers NEVER hold a bundle, a share, or the payload. +- Seed words are never included, and never accepted. +- Bundles leave with the client or by the guardian's own hand. +- Bitcoin Butlers pays for any chain publish, never the client's coins. diff --git a/e2e/chain-reader.spec.ts b/e2e/chain-reader.spec.ts new file mode 100644 index 0000000..13d3b96 --- /dev/null +++ b/e2e/chain-reader.spec.ts @@ -0,0 +1,183 @@ +import { test, expect } from './fixtures'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import AdmZip from 'adm-zip'; +import { execFileSync } from 'child_process'; +import { getInheritanceBin, generateStandaloneHTML } from './helpers'; + +/** + * The chain copy is the second way in. An heir holding one bundle and one of + * the wallet's own keys must be able to read the wallet's instructions with no + * other guardian, no internet, and nothing of ours alive. + * + * These tests open recover.html from a file:// URL and block every network + * request, so a page that quietly depends on the network fails here. + */ +test.describe('Chain copy reader', () => { + let htmlPath: string; + let tmpDir: string; + + // The published mainnet vector. Its transaction is at block 966450, and + // these are the same bytes docs/descriptor-backup-vector.md publishes. + const thresholdText = + 'wsh(sortedmulti(2,[48h/0h/0h/2h]<0;1>/*,[48h/0h/0h/2h]<0;1>/*,[48h/0h/0h/2h]<0;1>/*))' + + 'CCQDZ+maZ+y+OQ3xN/t/RZCLXHg0rDBPAPxLFXpWi+D8rqeGVJ+QlP/v3vh84/7D71chAqsOuYfY72DZ7rC7ObyBg9YPUbUgXmW66Jn6iIbr4t39jhDoCVGG0a29hLZ5W9qzH9/HrHyt+BXgawtDuAl/E5Q4F+ZfORyTT+cZOTnND2uQQ6MVPUTOhyII4ixLC/y4ESC9EjzU4nTjtjTTdsijdeFRICvQY5dePPA5ZhqevBwi2OBkmV/FgPJichRTkIl7+vjbuWKwd7ylbnqK8q1L3v/yCtFzohW9rXEoBZGVKI63kdiDjHtNyaeTZ3eurWwihnPtAYsBhvxgpoM/9p2iU/11IpSTqkWc41MeO0mMIDXEi8TsoCzBLc8kqn+9HaVpJxoXovivKh70SuzUQXcMtRU9GLgiE+TI3HG5oNP50zeAwpbZLHXDDetqL5dIt//lNdXEWciU'; + + const xpubs = [ + 'xpub6DkFAXWQ2dHxq2vatrt9qyA3bXYU4ToWQwCHbf5XB2mSTexcHZCeKS1VZYcPoBd5X8yVcbXFHJR9R8UCVpt82VX1VhR28mCyxUFL4r6KFrf', + 'xpub6FQya7zGhR92kacYsNnjreouvnHJMpXYsUXnW6NJJAJRCKsa26TzDy4LdnGhEurr3d6y1J8PJ7EEMKQp74XTqYvmGJNogYXSKDszYHtF8mX', + 'xpub6DnEBNkSJKBYQmsbhS1sP9cNdtU5c9PLFGCjTJmxicxc13WB8zNNGQazabQpyFAGW5bV9tMko4uBxDxjUKL6dSAcx1tEbgEHtgSqyRsekh6', + ]; + + test.beforeAll(() => { + if (!fs.existsSync(getInheritanceBin())) { + test.skip(); + return; + } + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chain-reader-')); + htmlPath = generateStandaloneHTML(tmpDir, 'recover'); + }); + + test.afterAll(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('reads a threshold backup offline, with two of three keys', async ({ page }) => { + // Nothing may reach the network. A bundle opened in twenty years will not + // have one. + await page.route('**/*', (route) => + route.request().url().startsWith('file://') ? route.continue() : route.abort() + ); + await page.goto(`file://${htmlPath}`); + + await page.locator('#chain-reader').evaluate((el: HTMLDetailsElement) => { el.open = true; }); + await page.fill('#chain-payload', thresholdText); + await page.fill('#chain-keys', `${xpubs[0]}\n${xpubs[2]}`); + await page.click('#chain-btn'); + + const out = page.locator('#chain-output'); + await expect(out).toBeVisible(); + const descriptor = await out.inputValue(); + expect(descriptor).toContain('73c5da0a'); + expect(descriptor).toContain('b8688df1'); + expect(descriptor).toContain('28645006'); + }); + + /** + * Which copy wins. + * + * 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 needs a rule, and the rule is the + * bundle. Decided 2026-09-23. + * + * The line appears only after a successful read: an heir holding one copy + * has nothing to reconcile, and telling them about a conflict they do not + * have is noise at the worst possible moment. + */ + test('names the bundle as the copy to trust, only once a copy is read', async ({ page }) => { + await page.route('**/*', (route) => + route.request().url().startsWith('file://') ? route.continue() : route.abort() + ); + await page.goto(`file://${htmlPath}`); + await page.locator('#chain-reader').evaluate((el: HTMLDetailsElement) => { el.open = true; }); + + // The standing warning is there before anything is read. + await expect(page.locator('[data-i18n="chain_may_be_older"]')).toBeVisible(); + // The reconciliation line is not. + await expect(page.locator('#chain-trust')).toBeHidden(); + + await page.fill('#chain-payload', thresholdText); + await page.fill('#chain-keys', `${xpubs[0]}\n${xpubs[2]}`); + await page.click('#chain-btn'); + + await expect(page.locator('#chain-output')).toBeVisible(); + await expect(page.locator('#chain-trust')).toBeVisible(); + await expect(page.locator('#chain-trust')).toContainText('use your bundle'); + + // A failed read must not leave the line standing over nothing. + await page.fill('#chain-keys', 'xpub-that-is-not-a-key'); + await page.click('#chain-btn'); + await expect(page.locator('#chain-trust')).toBeHidden(); + }); + + + // A BIP-138 backup, made by the Go implementation. Any ONE key opens it, + // which is the difference a client hears about in the placement session. + const bip138Text = + 'QklQMTM4AQAFLr8hjVQ5qqHdxyiEOfHcVsZlnWdFBZR9xHmWCtp4tWJaR5/Cbp/DWykbBs37bRC+vp1ToWylanVO6uQDWu1x45/taF28lFxuwUKt3hmq5YasCTz1fxRUnANNnssRCOf00cg8WqSKA1PbeolnJQbVy97ikTTCyTTdKgQYn/2C7/napS9kgdiVSkXkgKT+WdBv/zade3Vo1gUKHyv8CE2JgAFPrSBQu9z6+UWgPeL91gG4TJBM1DTmZKr0p6ORm04ZwSdGC3wiRyUbDB5JjwCC22Mnx6hKa9zsPdAFGxUahgNPmWVc14/nGxXiCaGyggmHG3VKu1tK2GpQqbdcSS3b9ly1S68Poj+Mlxpay9PAS2Z8lNbWIuuqUpQ0XpWRu3fn1zfA1XPqfz50/jL0us093r9WCdinuZFq0Wn10zjQD4PM0tjgWiEosLQad6g5BPQWrjDPi8eCMh66y4zi1I5+DKDHhUTeq29428RG2CysFMaj7PlthJ/BBtMhhqOjvQGPU3m2V2aUWoh31bvlLuMJIRn/oKwNXnQtQyOFejye7kTK8lKE3ZTNIQakiS1BFInRkDZzd6zfCE/YppG1o07zTv+GHJl7ys0EA/b7gy/2KRrN8uwLJeVNmz9y2dOpzAo7TzF/BdhParVQb1fqq75spBvsPkcju3XTRd/I7nLRaA7bpXDr5YzNBx/X65bii/mZkGquiocw8OF368t8J3M4YPnRaFC7Lq5dP+IpKgN2fgIOGae+OGA5XxD9xMS531LAOYn2urbrcATvm8wraJlH00gdTNMZss8fUSw0kH5ovtH6YR24MFiUrzCXoa98+x9ohLDaJsZ1h2PSCh/uwIozIJ5+lO87gA=='; + + test('reads a BIP-138 backup offline, with one key', async ({ page }) => { + await page.route('**/*', (route) => + route.request().url().startsWith('file://') ? route.continue() : route.abort() + ); + await page.goto(`file://${htmlPath}`); + + await page.locator('#chain-reader').evaluate((el: HTMLDetailsElement) => { el.open = true; }); + await page.fill('#chain-payload', bip138Text); + // The middle key alone. A threshold backup would refuse this. + await page.fill('#chain-keys', xpubs[1]); + await page.click('#chain-btn'); + + const out = page.locator('#chain-output'); + await expect(out).toBeVisible(); + expect(await out.inputValue()).toContain('73c5da0a'); + }); + + test('says the keys are wrong rather than blaming the text', async ({ page }) => { + await page.route('**/*', (route) => + route.request().url().startsWith('file://') ? route.continue() : route.abort() + ); + await page.goto(`file://${htmlPath}`); + + await page.locator('#chain-reader').evaluate((el: HTMLDetailsElement) => { el.open = true; }); + await page.fill('#chain-payload', thresholdText); + // One key, where this backup needs two. + await page.fill('#chain-keys', xpubs[0]); + await page.click('#chain-btn'); + + await expect(page.locator('#chain-output')).toBeHidden(); + await expect(page.locator('#chain-status')).toContainText(/keys/i); + }); + + test('offers itself when the heir cannot gather enough guardians', async ({ page }) => { + // The heir this exists for: one bundle, not enough pieces. They must not + // have to find a collapsed panel underneath a dead end. + const projectDir = path.join(tmpDir, 'stall'); + const bin = getInheritanceBin(); + execFileSync(bin, ['demo', projectDir], { stdio: 'ignore' }); + + const bundlesDir = path.join(projectDir, 'output', 'bundles'); + const oneBundle = path.join(tmpDir, 'one'); + fs.mkdirSync(oneBundle, { recursive: true }); + new AdmZip(path.join(bundlesDir, 'bundle-alice.zip')).extractAllTo(oneBundle, true); + + await page.goto(`file://${path.join(oneBundle, 'recover.html')}`); + + // Closed to begin with. + await expect(page.locator('#chain-reader')).not.toHaveAttribute('open', /.*/); + + // Add a second guardian's piece. The demo needs three, so this stalls. + const second = path.join(tmpDir, 'two'); + fs.mkdirSync(second, { recursive: true }); + new AdmZip(path.join(bundlesDir, 'bundle-bob.zip')).extractAllTo(second, true); + await page.locator('#share-file-input').setInputFiles(path.join(second, 'README.txt')); + await expect(page.locator('#chain-reader')).toHaveAttribute('open', /.*/); + await expect(page.locator('#chain-nudge')).toBeVisible(); + }); + + test('says the text is wrong when the text is wrong', async ({ page }) => { + await page.route('**/*', (route) => + route.request().url().startsWith('file://') ? route.continue() : route.abort() + ); + await page.goto(`file://${htmlPath}`); + + await page.locator('#chain-reader').evaluate((el: HTMLDetailsElement) => { el.open = true; }); + await page.fill('#chain-payload', ''); + await page.fill('#chain-keys', xpubs[0]); + await page.click('#chain-btn'); + + await expect(page.locator('#chain-status')).toContainText(/backup|missing/i); + }); +}); diff --git a/e2e/creation.spec.ts b/e2e/creation.spec.ts index 5c223c6..3b5d5bd 100644 --- a/e2e/creation.spec.ts +++ b/e2e/creation.spec.ts @@ -1,10 +1,11 @@ import { test, expect } from './fixtures'; import * as fs from 'fs'; +import { execFileSync } from 'child_process'; import * as path from 'path'; import * as os from 'os'; import AdmZip from 'adm-zip'; import { - getRememoryBin, + getInheritanceBin, CreationPage, RecoveryPage, generateStandaloneHTML @@ -15,15 +16,15 @@ test.describe('Browser Bundle Creation Tool', () => { let tmpDir: string; test.beforeAll(async () => { - // Skip if rememory binary not available - const bin = getRememoryBin(); + // Skip if inheritance binary not available + const bin = getInheritanceBin(); if (!fs.existsSync(bin)) { test.skip(); return; } // Generate standalone maker.html for testing - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rememory-create-e2e-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'inheritance-create-e2e-')); htmlPath = generateStandaloneHTML(tmpDir, 'create'); }); @@ -41,7 +42,7 @@ test.describe('Browser Bundle Creation Tool', () => { const link = page.locator('.how-secure-intro a').first(); await expect(link).toHaveCSS('color', 'rgb(251, 220, 123)'); - // Active tabs (Named/Anonymous in step 1, Simple/Advanced in step 3) used + // Active tabs (Simple/Advanced in step 3) used // to be white text on a white pill. const activeTabs = page.locator('.mode-tab.active'); const count = await activeTabs.count(); @@ -269,142 +270,238 @@ friends: await creation.expectFriendData(0, 'Alice', 'alice@test.com'); }); - test('anonymous mode toggle hides friends list', async ({ page }) => { + test('YAML export escapes special characters in friend names and contact fields', async ({ page }) => { const creation = new CreationPage(page, htmlPath); await creation.open(); - // Anonymous mode should be off by default - await creation.expectAnonymousModeUnchecked(); - await creation.expectFriendsListVisible(); - await creation.expectSharesInputHidden(); + // Set friends with special characters that need escaping + // Focus on testing quote and backslash escaping which are most critical for YAML validity + await page.locator('.friend-entry').nth(0).locator('.friend-name').fill('Alice "The Hacker" Smith'); + await page.locator('.friend-entry').nth(0).locator('.friend-contact').fill('Email: alice@test.com'); - // Enable anonymous mode - await creation.toggleAnonymousMode(); + await page.locator('.friend-entry').nth(1).locator('.friend-name').fill('Bob\\Johnson'); + await page.locator('.friend-entry').nth(1).locator('.friend-contact').fill('Contact info: bob@example.com'); - // Friends list should be hidden, shares input should be visible - await creation.expectAnonymousModeChecked(); - await creation.expectFriendsListHidden(); - await creation.expectSharesInputVisible(); + // Export YAML + const yamlContent = await creation.exportYAML(); - // Disable anonymous mode - await creation.toggleAnonymousMode(); + // Verify the YAML contains properly escaped characters + // The escaping function should convert: + // - Double quotes to \" (backslash-quote) + // - Backslashes to \\ (backslash-backslash) + // This prevents YAML injection and ensures syntactic validity + expect(yamlContent).toContain('\\"The Hacker\\"'); // Quotes should be escaped + expect(yamlContent).toContain('Bob\\\\Johnson'); // Backslashes should be doubled + + // Verify that the entire name and contact fields are properly quoted + expect(yamlContent).toMatch(/name: "Alice \\"The Hacker\\" Smith"/); + expect(yamlContent).toMatch(/name: "Bob\\\\Johnson"/); + expect(yamlContent).toMatch(/contact: "Email: alice@test\.com"/); + expect(yamlContent).toMatch(/contact: "Contact info: bob@example\.com"/); + + // Verify the YAML can be parsed (imported) without errors + // This tests that the escaping produces valid YAML + await creation.importYAML(yamlContent); - // Friends list should be visible again - await creation.expectAnonymousModeUnchecked(); - await creation.expectFriendsListVisible(); - await creation.expectSharesInputHidden(); + // Should have successfully imported 2 friends + await creation.expectFriendCount(2); }); - test('anonymous mode threshold updates with share count', async ({ page }) => { + test('the chain copy is opt in, and the label never promises more than it does', async ({ page }) => { const creation = new CreationPage(page, htmlPath); - await creation.open(); - // Enable anonymous mode - await creation.toggleAnonymousMode(); + // Default: bundles only. The label must not claim the chain, because a + // page about inheritance cannot make a promise it does not keep. + await expect(page.locator('.words-dest-chain')).toHaveText(/bundles only/i); + await expect(page.locator('#chain-fields')).toBeHidden(); - // Default 5 shares should have threshold options 2-5 - await creation.expectNumShares(5); - await creation.expectThresholdOptions(['2 of 5', '3 of 5', '4 of 5', '5 of 5']); + await page.check('input[name="destination"][value="both"]'); + await expect(page.locator('.words-dest-chain')).toHaveText(/chain/i); + await expect(page.locator('#chain-fields')).toBeVisible(); - // Change to 3 shares - await creation.setNumShares(3); - await creation.expectThresholdOptions(['2 of 3', '3 of 3']); + // A real descriptor produces a real size, worked out from the payload + // rather than estimated, so the sat figure is what the client pays. + await page.fill('#words-wallet', 'A 2 of 3. Any two of the three keys can spend.'); + await page.fill('#chain-descriptor', + 'wsh(sortedmulti(2,[73c5da0a/48h/0h/0h/2h]xpub6DkFAXWQ2dHxq2vatrt9qyA3bXYU4ToWQwCHbf5XB2mSTexcHZCeKS1VZYcPoBd5X8yVcbXFHJR9R8UCVpt82VX1VhR28mCyxUFL4r6KFrf/<0;1>/*,[b8688df1/48h/0h/0h/2h]xpub6FQya7zGhR92kacYsNnjreouvnHJMpXYsUXnW6NJJAJRCKsa26TzDy4LdnGhEurr3d6y1J8PJ7EEMKQp74XTqYvmGJNogYXSKDszYHtF8mX/<0;1>/*,[28645006/48h/0h/0h/2h]xpub6DnEBNkSJKBYQmsbhS1sP9cNdtU5c9PLFGCjTJmxicxc13WB8zNNGQazabQpyFAGW5bV9tMko4uBxDxjUKL6dSAcx1tEbgEHtgSqyRsekh6/<0;1>/*))'); - // Change to 7 shares - await creation.setNumShares(7); - await creation.expectThresholdOptions(['2 of 7', '3 of 7', '4 of 7', '5 of 7', '6 of 7', '7 of 7']); + await expect(page.locator('#chain-size')).toContainText(/characters on the chain/i); + await expect(page.locator('#chain-size')).toContainText(/sat/i); + + // Going back to bundles only clears it again. + await page.check('input[name="destination"][value="bundles"]'); + await expect(page.locator('.words-dest-chain')).toHaveText(/bundles only/i); + await expect(page.locator('#chain-size')).toHaveText(''); }); - test('anonymous mode full bundle creation workflow', async ({ page }, testInfo) => { + // The id can only get into the archive if the owner publishes BEFORE + // sealing, because the archive is encrypted and its key split before any + // transaction exists. Until 2026-09-24 nothing passed it at all, so every + // CHAIN-COPY.txt shipped without one and no test noticed. + test('a transaction id pasted before generating lands inside the archive', async ({ page }, testInfo) => { testInfo.setTimeout(120000); const creation = new CreationPage(page, htmlPath); - await creation.open(); - // Enable anonymous mode - await creation.toggleAnonymousMode(); - await creation.expectSharesInputVisible(); + await creation.setFriend(0, 'Hannah', 'hannah@test.com'); + await creation.setFriend(1, 'Sebastian', 'sebastian@test.com'); + await page.check('input[name="destination"][value="both"]'); + await page.fill('#words-wallet', 'A 2 of 3. Any two of the three keys can spend.'); + await page.fill('#chain-descriptor', + 'wsh(sortedmulti(2,[73c5da0a/48h/0h/0h/2h]xpub6DkFAXWQ2dHxq2vatrt9qyA3bXYU4ToWQwCHbf5XB2mSTexcHZCeKS1VZYcPoBd5X8yVcbXFHJR9R8UCVpt82VX1VhR28mCyxUFL4r6KFrf/<0;1>/*,[b8688df1/48h/0h/0h/2h]xpub6FQya7zGhR92kacYsNnjreouvnHJMpXYsUXnW6NJJAJRCKsa26TzDy4LdnGhEurr3d6y1J8PJ7EEMKQp74XTqYvmGJNogYXSKDszYHtF8mX/<0;1>/*,[28645006/48h/0h/0h/2h]xpub6DnEBNkSJKBYQmsbhS1sP9cNdtU5c9PLFGCjTJmxicxc13WB8zNNGQazabQpyFAGW5bV9tMko4uBxDxjUKL6dSAcx1tEbgEHtgSqyRsekh6/<0;1>/*))'); - // Set 4 shares with threshold 3 - await creation.setNumShares(4); - await creation.setThreshold(3); + const txid = '4801ea9c10e14a5ea5c0e5e68bfe08fd2422005ea0a3a9631fead29ce910a4df'; + await page.fill('#chain-txid', txid); - // Add test files - const testFiles = creation.createTestFiles(tmpDir, 'anon'); + const testFiles = creation.createTestFiles(tmpDir, 'txid'); await creation.addFiles(testFiles); - - // Generate bundles await creation.generate(); - - // Should complete successfully await creation.expectGenerationComplete(); - // Should have 4 bundles (Share 1, Share 2, Share 3, Share 4) - await creation.expectBundleCount(4); - await creation.expectBundleFor('Share 1'); - await creation.expectBundleFor('Share 2'); - await creation.expectBundleFor('Share 3'); - await creation.expectBundleFor('Share 4'); + const dir = path.join(tmpDir, 'txid-bundles'); + fs.mkdirSync(dir, { recursive: true }); + const first = path.join(dir, 'a'); + const second = path.join(dir, 'b'); + fs.mkdirSync(first, { recursive: true }); + fs.mkdirSync(second, { recursive: true }); + const firstZip = path.join(dir, 'a.zip'); + const secondZip = path.join(dir, 'b.zip'); + fs.writeFileSync(firstZip, (await creation.downloadBundle(0))!); + fs.writeFileSync(secondZip, (await creation.downloadBundle(1))!); + new AdmZip(firstZip).extractAllTo(first, true); + new AdmZip(secondZip).extractAllTo(second, true); + + // Not in the open, on any surface. + expect(new AdmZip(firstZip).readAsText('README.txt')).not.toContain(txid); + expect(new AdmZip(firstZip).readAsText('recover.html')).not.toContain(txid); + + // But sealed, and IN the file once the guardians combine. Asserting only + // that CHAIN-COPY.txt exists would pass with an empty id, which is exactly + // the bug this test is here for. + const out = path.join(dir, 'recovered'); + fs.mkdirSync(out, { recursive: true }); + execFileSync(getInheritanceBin(), [ + 'recover', + path.join(first, 'README.txt'), + path.join(second, 'README.txt'), + '--manifest', path.join(first, 'recover.html'), + ], { cwd: out, stdio: 'pipe' }); + + const found = execFileSync('find', [out, '-name', 'CHAIN-COPY.txt'], { encoding: 'utf8' }) + .trim().split('\n')[0]; + expect(found).toBeTruthy(); + const sealed = fs.readFileSync(found, 'utf8'); + expect(sealed).toContain(txid); }); - test('anonymous mode validates files required', async ({ page }) => { + test('warns when the owner has written nothing, and never blocks', async ({ page }) => { const creation = new CreationPage(page, htmlPath); - await creation.open(); - // Enable anonymous mode - await creation.toggleAnonymousMode(); + // Visible from the start, because nothing has been written. + await expect(page.locator('#empty-words-warning')).toBeVisible(); - // Don't add any files - this should fail validation - - // Try to generate - should show validation error - await creation.generate(); + // It must never stand between the owner and the button. 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. + await creation.setFriend(0, 'Hannah', 'hannah@test.com'); + await creation.setFriend(1, 'Sebastian', 'sebastian@test.com'); + const testFiles = creation.createTestFiles(tmpDir, 'emptywords'); + await creation.addFiles(testFiles); + await expect(page.locator('#generate-btn')).toBeEnabled(); - // Should show validation toast for missing files - await expect(page.locator('.toast-warning')).toBeVisible(); + // One word anywhere clears it. + await page.fill('#words-wallet', 'A 2 of 3.'); + await expect(page.locator('#empty-words-warning')).toBeHidden(); - // Files drop zone should be highlighted - await expect(page.locator('#files-drop-zone.has-error')).toBeVisible(); + // Clearing it again brings the warning back. + await page.fill('#words-wallet', ''); + await expect(page.locator('#empty-words-warning')).toBeVisible(); }); - test('YAML export escapes special characters in friend names and contact fields', async ({ page }) => { + test("the owner's words land where they were decided to land", async ({ page }, testInfo) => { + // The one that must never silently regress. A README is built to be + // forwarded: a guardian's own copy tells them to send it to whoever asks + // for their piece. So nothing the owner writes may sit in one. Every word + // is sealed in the encrypted archive, which opens only when enough + // guardians combine their pieces. + // + // The method sat in the open until 2026-09-24, beside the roster. Read + // together they told a colluding guardian how the wallet works and who + // else to approach. + testInfo.setTimeout(120000); const creation = new CreationPage(page, htmlPath); - await creation.open(); - // Set friends with special characters that need escaping - // Focus on testing quote and backslash escaping which are most critical for YAML validity - await page.locator('.friend-entry').nth(0).locator('.friend-name').fill('Alice "The Hacker" Smith'); - await page.locator('.friend-entry').nth(0).locator('.friend-contact').fill('Email: alice@test.com'); + await creation.setFriend(0, 'Hannah', 'hannah@test.com'); + await creation.setFriend(1, 'Sebastian', 'sebastian@test.com'); - await page.locator('.friend-entry').nth(1).locator('.friend-name').fill('Bob\\Johnson'); - await page.locator('.friend-entry').nth(1).locator('.friend-contact').fill('Contact info: bob@example.com'); + const method = 'The older Coldcard needs firmware 5.1 or it will not show the wallet.'; + const location = 'Key 1: the safe at the Wellington house.'; + const alsoNote = 'The safe code is my birth year backwards.'; - // Export YAML - const yamlContent = await creation.exportYAML(); + await page.fill('#words-wallet', 'A 2 of 3. Any two of the three keys can spend.'); + await page.fill('#words-sign', method); + await page.fill('#words-keys', location); + await page.fill('#words-call', 'Hannah. She has done this drill twice.'); + await page.fill('.words-else', alsoNote); - // Verify the YAML contains properly escaped characters - // The escaping function should convert: - // - Double quotes to \" (backslash-quote) - // - Backslashes to \\ (backslash-backslash) - // This prevents YAML injection and ensures syntactic validity - expect(yamlContent).toContain('\\"The Hacker\\"'); // Quotes should be escaped - expect(yamlContent).toContain('Bob\\\\Johnson'); // Backslashes should be doubled - - // Verify that the entire name and contact fields are properly quoted - expect(yamlContent).toMatch(/name: "Alice \\"The Hacker\\" Smith"/); - expect(yamlContent).toMatch(/name: "Bob\\\\Johnson"/); - expect(yamlContent).toMatch(/contact: "Email: alice@test\.com"/); - expect(yamlContent).toMatch(/contact: "Contact info: bob@example\.com"/); - - // Verify the YAML can be parsed (imported) without errors - // This tests that the escaping produces valid YAML - await creation.importYAML(yamlContent); + const testFiles = creation.createTestFiles(tmpDir, 'ownerwords'); + await creation.addFiles(testFiles); + await creation.generate(); + await creation.expectGenerationComplete(); - // Should have successfully imported 2 friends - await creation.expectFriendCount(2); + const data = await creation.downloadBundle(0); + expect(data).toBeTruthy(); + const dir = path.join(tmpDir, 'ownerwords-bundle'); + fs.mkdirSync(dir, { recursive: true }); + const zipPath = path.join(dir, 'bundle.zip'); + fs.writeFileSync(zipPath, data!); + const readme = new AdmZip(zipPath).readAsText('README.txt'); + + // Not one word of it, on any surface a lone guardian can read. + expect(readme).not.toContain(method); + expect(readme).not.toContain(location); + expect(readme).not.toContain(alsoNote); + expect(readme).not.toContain('Wellington'); + expect(readme).not.toContain('Coldcard'); + + // And no other guardian is named. + expect(readme).not.toContain('Sebastian'); + expect(readme).not.toContain('sebastian@test.com'); + + // The personalised recover page is the third surface. A fix that misses + // one of the three leaks everything. + const recoverHtml = new AdmZip(zipPath).readAsText('recover.html'); + expect(recoverHtml).not.toContain(method); + expect(recoverHtml).not.toContain(location); + expect(recoverHtml).not.toContain('sebastian@test.com'); + + // And now the half that matters just as much: the words must still be + // THERE, sealed, not quietly dropped. A test that only checks absence + // passes just as well when the maker loses the owner's writing. + const secondData = await creation.downloadBundle(1); + const secondDir = path.join(dir, 'sebastian'); + const secondZipPath = path.join(secondDir, 'bundle.zip'); + fs.mkdirSync(secondDir, { recursive: true }); + fs.writeFileSync(secondZipPath, secondData!); + new AdmZip(secondZipPath).extractAllTo(path.join(secondDir, 'x'), true); + + const firstDir = path.join(dir, 'hannah'); + fs.mkdirSync(firstDir, { recursive: true }); + new AdmZip(zipPath).extractAllTo(firstDir, true); + + const recovery = new RecoveryPage(page, firstDir); + await recovery.open(); + await recovery.addShares(path.join(secondDir, 'x')); + await recovery.expectRecoveryComplete(); + + const recovered = await page.locator('.file-item').allInnerTexts(); + const names = recovered.join('\n'); + expect(names).toContain('HOW-THE-WALLET-WORKS.txt'); + expect(names).toContain('WHERE-THE-KEYS-ARE.txt'); }); test('browser-created bundles can be recovered @cross-browser', async ({ page }, testInfo) => { diff --git a/e2e/descriptor.spec.ts b/e2e/descriptor.spec.ts new file mode 100644 index 0000000..f97fabe --- /dev/null +++ b/e2e/descriptor.spec.ts @@ -0,0 +1,87 @@ +import { test, expect } from './fixtures'; +import * as fs from 'fs'; +import { getInheritanceBin, getDescriptorHtml } from './helpers'; +import vector from '../internal/html/assets/src/crypto/testdata/descriptor-vector.json'; + +/** + * The descriptor page, as an heir drives it. + * + * The page used to make backups as well as read them. Making one moved to + * Create Bundles, so this file covers only what is left: somebody with a + * backup and the wallet's keys, getting the descriptor back. + * + * The crypto itself is covered by `make test` in internal/descriptorbackup and + * by `make test-xlang`, which proves Go and TypeScript open each other's + * backups. What these tests prove is that the page wires it up: the right keys + * open it, the wrong ones do not, and a mistyped transaction id is refused + * before anybody is asked for it. + * + * They use the PUBLISHED vector, so they read the same bytes that sit on + * mainnet at block 966450 rather than something made moments earlier. + */ +test.describe('Descriptor page', () => { + let pagePath: string; + + test.beforeAll(async () => { + if (!fs.existsSync(getInheritanceBin())) { + test.skip(); + return; + } + pagePath = getDescriptorHtml(); + }); + + test.beforeEach(async ({ page }) => { + await page.goto('file://' + pagePath); + }); + + test('is a reading page now, and says where to make one', async ({ page }) => { + await expect(page.locator('h1')).toContainText('Read a Descriptor Backup'); + // The protect half is gone, not hidden. + await expect(page.locator('#panel-protect')).toHaveCount(0); + await expect(page.locator('.mode-tab')).toHaveCount(0); + // Somebody who wanted to make one must be told where to go, in the page + // itself and not only in the nav that is on every page. + await expect(page.locator('.page-intro a[href="maker.html"]')).toBeVisible(); + }); + + test('threshold backup: two keys open it, one key does not', async ({ page }) => { + await page.fill('#recover-input', vector.encryptedText); + + // One key alone, where this backup needs two. + await page.fill('#xpubs-input', vector.xpubs[0]); + await page.click('#recover-btn'); + await expect(page.locator('#recover-output')).toBeHidden(); + + // Two of the three, and it opens. + await page.fill('#xpubs-input', `${vector.xpubs[0]}\n${vector.xpubs[2]}`); + await page.click('#recover-btn'); + await expect(page.locator('#recover-output')).toBeVisible(); + expect(await page.locator('#recover-output').inputValue()).toBe(vector.descriptor); + }); + + test('a stranger key opens nothing', async ({ page }) => { + await page.fill('#recover-input', vector.encryptedText); + // A well-formed key from another wallet. + await page.fill( + '#xpubs-input', + 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8' + ); + await page.click('#recover-btn'); + await expect(page.locator('#recover-output')).toBeHidden(); + }); + + test('fetching by transaction id validates the id before asking anyone', async ({ page }) => { + // Nothing may leave the machine for an id that cannot be real. + let requests = 0; + await page.route('**/*', (route) => { + if (!route.request().url().startsWith('file://')) requests++; + return route.request().url().startsWith('file://') ? route.continue() : route.abort(); + }); + + await page.check('input[name="source"][value="txid"]'); + await page.fill('#txid-input', 'not-a-transaction-id'); + await page.click('#fetch-btn'); + await page.waitForTimeout(300); + expect(requests).toBe(0); + }); +}); diff --git a/e2e/docs.spec.ts b/e2e/docs.spec.ts index 568584e..9eefbd2 100644 --- a/e2e/docs.spec.ts +++ b/e2e/docs.spec.ts @@ -1,12 +1,12 @@ import { test, expect } from './fixtures'; import * as fs from 'fs'; -import { getRememoryBin, getDocsHtml } from './helpers'; +import { getInheritanceBin, getDocsHtml } from './helpers'; test.describe('Documentation Page', () => { let docsPath: string; test.beforeAll(async () => { - if (!fs.existsSync(getRememoryBin())) { + if (!fs.existsSync(getInheritanceBin())) { test.skip(); return; } @@ -17,7 +17,7 @@ test.describe('Documentation Page', () => { await page.goto('file://' + docsPath); // Page title - await expect(page).toHaveTitle(/Kaitiaki Guide/); + await expect(page).toHaveTitle(/Bitcoin Inheritance Guide/); // TOC sidebar is visible const toc = page.locator('.toc'); diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index 0855729..f545588 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -3,19 +3,19 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -function getRememoryBin(): string { - const binEnv = process.env.REMEMORY_BIN || './kaitiaki'; +function getInheritanceBin(): string { + const binEnv = process.env.INHERITANCE_BIN || './inheritance'; return path.resolve(binEnv); } async function globalSetup() { - const bin = getRememoryBin(); + const bin = getInheritanceBin(); if (!fs.existsSync(bin)) { - console.log('Global setup: rememory binary not found, skipping shared resource creation'); + console.log('Global setup: inheritance binary not found, skipping shared resource creation'); return; } - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rememory-e2e-global-')); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'inheritance-e2e-global-')); // --- Projects --- @@ -43,17 +43,6 @@ async function globalSetup() { execFileSync(bin, ['seal', '--no-embed-manifest'], { cwd: noEmbedProject, stdio: 'inherit' }); execFileSync(bin, ['bundle', '--no-embed-manifest'], { cwd: noEmbedProject, stdio: 'inherit' }); - // Anonymous test project (3 shares, threshold 2) - const anonymousProject = path.join(tmpDir, 'anonymous-project'); - execFileSync(bin, [ - 'init', anonymousProject, '--name', 'Anonymous E2E Test', '--anonymous', '--shares', '3', '--threshold', '2', - ], { stdio: 'inherit' }); - const anonManifest = path.join(anonymousProject, 'manifest'); - fs.writeFileSync(path.join(anonManifest, 'secret.txt'), 'Anonymous secret: correct-horse-battery-staple'); - fs.writeFileSync(path.join(anonManifest, 'notes.txt'), 'Anonymous notes!'); - execFileSync(bin, ['seal'], { cwd: anonymousProject, stdio: 'inherit' }); - execFileSync(bin, ['bundle'], { cwd: anonymousProject, stdio: 'inherit' }); - // --- Standalone HTML files --- const makerHtml = path.join(tmpDir, 'maker.html'); @@ -83,7 +72,7 @@ async function globalSetup() { Crypto Test - + `); @@ -93,7 +82,6 @@ async function globalSetup() { tmpDir, standardProject, noEmbedProject, - anonymousProject, makerHtml, recoverHtml, docsHtml, @@ -103,7 +91,7 @@ async function globalSetup() { const setupPath = path.join(tmpDir, 'setup.json'); fs.writeFileSync(setupPath, JSON.stringify(setup, null, 2)); - process.env.REMEMORY_E2E_SETUP = setupPath; + process.env.INHERITANCE_E2E_SETUP = setupPath; } export default globalSetup; diff --git a/e2e/global-teardown.ts b/e2e/global-teardown.ts index 0bed2e5..8af0aac 100644 --- a/e2e/global-teardown.ts +++ b/e2e/global-teardown.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; async function globalTeardown() { - const setupPath = process.env.REMEMORY_E2E_SETUP; + const setupPath = process.env.INHERITANCE_E2E_SETUP; if (!setupPath || !fs.existsSync(setupPath)) return; const setup = JSON.parse(fs.readFileSync(setupPath, 'utf8')); diff --git a/e2e/golden-crypto.spec.ts b/e2e/golden-crypto.spec.ts index 082292e..92ef761 100644 --- a/e2e/golden-crypto.spec.ts +++ b/e2e/golden-crypto.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from './fixtures'; import * as fs from 'fs'; import * as path from 'path'; -import { getRememoryBin, createTestProject, cleanupProject, extractBundle, getCryptoTestHtml } from './helpers'; +import { getInheritanceBin, createTestProject, cleanupProject, extractBundle, getCryptoTestHtml } from './helpers'; // Load golden fixtures const v1Golden = JSON.parse( @@ -36,7 +36,7 @@ test.describe('Golden Crypto Compatibility @cross-browser', () => { const result = await page.evaluate( async ({ shares, manifestBytes, version }: { shares: any[]; manifestBytes: number[]; version: number }) => { - const crypto = (window as any).rememoryCrypto; + const crypto = (window as any).inheritanceCrypto; // Convert hex shares to Uint8Array const shareBytes = shares.map(s => { @@ -85,7 +85,7 @@ test.describe('Golden Crypto Compatibility @cross-browser', () => { const share = data.shares[0]; const result = await page.evaluate(async (pem: string) => { - const crypto = (window as any).rememoryCrypto; + const crypto = (window as any).inheritanceCrypto; try { const parsed = await crypto.parseShare(pem); return { @@ -117,7 +117,7 @@ test.describe('Golden Crypto Compatibility @cross-browser', () => { expect(words.length).toBe(25); const result = await page.evaluate(async (words: string[]) => { - const crypto = (window as any).rememoryCrypto; + const crypto = (window as any).inheritanceCrypto; try { const decoded = await crypto.decodeShareWords(words); const hex = Array.from(decoded.data as Uint8Array) @@ -143,7 +143,7 @@ test.describe('Golden Crypto Compatibility @cross-browser', () => { const share = v2Golden.shares[0]; const result = await page.evaluate(async (compact: string) => { - const crypto = (window as any).rememoryCrypto; + const crypto = (window as any).inheritanceCrypto; try { const parsed = await crypto.parseCompactShare(compact); return { @@ -173,7 +173,7 @@ test.describe('Golden Crypto Compatibility @cross-browser', () => { const shares = v2Golden.shares.slice(0, 2).map((s: any) => s.data_hex); const result = await page.evaluate(async ({ shares, version, expectedPassphrase }: { shares: string[]; version: number; expectedPassphrase: string }) => { - const crypto = (window as any).rememoryCrypto; + const crypto = (window as any).inheritanceCrypto; // Convert hex shares to Uint8Array const shareBytes = shares.map(hex => { @@ -206,7 +206,7 @@ test.describe('extractBundle from recover.html', () => { let bundlesDir: string; test.beforeAll(async () => { - const bin = getRememoryBin(); + const bin = getInheritanceBin(); if (!fs.existsSync(bin)) { test.skip(); return; @@ -231,7 +231,7 @@ test.describe('extractBundle from recover.html', () => { ); const result = await page.evaluate(async (htmlBytes: number[]) => { - const crypto = (window as any).rememoryCrypto; + const crypto = (window as any).inheritanceCrypto; try { const data = new Uint8Array(htmlBytes); const bundle = await crypto.extractBundle(data); diff --git a/e2e/helpers.ts b/e2e/helpers.ts index d9bd6c5..f44ee70 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -10,7 +10,6 @@ interface SharedSetup { tmpDir: string; standardProject: string; noEmbedProject: string; - anonymousProject: string; makerHtml: string; recoverHtml: string; docsHtml: string; @@ -22,7 +21,7 @@ let _sharedSetup: SharedSetup | null | undefined; function getSharedSetup(): SharedSetup | null { if (_sharedSetup === undefined) { - const setupPath = process.env.REMEMORY_E2E_SETUP; + const setupPath = process.env.INHERITANCE_E2E_SETUP; if (setupPath && fs.existsSync(setupPath)) { _sharedSetup = JSON.parse(fs.readFileSync(setupPath, 'utf8')); } else { @@ -37,7 +36,7 @@ let _fallbackTmpDir: string | null = null; function getFallbackTmpDir(): string { if (!_fallbackTmpDir) { - _fallbackTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rememory-e2e-fallback-')); + _fallbackTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'inheritance-e2e-fallback-')); } return _fallbackTmpDir; } @@ -59,7 +58,7 @@ function buildCryptoTestHtml(tmpDir: string): string { Crypto Test - + `); @@ -85,10 +84,10 @@ function getOrBuild(field: keyof SharedSetup, build: (tmpDir: string) => string) return built; } -/** Helper to build an HTML resource via the rememory CLI. */ +/** Helper to build an HTML resource via the inheritance CLI. */ function buildHtmlResource(tmpDir: string, filename: string, args: string[]): string { const htmlPath = path.join(tmpDir, filename); - execFileSync(getRememoryBin(), ['html', ...args, '-o', htmlPath], { stdio: 'inherit' }); + execFileSync(getInheritanceBin(), ['html', ...args, '-o', htmlPath], { stdio: 'inherit' }); return htmlPath; } @@ -104,9 +103,15 @@ export function getIndexHtml(): string { return getOrBuild('indexHtml', (dir) => buildHtmlResource(dir, 'index.html', ['index'])); } -// Get absolute path to rememory binary -export function getRememoryBin(): string { - const binEnv = process.env.REMEMORY_BIN || './kaitiaki'; +export function getDescriptorHtml(): string { + return getOrBuild('descriptorHtml', (dir) => + buildHtmlResource(dir, 'descriptor.html', ['descriptor']) + ); +} + +// Get absolute path to inheritance binary +export function getInheritanceBin(): string { + const binEnv = process.env.INHERITANCE_BIN || './inheritance'; return path.resolve(binEnv); } @@ -123,7 +128,7 @@ export function generateStandaloneHTML(tmpDir: string, type: 'recover' | 'create } } - const bin = getRememoryBin(); + const bin = getInheritanceBin(); const htmlPath = path.join(tmpDir, type === 'create' ? 'maker.html' : 'recover.html'); execFileSync(bin, ['html', type, '-o', htmlPath, ...extraFlags], { stdio: 'inherit' }); @@ -173,9 +178,9 @@ export function createTestProject(options: TestProjectOptions = {}): string { } } - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rememory-e2e-')); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'inheritance-e2e-')); const projectDir = path.join(tmpDir, 'test-project'); - const bin = getRememoryBin(); + const bin = getInheritanceBin(); const friends = options.friends || [ { name: 'Alice', email: 'alice@test.com' }, @@ -205,44 +210,6 @@ export function createTestProject(options: TestProjectOptions = {}): string { return projectDir; } -// Create a sealed anonymous test project with bundles (cached within a worker) -export function createAnonymousTestProject(): string { - const key = 'anonymous'; - const cached = projectCache.get(key); - if (cached && fs.existsSync(cached)) { - return cached; - } - - // Use pre-created project from global setup when available - const shared = getSharedSetup(); - if (shared?.anonymousProject && fs.existsSync(shared.anonymousProject)) { - projectCache.set(key, shared.anonymousProject); - globalSetupPaths.add(shared.anonymousProject); - return shared.anonymousProject; - } - - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rememory-e2e-anon-')); - const projectDir = path.join(tmpDir, 'test-anon-project'); - const bin = getRememoryBin(); - - // Create anonymous project with 3 shares, threshold 2 - execFileSync(bin, [ - 'init', projectDir, '--name', 'Anonymous E2E Test', '--anonymous', '--shares', '3', '--threshold', '2', - ], { stdio: 'inherit' }); - - // Add secret content - const manifestDir = path.join(projectDir, 'manifest'); - fs.writeFileSync(path.join(manifestDir, 'secret.txt'), 'Anonymous secret: correct-horse-battery-staple'); - fs.writeFileSync(path.join(manifestDir, 'notes.txt'), 'Anonymous notes!'); - - // Seal and generate bundles - execFileSync(bin, ['seal'], { cwd: projectDir, stdio: 'inherit' }); - execFileSync(bin, ['bundle'], { cwd: projectDir, stdio: 'inherit' }); - - projectCache.set(key, projectDir); - cachedPaths.add(projectDir); - return projectDir; -} // Safe cleanup: only removes the directory if it's not a cached project // that other describe blocks might still need. @@ -296,15 +263,7 @@ export function extractBundles(bundlesDir: string, friendNames: string[]): strin return friendNames.map(name => extractBundle(bundlesDir, name)); } -// Extract anonymous bundle by share number -export function extractAnonymousBundle(bundlesDir: string, shareNum: number): string { - return extractBundle(bundlesDir, `share-${shareNum}`); -} -// Extract multiple anonymous bundles -export function extractAnonymousBundles(bundlesDir: string, shareNums: number[]): string[] { - return shareNums.map(num => extractAnonymousBundle(bundlesDir, num)); -} // Load README filenames from translations (source of truth) function loadReadmeFilenames(): string[] { @@ -369,7 +328,7 @@ export class RecoveryPage { async open(): Promise { await this.page.goto(`file://${path.join(this.bundleDir, 'recover.html')}`); await this.page.waitForFunction( - () => (window as any).rememoryAppReady === true, + () => (window as any).inheritanceAppReady === true, { timeout: 30000 } ); } @@ -378,7 +337,7 @@ export class RecoveryPage { async openFile(htmlPath: string): Promise { await this.page.goto(`file://${htmlPath}`); await this.page.waitForFunction( - () => (window as any).rememoryAppReady === true, + () => (window as any).inheritanceAppReady === true, { timeout: 30000 } ); } @@ -508,24 +467,6 @@ export class RecoveryPage { await expect(this.page.locator('.share-item').first()).toContainText('Your piece'); } - // Contact list assertions - async expectContactListVisible(): Promise { - await expect(this.page.locator('#contact-list-section')).toBeVisible(); - } - - async expectContactItem(name: string): Promise { - await expect(this.page.locator('.contact-item').filter({ hasText: name })).toBeVisible(); - } - - async expectContactCollected(name: string): Promise { - const contact = this.page.locator('.contact-item').filter({ hasText: name }); - await expect(contact).toHaveClass(/collected/); - } - - async expectContactNotCollected(name: string): Promise { - const contact = this.page.locator('.contact-item').filter({ hasText: name }); - await expect(contact).not.toHaveClass(/collected/); - } // Steps collapse assertions async expectStepsVisible(): Promise { @@ -546,7 +487,7 @@ export class CreationPage { async open(): Promise { await this.page.goto(`file://${this.htmlPath}`); await this.page.waitForFunction( - () => (window as any).rememoryReady === true, + () => (window as any).inheritanceReady === true, { timeout: 30000 } ); } @@ -684,7 +625,7 @@ export class CreationPage { async downloadBundle(index: number): Promise { // Get bundle data from the page's state const data = await this.page.evaluate((idx) => { - const state = (window as any).rememoryBundles; + const state = (window as any).inheritanceBundles; if (!state || !state[idx]) return null; return Array.from(state[idx].data as Uint8Array); }, index); @@ -695,7 +636,7 @@ export class CreationPage { // UI assertions async expectUIElements(): Promise { - await expect(this.page.locator('.logo')).toContainText('Kaitiaki'); + await expect(this.page.locator('.logo')).toContainText('Inheritance'); await expect(this.page.locator('#friends-list')).toBeVisible(); await expect(this.page.locator('#files-drop-zone')).toBeVisible(); await expect(this.page.locator('#generate-btn')).toBeVisible(); @@ -710,66 +651,6 @@ export class CreationPage { this.page.on('dialog', dialog => dialog[action]()); } - // Anonymous mode methods - async selectAnonymousMode(): Promise { - await this.page.locator('.mode-tab[data-mode="anonymous"]').click(); - } - - async selectNamedMode(): Promise { - await this.page.locator('.mode-tab[data-mode="named"]').click(); - } - - async toggleAnonymousMode(): Promise { - const anonTab = this.page.locator('.mode-tab[data-mode="anonymous"]'); - const isActive = await anonTab.evaluate(el => el.classList.contains('active')); - if (isActive) { - await this.page.locator('.mode-tab[data-mode="named"]').click(); - } else { - await anonTab.click(); - } - } - - async expectAnonymousModeActive(): Promise { - await expect(this.page.locator('.mode-tab[data-mode="anonymous"]')).toHaveClass(/active/); - } - - async expectNamedModeActive(): Promise { - await expect(this.page.locator('.mode-tab[data-mode="named"]')).toHaveClass(/active/); - } - - async expectAnonymousModeChecked(): Promise { - await this.expectAnonymousModeActive(); - } - - async expectAnonymousModeUnchecked(): Promise { - await this.expectNamedModeActive(); - } - - async expectFriendsListHidden(): Promise { - await expect(this.page.locator('#friends-section')).toHaveClass(/hidden/); - } - - async expectFriendsListVisible(): Promise { - await expect(this.page.locator('#friends-section')).not.toHaveClass(/hidden/); - } - - async expectSharesInputVisible(): Promise { - await expect(this.page.locator('#shares-input')).toBeVisible(); - } - - async expectSharesInputHidden(): Promise { - await expect(this.page.locator('#shares-input')).toHaveClass(/hidden/); - } - - async setNumShares(count: number): Promise { - await this.page.locator('#num-shares').fill(String(count)); - // Trigger input event to update state - await this.page.locator('#num-shares').dispatchEvent('input'); - } - - async expectNumShares(count: number): Promise { - await expect(this.page.locator('#num-shares')).toHaveValue(String(count)); - } // Export YAML and return content async exportYAML(): Promise { diff --git a/e2e/index.spec.ts b/e2e/index.spec.ts index 406ea6e..f07a9a8 100644 --- a/e2e/index.spec.ts +++ b/e2e/index.spec.ts @@ -1,12 +1,12 @@ import { test, expect } from './fixtures'; import * as fs from 'fs'; -import { getRememoryBin, getIndexHtml } from './helpers'; +import { getInheritanceBin, getIndexHtml } from './helpers'; test.describe('Landing Page', () => { let indexPath: string; test.beforeAll(async () => { - if (!fs.existsSync(getRememoryBin())) { + if (!fs.existsSync(getInheritanceBin())) { test.skip(); return; } @@ -17,7 +17,7 @@ test.describe('Landing Page', () => { await page.goto('file://' + indexPath); // Main heading - await expect(page.locator('h1')).toContainText('Kaitiaki'); + await expect(page.locator('h1')).toContainText('Bitcoin Inheritance'); // Key sections await expect(page.locator('.intro')).toBeVisible(); diff --git a/e2e/multilang-crypto.spec.ts b/e2e/multilang-crypto.spec.ts index f811da9..0c80831 100644 --- a/e2e/multilang-crypto.spec.ts +++ b/e2e/multilang-crypto.spec.ts @@ -66,7 +66,7 @@ test.describe('Multi-language BIP39 Support', () => { const words = generateTestWords(code); const result = await page.evaluate(async (words: string[]) => { - const crypto = (window as any).rememoryCrypto; + const crypto = (window as any).inheritanceCrypto; try { const decoded = crypto.decodeWords(words); return { success: true, length: decoded.length }; @@ -85,7 +85,7 @@ test.describe('Multi-language BIP39 Support', () => { await page.waitForFunction(() => (window as any).testReady); const result = await page.evaluate(async () => { - const crypto = (window as any).rememoryCrypto; + const crypto = (window as any).inheritanceCrypto; try { const idx = crypto.lookupWord('ábaco'); return { index: idx }; @@ -103,7 +103,7 @@ test.describe('Multi-language BIP39 Support', () => { await page.waitForFunction(() => (window as any).testReady); const result = await page.evaluate(async () => { - const crypto = (window as any).rememoryCrypto; + const crypto = (window as any).inheritanceCrypto; try { // "abend" is word index 4 in the German wordlist const idx = crypto.lookupWordInLang('de', 'abend'); @@ -122,7 +122,7 @@ test.describe('Multi-language BIP39 Support', () => { await page.waitForFunction(() => (window as any).testReady); const result = await page.evaluate(async () => { - const crypto = (window as any).rememoryCrypto; + const crypto = (window as any).inheritanceCrypto; try { // "abend" is German-only const idx = crypto.lookupWord('abend'); @@ -151,7 +151,7 @@ test.describe('Language Auto-detection', () => { const words = generateTestWords('es'); const result = await page.evaluate(async (words: string[]) => { - const crypto = (window as any).rememoryCrypto; + const crypto = (window as any).inheritanceCrypto; if (typeof crypto.detectLanguage !== 'function') { return { error: 'detectLanguage function not implemented' }; } @@ -174,7 +174,7 @@ test.describe('Language Auto-detection', () => { const words = generateTestWords('fr'); const result = await page.evaluate(async (words: string[]) => { - const crypto = (window as any).rememoryCrypto; + const crypto = (window as any).inheritanceCrypto; if (typeof crypto.detectLanguage !== 'function') { return { error: 'detectLanguage function not implemented' }; } @@ -197,7 +197,7 @@ test.describe('Language Auto-detection', () => { const words = generateTestWords('de'); const result = await page.evaluate(async (words: string[]) => { - const crypto = (window as any).rememoryCrypto; + const crypto = (window as any).inheritanceCrypto; if (typeof crypto.detectLanguage !== 'function') { return { error: 'detectLanguage function not implemented' }; } @@ -220,7 +220,7 @@ test.describe('Language Auto-detection', () => { const words = generateTestWords('es'); const result = await page.evaluate(async (words: string[]) => { - const crypto = (window as any).rememoryCrypto; + const crypto = (window as any).inheritanceCrypto; try { const decoded = crypto.decodeWords(words); return { success: true, length: decoded.length }; @@ -248,7 +248,7 @@ test.describe('Word Normalization', () => { await page.waitForFunction(() => (window as any).testReady); const result = await page.evaluate(async () => { - const crypto = (window as any).rememoryCrypto; + const crypto = (window as any).inheritanceCrypto; if (typeof crypto.lookupWordInLang !== 'function') { try { @@ -277,7 +277,7 @@ test.describe('Word Normalization', () => { await page.waitForFunction(() => (window as any).testReady); const result = await page.evaluate(async () => { - const crypto = (window as any).rememoryCrypto; + const crypto = (window as any).inheritanceCrypto; if (typeof crypto.lookupWordInLang !== 'function') { return { error: 'lookupWordInLang not implemented' }; @@ -300,7 +300,7 @@ test.describe('Word Normalization', () => { await page.waitForFunction(() => (window as any).testReady); const result = await page.evaluate(async () => { - const crypto = (window as any).rememoryCrypto; + const crypto = (window as any).inheritanceCrypto; try { const idx = crypto.lookupWord('ABANDON'); return { index: idx }; diff --git a/e2e/owner-key.spec.ts b/e2e/owner-key.spec.ts index c43ac51..da642eb 100644 --- a/e2e/owner-key.spec.ts +++ b/e2e/owner-key.spec.ts @@ -3,7 +3,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import AdmZip from 'adm-zip'; -import { getRememoryBin, CreationPage, RecoveryPage, generateStandaloneHTML } from './helpers'; +import { getInheritanceBin, CreationPage, RecoveryPage, generateStandaloneHTML } from './helpers'; // Test vector from docs/owner-key-vector.md (public, never for real use) const OWNER_RECIPIENT = @@ -19,12 +19,12 @@ test.describe('Owner Key', () => { let tmpDir: string; test.beforeAll(async () => { - const bin = getRememoryBin(); + const bin = getInheritanceBin(); if (!fs.existsSync(bin)) { test.skip(); return; } - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rememory-owner-e2e-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'inheritance-owner-e2e-')); htmlPath = generateStandaloneHTML(tmpDir, 'create'); }); diff --git a/e2e/pages.spec.ts b/e2e/pages.spec.ts index 8e6c1ba..a2ee4ea 100644 --- a/e2e/pages.spec.ts +++ b/e2e/pages.spec.ts @@ -5,7 +5,7 @@ import * as path from 'path'; import * as os from 'os'; import * as net from 'net'; import { - getRememoryBin, + getInheritanceBin, createTestProject, cleanupProject, extractBundles, @@ -34,14 +34,14 @@ test.describe('Static Pages', () => { let baseURL: string; test.beforeAll(async () => { - const bin = getRememoryBin(); + const bin = getInheritanceBin(); if (!fs.existsSync(bin)) { test.skip(); return; } // Create a sealed project with --pages flag - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rememory-pages-e2e-')); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'inheritance-pages-e2e-')); projectDir = path.join(tmpDir, 'test-project'); const { execFileSync } = require('child_process'); @@ -118,7 +118,7 @@ test.describe('Static Pages', () => { // Navigate to recover.html await page.goto(`${baseURL}/recover.html`); await page.waitForFunction( - () => (window as any).rememoryAppReady === true, + () => (window as any).inheritanceAppReady === true, { timeout: 30000 } ); diff --git a/e2e/qr-scanner.spec.ts b/e2e/qr-scanner.spec.ts index b8737db..9756e08 100644 --- a/e2e/qr-scanner.spec.ts +++ b/e2e/qr-scanner.spec.ts @@ -2,7 +2,7 @@ import { test, expect } from './fixtures'; import * as fs from 'fs'; import * as path from 'path'; import { - getRememoryBin, + getInheritanceBin, createTestProject, cleanupProject, extractBundle, @@ -16,7 +16,7 @@ test.describe('QR Scanner', () => { let bundlesDir: string; test.beforeAll(async () => { - const bin = getRememoryBin(); + const bin = getInheritanceBin(); if (!fs.existsSync(bin)) { test.skip(); return; @@ -152,7 +152,7 @@ test.describe('QR Scanner', () => { // Use a known valid compact share from golden fixtures (Carol, index 3) const compactShare = 'RM2:3:5:3:aKoRQv1shz6UZSAXvTLEXnS1zSQkTS3jhqA3-06G2jnA:6ec0'; - const qrUrl = `https://eljojo.github.io/rememory/recover.html#share=${encodeURIComponent(compactShare)}`; + const qrUrl = `https://eljojo.github.io/inheritance/recover.html#share=${encodeURIComponent(compactShare)}`; // Mock BarcodeDetector to return a URL with fragment await page.addInitScript((url: string) => { diff --git a/e2e/recovery.spec.ts b/e2e/recovery.spec.ts index 5ccd3d5..9b67923 100644 --- a/e2e/recovery.spec.ts +++ b/e2e/recovery.spec.ts @@ -3,13 +3,11 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { - getRememoryBin, + getInheritanceBin, createTestProject, - createAnonymousTestProject, cleanupProject, extractBundle, extractBundles, - extractAnonymousBundles, extractWordsFromReadme, findReadmeFile, generateStandaloneHTML, @@ -23,8 +21,8 @@ test.describe('Browser Recovery Tool', () => { let mismatchBundlesDir: string; test.beforeAll(async () => { - // Skip if rememory binary not available - const bin = getRememoryBin(); + // Skip if inheritance binary not available + const bin = getInheritanceBin(); if (!fs.existsSync(bin)) { test.skip(); return; @@ -69,46 +67,52 @@ test.describe('Browser Recovery Tool', () => { await recovery.expectNeedMoreShares(1); }); - test('shows contact list for other friends', async ({ page }) => { + // A guardian's own page names them and nobody else. It listed every other + // guardian and their email until 2026-09-24, which is the collusion path: + // one bundle told its holder exactly who to approach. + test('the recover page names no other guardian', async ({ page }) => { const bundleDir = extractBundle(bundlesDir, 'Alice'); const recovery = new RecoveryPage(page, bundleDir); await recovery.open(); - // Contact list should show Bob and Carol (other friends) - await recovery.expectContactListVisible(); - await recovery.expectContactItem('Bob'); - await recovery.expectContactItem('Carol'); + await expect(page.locator('#contact-list-section')).toHaveCount(0); + const body = await page.locator('body').innerText(); + for (const secret of ['Bob', 'Carol', 'bob@test.com', 'carol@test.com']) { + expect(body).not.toContain(secret); + } }); - test('email addresses in contact list are mailto links', async ({ page }) => { + // The bundle names nobody, so the page has to say where the names are. + // Without this an heir has a count of missing pieces and nowhere to look. + test('the page says where to find the other guardians', async ({ page }) => { const bundleDir = extractBundle(bundlesDir, 'Alice'); const recovery = new RecoveryPage(page, bundleDir); await recovery.open(); - // Bob's email should be a tappable mailto: link - const bobContact = page.locator('.contact-item').filter({ hasText: 'Bob' }).locator('.contact-info a'); - await expect(bobContact).toHaveAttribute('href', 'mailto:bob@test.com'); - await expect(bobContact).toHaveText('bob@test.com'); + const whoElse = page.locator('#who-else'); + await expect(whoElse).toBeVisible(); + await expect(whoElse).toContainText('will or estate papers'); }); - test('contact list updates when shares are collected', async ({ page }) => { - const [aliceDir, bobDir] = extractBundles(bundlesDir, ['Alice', 'Bob']); - const recovery = new RecoveryPage(page, aliceDir); + // Before anything is collected, the list holds one entry: the holder's own + // piece. The other guardians are not named, because the bundle does not know + // them. A piece that someone sends you does carry their name, which is + // theirs to give. + test('the share list names the holder and no one else', async ({ page }) => { + const bundleDir = extractBundle(bundlesDir, 'Alice'); + const recovery = new RecoveryPage(page, bundleDir); await recovery.open(); + await recovery.expectShareCount(1); - // Bob's contact should not be checked initially - await recovery.expectContactNotCollected('Bob'); - - // Add Bob's share - await recovery.addShares(bobDir); - - // Bob's contact should now be checked - await recovery.expectContactCollected('Bob'); + const list = await page.locator('#shares-list').innerText(); + expect(list.trim().length).toBeGreaterThan(0); + expect(list).toContain('Alice'); + expect(list).not.toContain('Bob'); + expect(list).not.toContain('Carol'); }); - test('paste share functionality', async ({ page }) => { const [aliceDir, bobDir] = extractBundles(bundlesDir, ['Alice', 'Bob']); const recovery = new RecoveryPage(page, aliceDir); @@ -283,79 +287,6 @@ test.describe('Browser Recovery Tool', () => { }); }); -test.describe('Anonymous Bundle Recovery', () => { - let anonProjectDir: string; - let anonBundlesDir: string; - - test.beforeAll(async () => { - // Skip if rememory binary not available - const bin = getRememoryBin(); - if (!fs.existsSync(bin)) { - test.skip(); - return; - } - - anonProjectDir = createAnonymousTestProject(); - anonBundlesDir = path.join(anonProjectDir, 'output', 'bundles'); - }); - - test.afterAll(async () => { - cleanupProject(anonProjectDir); - }); - - test('anonymous recover.html loads and shows UI without contact list', async ({ page }) => { - const [share1Dir] = extractAnonymousBundles(anonBundlesDir, [1]); - const recovery = new RecoveryPage(page, share1Dir); - - await recovery.open(); - await recovery.expectUIElements(); - // Manifest should be pre-loaded (embedded in personalization) - await recovery.expectManifestLoaded(); - - // Share should be pre-loaded with synthetic name - await recovery.expectShareCount(1); - await recovery.expectShareHolder('Share 1'); - - // Contact list should NOT be visible for anonymous bundles - await expect(page.locator('#contact-list-section')).not.toBeVisible(); - }); - - test('anonymous full recovery workflow', async ({ page }) => { - const [share1Dir, share2Dir] = extractAnonymousBundles(anonBundlesDir, [1, 2]); - const recovery = new RecoveryPage(page, share1Dir); - - await recovery.open(); - - // Share 1 is pre-loaded, manifest is embedded - await recovery.expectShareCount(1); - await recovery.expectShareHolder('Share 1'); - await recovery.expectManifestLoaded(); - - // Add Share 2 (triggers auto-recovery since threshold is 2) - await recovery.addShares(share2Dir); - - // Recovery should complete automatically - await recovery.expectRecoveryComplete(); - await recovery.expectFileCount(3); // secret.txt, notes.txt, README.md - await recovery.expectDownloadVisible(); - }); - - test('anonymous recovery shows generic share labels', async ({ page }) => { - const [share1Dir, share2Dir] = extractAnonymousBundles(anonBundlesDir, [1, 2]); - const recovery = new RecoveryPage(page, share1Dir); - - await recovery.open(); - - // Add Share 2 - await recovery.addShares(share2Dir); - - // Both shares should be visible with synthetic names - await recovery.expectShareCount(2); - await recovery.expectShareHolder('Share 1'); - await recovery.expectShareHolder('Share 2'); - }); -}); - test.describe('Generic recover.html (no personalization)', () => { let projectDir: string; let bundlesDir: string; @@ -365,7 +296,7 @@ test.describe('Generic recover.html (no personalization)', () => { let tmpDir: string; test.beforeAll(async () => { - const bin = getRememoryBin(); + const bin = getInheritanceBin(); if (!fs.existsSync(bin)) { test.skip(); return; @@ -375,7 +306,7 @@ test.describe('Generic recover.html (no personalization)', () => { bundlesDir = path.join(projectDir, 'output', 'bundles'); noEmbedProjectDir = createTestProject({ noEmbedManifest: true }); noEmbedBundlesDir = path.join(noEmbedProjectDir, 'output', 'bundles'); - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rememory-generic-e2e-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'inheritance-generic-e2e-')); standaloneRecoverHtml = generateStandaloneHTML(tmpDir, 'recover'); }); @@ -512,7 +443,7 @@ test.describe('--no-embed-manifest flag', () => { let noEmbedBundlesDir: string; test.beforeAll(async () => { - const bin = getRememoryBin(); + const bin = getInheritanceBin(); if (!fs.existsSync(bin)) { test.skip(); return; @@ -561,7 +492,7 @@ test.describe('PDF Share Import', () => { let bundlesDir: string; test.beforeAll(async () => { - const bin = getRememoryBin(); + const bin = getInheritanceBin(); if (!fs.existsSync(bin)) { test.skip(); return; @@ -644,7 +575,7 @@ test.describe('ZIP Bundle Import', () => { test('dropping a bundle ZIP on standalone recover.html extracts manifest from inner recover.html', async ({ page }) => { // Use a truly standalone recover.html (no personalization, no manifest) - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rememory-e2e-standalone-')); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'inheritance-e2e-standalone-')); const standaloneHtml = generateStandaloneHTML(tmpDir, 'recover'); const recovery = new RecoveryPage(page, tmpDir); await recovery.openFile(standaloneHtml); @@ -670,7 +601,7 @@ test.describe('ZIP Bundle Import', () => { test('manifest replacement via second ZIP does not corrupt recovery', async ({ page }) => { // Use a standalone recover.html (no personalization, no manifest) - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rememory-e2e-manifest-replace-')); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'inheritance-e2e-manifest-replace-')); const standaloneHtml = generateStandaloneHTML(tmpDir, 'recover'); const recovery = new RecoveryPage(page, tmpDir); await recovery.openFile(standaloneHtml); diff --git a/e2e/screenshots.spec.ts b/e2e/screenshots.spec.ts index d768f0c..04a494a 100644 --- a/e2e/screenshots.spec.ts +++ b/e2e/screenshots.spec.ts @@ -10,16 +10,17 @@ * * Usage: * make screenshots # or: - * REMEMORY_BIN=./rememory npx playwright test e2e/screenshots.spec.ts --project=chromium + * INHERITANCE_BIN=./inheritance npx playwright test e2e/screenshots.spec.ts --project=chromium */ import { test, expect, Page } from './fixtures'; +import { chromium } from '@playwright/test'; import { execFileSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { - getRememoryBin, + getInheritanceBin, generateStandaloneHTML, extractBundle, extractWordsFromReadme, @@ -44,11 +45,15 @@ const VIEWPORT = { width: 1280, height: 2000 }; // Helpers // --------------------------------------------------------------------------- -/** Save a screenshot cropped tightly to visible cards. */ +/** Save a screenshot cropped tightly to visible cards, under docs/screenshots/{lang}/. */ async function snap(page: Page, lang: Lang, name: string): Promise { const dir = path.join(SCREENSHOTS_ROOT, lang); fs.mkdirSync(dir, { recursive: true }); + await snapTo(page, path.join(dir, `${name}.png`)); +} +/** Save a screenshot cropped tightly to visible cards, to an absolute path. */ +async function snapTo(page: Page, file: string): Promise { // Remove overflow:hidden from cards so content isn't clipped, then measure bounds. const bounds = await page.evaluate(() => { const cards = document.querySelectorAll('.container > .card'); @@ -73,7 +78,7 @@ async function snap(page: Page, lang: Lang, name: string): Promise { const pad = 16; await page.screenshot({ - path: path.join(dir, `${name}.png`), + path: file, clip: { x: bounds.x - pad, y: bounds.y - pad, @@ -148,14 +153,14 @@ let standaloneRecoverHtml: string; const langBundlesDirs: Partial> = {}; test.beforeAll(async () => { - const bin = getRememoryBin(); + const bin = getInheritanceBin(); if (!fs.existsSync(bin)) { test.skip(); return; } // Create a project with 5 friends for richer screenshots. - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rememory-screenshots-')); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'inheritance-screenshots-')); projectDir = path.join(tmpDir, 'screenshot-project'); execFileSync(bin, [ @@ -180,7 +185,7 @@ test.beforeAll(async () => { // Create per-language projects so word screenshots show translated BIP39 words for (const lang of LANGUAGES) { - const langTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `rememory-ss-${lang}-`)); + const langTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `inheritance-ss-${lang}-`)); const langProjectDir = path.join(langTmpDir, `words-${lang}`); execFileSync(bin, [ 'init', langProjectDir, '--name', 'Words', @@ -196,7 +201,7 @@ test.beforeAll(async () => { } // Generate standalone HTML files - const htmlTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rememory-ss-html-')); + const htmlTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'inheritance-ss-html-')); makerHtmlPath = generateStandaloneHTML(htmlTmpDir, 'create'); standaloneRecoverHtml = generateStandaloneHTML(htmlTmpDir, 'recover'); }); @@ -270,6 +275,37 @@ for (const lang of LANGUAGES) { await snap(page, lang, 'files'); }); + test(`[${lang}] owners-words`, async ({ page }) => { + const creation = new CreationPage(page, makerHtmlPath); + await creation.open(); + + // The prompts show their examples as placeholder text, so an empty step + // is the honest picture: it is what an owner actually sees when they + // arrive, and the examples are the point of the figure. + // + // Index 2 is correct here: this IS step 3. + await frameCreationStep(page, [2]); + + await snap(page, lang, 'owners-words'); + }); + + // The chain step, with a transaction id in it. Publishing happens BEFORE + // generating, and nothing in the guide had a picture of that. + test(`[${lang}] chain-copy`, async ({ page }) => { + const creation = new CreationPage(page, makerHtmlPath); + await creation.open(); + + await page.check('input[name="destination"][value="both"]'); + await page.fill('#words-wallet', 'A 2 of 3. Any two of the three keys can spend.'); + await page.fill('#chain-descriptor', + 'wsh(sortedmulti(2,[73c5da0a/48h/0h/0h/2h]xpub6DkFAXWQ2dHxq2vatrt9qyA3bXYU4ToWQwCHbf5XB2mSTexcHZCeKS1VZYcPoBd5X8yVcbXFHJR9R8UCVpt82VX1VhR28mCyxUFL4r6KFrf/<0;1>/*,[b8688df1/48h/0h/0h/2h]xpub6FQya7zGhR92kacYsNnjreouvnHJMpXYsUXnW6NJJAJRCKsa26TzDy4LdnGhEurr3d6y1J8PJ7EEMKQp74XTqYvmGJNogYXSKDszYHtF8mX/<0;1>/*,[28645006/48h/0h/0h/2h]xpub6DnEBNkSJKBYQmsbhS1sP9cNdtU5c9PLFGCjTJmxicxc13WB8zNNGQazabQpyFAGW5bV9tMko4uBxDxjUKL6dSAcx1tEbgEHtgSqyRsekh6/<0;1>/*))'); + await expect(page.locator('#chain-size')).toContainText(/sat/i); + await page.fill('#chain-txid', '4801ea9c10e14a5ea5c0e5e68bfe08fd2422005ea0a3a9631fead29ce910a4df'); + + await frameCreationStep(page, [2]); + await snap(page, lang, 'chain-copy'); + }); + test(`[${lang}] bundles`, async ({ page }) => { const creation = new CreationPage(page, makerHtmlPath); await creation.open(); @@ -292,8 +328,12 @@ for (const lang of LANGUAGES) { await expect(page.locator('#status-message.success')).toBeAttached({ timeout: 120000 }); await expect(page.locator('#generate-btn.btn-secondary')).toBeAttached({ timeout: 5000 }); - // Frame: only Step 3 (bundles), hide progress bar - await frameCreationStep(page, [2]); + // Frame: only Step 4, Generate Bundles. Hide the progress bar. + // + // Index 3, not 2. Step 3 became "What Your Heirs Need To Know" on + // 2026-09-22 and Generate moved down one. Until 2026-09-23 this framed + // index 2 and photographed the wrong card. + await frameCreationStep(page, [3]); await hide(page, '#progress-bar'); await snap(page, lang, 'bundles'); @@ -310,8 +350,12 @@ for (const lang of LANGUAGES) { // Wait for the date preview to appear await expect(page.locator('#timelock-date-preview')).not.toBeEmpty(); - // Frame: only Step 3 (generate bundles with tlock panel) - await frameCreationStep(page, [2]); + // Frame: only Step 4, Generate Bundles, where the time lock panel is. + // + // Index 3, not 2. This framed index 2 and produced a picture BYTE + // IDENTICAL to owners-words.png, so the guide showed the wrong card + // under the time lock heading. + await frameCreationStep(page, [3]); await snap(page, lang, 'tlock-setup'); }); @@ -365,6 +409,48 @@ for (const lang of LANGUAGES) { await snap(page, lang, 'recovery-2'); }); + // What the guardians actually get back. Every other recovery figure uses + // a fixture with no owner texts, so nothing in the guide showed the + // sealed files by name, which is the whole point of sealing them. + test(`[${lang}] sealed-files`, async ({ page }, testInfo) => { + testInfo.setTimeout(120000); + const creation = new CreationPage(page, makerHtmlPath); + await creation.open(); + + await creation.setFriend(0, 'Alice', 'alice@example.com'); + await creation.setFriend(1, 'Bob', 'bob@example.com'); + await page.fill('#words-wallet', 'A 2 of 3. Any two of the three keys can spend.'); + await page.fill('#words-keys', 'Key 1: the safe at home. Key 2: Hannah has it.'); + + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'inheritance-sealed-')); + const files = creation.createTestFiles(tmp, 'sealed'); + await creation.addFiles(files); + await creation.generate(); + await creation.expectGenerationComplete(); + + const dirs: string[] = []; + for (let i = 0; i < 2; i++) { + const data = await creation.downloadBundle(i); + const zipPath = path.join(tmp, `b${i}.zip`); + fs.writeFileSync(zipPath, data!); + const out = path.join(tmp, `b${i}`); + fs.mkdirSync(out, { recursive: true }); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const AdmZip = require('adm-zip'); + new AdmZip(zipPath).extractAllTo(out, true); + dirs.push(out); + } + + const recovery = new RecoveryPage(page, dirs[0]); + await recovery.open(); + await recovery.addShares(dirs[1]); + await expect(page.locator('#status-message.success')).toBeAttached({ timeout: 60000 }); + await page.waitForTimeout(500); + + await frameRecoveryStep(page, [2]); + await snap(page, lang, 'sealed-files'); + }); + test(`[${lang}] tlock-waiting`, async ({ page }) => { const recovery = new RecoveryPage(page, path.dirname(standaloneRecoverHtml)); await recovery.openFile(standaloneRecoverHtml); @@ -480,3 +566,117 @@ test.describe('README screenshot', () => { }); }); }); + +// --------------------------------------------------------------------------- +// Docs step screenshots +// +// The guide's recovery section had three images the generator above did not +// make: a browser asking for camera permission, the scanner in use, and an +// OS file picker. The first and last were dialogs of the reader's browser and +// operating system, not of this tool, and a page screenshot cannot contain +// them. They are replaced by the page at that step: the share step with the +// Scan button, and the manifest drop zone. The scanner shot needs a camera, +// which the offline fixtures do not provide, so it launches its own Chromium +// with a fake camera fed from a rendered QR code on paper, and stubs +// BarcodeDetector so the modal stays open long enough to photograph. +// +// Also made here: the Open Graph image (docs/screenshots/recovery-1.png) at +// 1200x630, and the root friends.png the README links to. +// --------------------------------------------------------------------------- + +test.describe('Docs step screenshots', () => { + test('scan button, scanner, manifest drop zone, og image', async () => { + test.setTimeout(120000); + const aliceDir = extractBundle(bundlesDir, 'Alice'); + + // A QR code on warm paper, as a short looping video the fake camera plays. + // The code carries our recovery URL and nothing else, so a reader who + // scans the documentation image lands on the tool. + const work = fs.mkdtempSync(path.join(os.tmpdir(), 'inheritance-qr-')); + const qrPng = path.join(work, 'qr.png'); + const feed = path.join(work, 'qr.y4m'); + execFileSync('qrencode', ['-o', qrPng, '-s', '10', '-m', '2', 'https://www.bitcoinbutlers.com/tools/inheritance/recover.html']); + // Portrait, like a phone camera; the code sized to sit inside the 250px frame. + execFileSync('ffmpeg', [ + '-y', '-loglevel', 'error', + '-f', 'lavfi', '-i', 'color=c=0xf3eee4:s=480x640:r=10:d=2', + '-i', qrPng, + '-filter_complex', '[1:v]scale=150:-1[q];[0:v][q]overlay=(W-w)/2:(H-h)/2', + '-pix_fmt', 'yuv420p', '-t', '2', feed, + ]); + + const browser = await chromium.launch({ + args: [ + '--use-fake-device-for-media-stream', + '--use-fake-ui-for-media-stream', + `--use-file-for-fake-video-capture=${feed}`, + ], + }); + try { + const context = await browser.newContext({ viewport: VIEWPORT, permissions: ['camera'] }); + // Never detect anything: the modal must stay open for the photograph. + await context.addInitScript(() => { + (window as any).BarcodeDetector = class { + static getSupportedFormats() { return Promise.resolve(['qr_code']); } + detect() { return Promise.resolve([]); } + }; + }); + const page = await context.newPage(); + const recovery = new RecoveryPage(page, aliceDir); + await recovery.open(); + await recovery.expectShareCount(1); + + // The share step, with the Scan QR code button the reader is about to press. + await frameRecoveryStep(page, [0]); + await snapTo(page, path.join(SCREENSHOTS_ROOT, 'scan-qr-button.png')); + + // The scanner, with the printed code in the camera's view. The guide says + // to scan with a phone, so this one is a phone: 430x932 at 2x. + const phone = await browser.newContext({ + viewport: { width: 430, height: 932 }, + deviceScaleFactor: 2, + permissions: ['camera'], + }); + await phone.addInitScript(() => { + (window as any).BarcodeDetector = class { + static getSupportedFormats() { return Promise.resolve(['qr_code']); } + detect() { return Promise.resolve([]); } + }; + }); + const phonePage = await phone.newPage(); + const phoneRecovery = new RecoveryPage(phonePage, aliceDir); + await phoneRecovery.open(); + await phoneRecovery.expectShareCount(1); + await phonePage.locator('#scan-qr-btn').click(); + const modal = phonePage.locator('#qr-scanner-modal'); + await expect(modal).toBeVisible(); + await phonePage.waitForFunction(() => { + const v = document.getElementById('qr-video') as HTMLVideoElement | null; + return !!v && v.readyState >= 2 && v.videoWidth > 0; + }, null, { timeout: 20000 }); + await phonePage.waitForTimeout(700); + await modal.screenshot({ path: path.join(SCREENSHOTS_ROOT, 'qr-scanning.png') }); + await phone.close(); + + // The manifest step before anything is loaded: the standalone page, not + // a personalised bundle, because a bundle carries its manifest with it. + await recovery.openFile(standaloneRecoverHtml); + await frameRecoveryStep(page, [1]); + await snapTo(page, path.join(SCREENSHOTS_ROOT, 'manifest-drop-zone.png')); + + // Open Graph image: the recovery tool with a bundle open, 1200x630. + const og = await browser.newPage({ viewport: { width: 1200, height: 630 } }); + const ogRecovery = new RecoveryPage(og, aliceDir); + await ogRecovery.open(); + await ogRecovery.expectShareCount(1); + await og.screenshot({ path: path.join(SCREENSHOTS_ROOT, 'recovery-1.png') }); + await og.close(); + } finally { + await browser.close(); + fs.rmSync(work, { recursive: true, force: true }); + } + + // The README links the root friends.png; keep it the same image as the guide's. + fs.copyFileSync(path.join(SCREENSHOTS_ROOT, 'en', 'friends.png'), path.join(SCREENSHOTS_ROOT, 'friends.png')); + }); +}); diff --git a/e2e/selfhosted.spec.ts b/e2e/selfhosted.spec.ts index da93c4d..3dd9c00 100644 --- a/e2e/selfhosted.spec.ts +++ b/e2e/selfhosted.spec.ts @@ -5,7 +5,7 @@ import * as path from 'path'; import * as os from 'os'; import * as net from 'net'; import AdmZip from 'adm-zip'; -import { getRememoryBin, extractWordsFromReadme, findReadmeFile } from './helpers'; +import { getInheritanceBin, extractWordsFromReadme, findReadmeFile } from './helpers'; // Find an available port async function getAvailablePort(): Promise { @@ -19,7 +19,7 @@ async function getAvailablePort(): Promise { }); } -// Start rememory serve and wait for it to be ready +// Start inheritance serve and wait for it to be ready async function startServer(bin: string, port: number, dataDir: string): Promise { const proc = spawn(bin, ['serve', '--port', String(port), '--host', '127.0.0.1', '--data', dataDir], { stdio: ['ignore', 'pipe', 'pipe'], @@ -52,13 +52,13 @@ test.describe('Selfhosted Server', () => { const adminPassword = 'test-password-e2e'; test.beforeAll(async () => { - const bin = getRememoryBin(); + const bin = getInheritanceBin(); if (!fs.existsSync(bin)) { test.skip(); return; } - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rememory-selfhosted-e2e-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'inheritance-selfhosted-e2e-')); dataDir = path.join(tmpDir, 'data'); port = await getAvailablePort(); baseURL = `http://127.0.0.1:${port}`; @@ -97,7 +97,7 @@ test.describe('Selfhosted Server', () => { // Step 1: Visit root — should show setup page (no password) // ----------------------------------------------------------- await page.goto(baseURL); - await expect(page.locator('h1')).toContainText('Set up Kaitiaki'); + await expect(page.locator('h1')).toContainText('Set up Bitcoin Inheritance'); // Fill in password await page.locator('#password').fill(adminPassword); @@ -118,7 +118,7 @@ test.describe('Selfhosted Server', () => { // Wait for WASM to load await page.waitForFunction( - () => (window as any).rememoryReady === true, + () => (window as any).inheritanceReady === true, { timeout: 30000 } ); @@ -162,7 +162,7 @@ test.describe('Selfhosted Server', () => { // Download both bundles — extract ZIP data from the page const bundleData = await page.evaluate(() => { - const bundles = (window as any).rememoryBundles; + const bundles = (window as any).inheritanceBundles; if (!bundles) return null; return bundles.map((b: any) => ({ fileName: b.fileName, @@ -210,7 +210,7 @@ test.describe('Selfhosted Server', () => { await page.waitForURL(/\/recover\.html\?id=/); await page.waitForFunction( - () => (window as any).rememoryAppReady === true, + () => (window as any).inheritanceAppReady === true, { timeout: 30000 } ); diff --git a/e2e/test-run.spec.ts b/e2e/test-run.spec.ts new file mode 100644 index 0000000..b051c32 --- /dev/null +++ b/e2e/test-run.spec.ts @@ -0,0 +1,131 @@ +import { test, expect } from './fixtures'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import AdmZip from 'adm-zip'; +import { getInheritanceBin, generateStandaloneHTML, CreationPage } from './helpers'; + +/** + * The test run. + * + * An owner who has never seen a recovery cannot tell a good bundle from a bad + * one, so the maker offers throwaway bundles to practise on. Two things make + * that safe, and both are tested here rather than trusted: + * + * 1. A test bundle carries NONE of the owner's own material. Not their + * files, and not the people and places, which is the most sensitive + * thing they write. + * 2. Every test bundle is stamped, so a drill can never be mistaken for the + * real thing. + * + * The nudge that offers it is an offer and never a gate. A required gate was + * rejected while this was decided, because people fake a gate to get past it. + */ +test.describe('Test run', () => { + let htmlPath: string; + let tmpDir: string; + + const SECRET = 'SAFE-BEHIND-THE-PAINTING-42'; + + test.beforeAll(async () => { + const bin = getInheritanceBin(); + if (!fs.existsSync(bin)) { + test.skip(); + return; + } + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'inheritance-testrun-e2e-')); + htmlPath = generateStandaloneHTML(tmpDir, 'create'); + }); + + test.afterAll(async () => { + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + test('offers a test run once, and never blocks Generate', async ({ page }) => { + const creation = new CreationPage(page, htmlPath); + await creation.open(); + + await expect(page.locator('#test-nudge')).toBeVisible(); + await expect(page.locator('#test-banner')).toBeHidden(); + // The offer must never disable the real button beside it. + await expect(page.locator('#generate-btn')).toBeEnabled(); + + await page.locator('#test-nudge-btn').click(); + await expect(page.locator('#test-banner')).toBeVisible(); + await expect(page.locator('#test-nudge')).toBeHidden(); + + // Said once. A reload must not offer it again. + await page.reload(); + await expect(page.locator('#test-nudge')).toBeHidden(); + await expect(page.locator('#test-banner')).toBeHidden(); + }); + + test('a test bundle is stamped and carries none of the owner material', async ({ page }) => { + const creation = new CreationPage(page, htmlPath); + await creation.open(); + + // Something sensitive in the owner's words, so its absence is proved + // rather than assumed. + await page.locator('#words-keys').fill(SECRET); + + await creation.setFriend(0, 'Alice'); + await creation.setFriend(1, 'Bob'); + + await page.locator('#test-nudge-btn').click(); + await expect(page.locator('#test-banner')).toBeVisible(); + + // No files added on purpose: a test run brings its own sample. + await page.locator('#generate-btn').click(); + await expect(page.locator('#bundles-list')).toBeVisible({ timeout: 120_000 }); + + // The list must show the name the file will actually be saved under. + await expect(page.locator('#bundles-list')).toContainText('TEST-bundle-alice.zip'); + + const download = await Promise.all([ + page.waitForEvent('download'), + page.locator('#bundles-list button[data-index="0"]').click(), + ]).then(([d]) => d); + + expect(download.suggestedFilename()).toBe('TEST-bundle-alice.zip'); + + const saved = path.join(tmpDir, 'downloaded.zip'); + await download.saveAs(saved); + const zip = new AdmZip(saved); + const entries = zip.getEntries().map((e) => e.entryName); + const readme = zip.readAsText('README.txt'); + + // Stamped where a guardian reads it first. + expect(readme).toContain('TEST'); + // And carrying nothing of the owner's. + expect(readme).not.toContain(SECRET); + expect(entries).not.toContain('WHERE-THE-KEYS-ARE.txt'); + expect(zip.toBuffer().toString('latin1')).not.toContain(SECRET); + }); + + test('turning the test run off unstamps the next run, not the last one', async ({ page }) => { + const creation = new CreationPage(page, htmlPath); + await creation.open(); + + await creation.setFriend(0, 'Alice'); + await creation.setFriend(1, 'Bob'); + + await page.locator('#test-nudge-btn').click(); + await page.locator('#generate-btn').click(); + await expect(page.locator('#bundles-list')).toBeVisible({ timeout: 120_000 }); + + // The stamp belongs to the bundles that exist, so turning the mode off + // must not rename what is already made. Reading it at click time was a + // real bug: the list said TEST and the saved file did not. + await page.locator('#test-off-btn').click(); + await expect(page.locator('#test-banner')).toBeHidden(); + await expect(page.locator('#bundles-list')).toContainText('TEST-bundle-alice.zip'); + + const download = await Promise.all([ + page.waitForEvent('download'), + page.locator('#bundles-list button[data-index="0"]').click(), + ]).then(([d]) => d); + expect(download.suggestedFilename()).toBe('TEST-bundle-alice.zip'); + }); +}); diff --git a/e2e/tlock.spec.ts b/e2e/tlock.spec.ts index 5e3aa7f..d24873e 100644 --- a/e2e/tlock.spec.ts +++ b/e2e/tlock.spec.ts @@ -3,7 +3,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { - getRememoryBin, + getInheritanceBin, generateStandaloneHTML, createTestProject, extractBundle, @@ -16,12 +16,12 @@ test.describe('Time-lock: maker.html advanced options', () => { let tmpDir: string; test.beforeAll(async () => { - const bin = getRememoryBin(); + const bin = getInheritanceBin(); if (!fs.existsSync(bin)) { test.skip(); return; } - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rememory-tlock-e2e-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'inheritance-tlock-e2e-')); htmlPath = generateStandaloneHTML(tmpDir, 'create'); }); @@ -129,10 +129,10 @@ test.describe('Time-lock: maker.html bundle creation and recovery with tlock', ( // Recovery needs drand beacon access — creation is still offline (enforced by group 1). test.use({ allowedHosts: ['api.drand.sh'] }); - // This test hits the real drand network — only run under REMEMORY_TEST_TLOCK=1 (make test-tlock) + // This test hits the real drand network — only run under INHERITANCE_TEST_TLOCK=1 (make test-tlock) test.beforeAll(() => { - if (process.env.REMEMORY_TEST_TLOCK !== '1') { - throw new Error('REMEMORY_TEST_TLOCK=1 is required — run these tests via make test-tlock'); + if (process.env.INHERITANCE_TEST_TLOCK !== '1') { + throw new Error('INHERITANCE_TEST_TLOCK=1 is required — run these tests via make test-tlock'); } }); @@ -141,12 +141,12 @@ test.describe('Time-lock: maker.html bundle creation and recovery with tlock', ( let tmpDir: string; test.beforeAll(async () => { - const bin = getRememoryBin(); + const bin = getInheritanceBin(); if (!fs.existsSync(bin)) { test.skip(); return; } - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rememory-tlock-create-e2e-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'inheritance-tlock-create-e2e-')); makerPath = generateStandaloneHTML(tmpDir, 'create'); recoverPath = generateStandaloneHTML(tmpDir, 'recover'); }); @@ -252,12 +252,12 @@ test.describe('Time-lock: recover.html tlock detection', () => { let tmpDir: string; test.beforeAll(async () => { - const bin = getRememoryBin(); + const bin = getInheritanceBin(); if (!fs.existsSync(bin)) { test.skip(); return; } - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rememory-tlock-recover-e2e-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'inheritance-tlock-recover-e2e-')); genericRecoverPath = generateStandaloneHTML(tmpDir, 'recover'); }); @@ -304,7 +304,7 @@ test.describe('Time-lock: non-tlock bundles', () => { let projectDir: string; test.beforeAll(async () => { - const bin = getRememoryBin(); + const bin = getInheritanceBin(); if (!fs.existsSync(bin)) { test.skip(); return; @@ -344,7 +344,7 @@ test.describe('Time-lock: non-tlock bundles', () => { const personalizedSize = fs.statSync(path.join(aliceDir, 'recover.html')).size; // Generate generic recover.html (which includes tlock-js) - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rememory-tlock-size-e2e-')); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'inheritance-tlock-size-e2e-')); try { const genericPath = generateStandaloneHTML(tmpDir, 'recover'); const genericSize = fs.statSync(genericPath).size; diff --git a/go.mod b/go.mod index 6c5c27f..dccb60a 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/eljojo/rememory +module github.com/Bitcoin-Butlers/kaitiaki go 1.25.7 @@ -12,6 +12,7 @@ require ( github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/spf13/cobra v1.10.2 github.com/yuin/goldmark v1.8.2 + golang.org/x/crypto v0.52.0 golang.org/x/text v0.40.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -42,7 +43,6 @@ require ( go.uber.org/zap v1.28.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.52.0 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/sys v0.45.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 // indirect diff --git a/internal/audit_test.go b/internal/audit_test.go index 867c7f5..e5a7dd8 100644 --- a/internal/audit_test.go +++ b/internal/audit_test.go @@ -5,8 +5,8 @@ import ( "strings" "testing" - "github.com/eljojo/rememory/internal/core" - "github.com/eljojo/rememory/internal/crypto" + "github.com/Bitcoin-Butlers/kaitiaki/internal/core" + "github.com/Bitcoin-Butlers/kaitiaki/internal/crypto" ) // sealSecret encrypts secret with a fresh v2 passphrase and splits the raw diff --git a/internal/bundle/bundle.go b/internal/bundle/bundle.go index e75ae60..c77b6af 100644 --- a/internal/bundle/bundle.go +++ b/internal/bundle/bundle.go @@ -11,11 +11,11 @@ import ( "strings" "time" - "github.com/eljojo/rememory/internal/core" - "github.com/eljojo/rememory/internal/html" - "github.com/eljojo/rememory/internal/pdf" - "github.com/eljojo/rememory/internal/project" - "github.com/eljojo/rememory/internal/translations" + "github.com/Bitcoin-Butlers/kaitiaki/internal/core" + "github.com/Bitcoin-Butlers/kaitiaki/internal/html" + "github.com/Bitcoin-Butlers/kaitiaki/internal/pdf" + "github.com/Bitcoin-Butlers/kaitiaki/internal/project" + "github.com/Bitcoin-Butlers/kaitiaki/internal/translations" ) // Config holds configuration for bundle generation. @@ -86,29 +86,13 @@ func GenerateAll(p *project.Project, cfg Config) error { lang = "en" } - // Get other friends (excluding this one) - empty for anonymous mode - var otherFriends []project.Friend - var otherFriendsInfo []html.FriendInfo - if !p.Anonymous { - otherFriends = make([]project.Friend, 0, len(p.Friends)-1) - otherFriendsInfo = make([]html.FriendInfo, 0, len(p.Friends)-1) - for j, f := range p.Friends { - if j != i { - otherFriends = append(otherFriends, f) - otherFriendsInfo = append(otherFriendsInfo, html.FriendInfo{ - Name: f.Name, - Contact: f.Contact, - ShareIndex: j + 1, // 1-based share index - }) - } - } - } + // A bundle names its own holder and nobody else. The other guardians + // were listed here until 2026-09-24. See sealed_texts.go. // Generate personalized recover.html for this friend personalization := &html.PersonalizationData{ Holder: friend.Name, HolderShare: share.Encode(), - OtherFriends: otherFriendsInfo, Threshold: disclosedThreshold, Total: disclosedTotal, Language: lang, @@ -131,7 +115,6 @@ func GenerateAll(p *project.Project, cfg Config) error { ProjectName: p.Name, Friend: friend, Share: share, - OtherFriends: otherFriends, Threshold: disclosedThreshold, Total: disclosedTotal, ManifestData: manifestData, @@ -142,10 +125,12 @@ func GenerateAll(p *project.Project, cfg Config) error { Version: cfg.Version, GitHubReleaseURL: githubReleaseURL, SealedAt: p.Sealed.At, - Anonymous: p.Anonymous, RecoveryURL: cfg.RecoveryURL, Language: lang, TlockEnabled: cfg.TlockEnabled, + // The texts themselves went into the archive before it was + // encrypted. A bundle carries only whether there were any. + OwnerWroteNothing: p.RecoverySteps == "" && p.ChainPayload == "", }) if err != nil { return fmt.Errorf("generating bundle for %s: %w", friend.Name, err) @@ -166,7 +151,6 @@ type BundleParams struct { ProjectName string Friend project.Friend Share *core.Share - OtherFriends []project.Friend Threshold int Total int ManifestData []byte @@ -177,31 +161,34 @@ type BundleParams struct { Version string GitHubReleaseURL string SealedAt time.Time - Anonymous bool RecoveryURL string Language string // Bundle language for this friend TlockEnabled bool // true when manifest uses time-lock encryption + + // OwnerWroteNothing is true when the owner left no text at all. Every + // text an owner writes is sealed in the encrypted archive, so a bundle + // carries the flag and never the words. See sealed_texts.go. + OwnerWroteNothing bool } // GenerateBundle creates a single bundle ZIP file for one friend. func GenerateBundle(params BundleParams) error { // Common data for both README formats readmeData := ReadmeData{ - ProjectName: params.ProjectName, - Holder: params.Friend.Name, - Share: params.Share, - OtherFriends: params.OtherFriends, - Threshold: params.Threshold, - Total: params.Total, - Version: params.Version, - GitHubReleaseURL: params.GitHubReleaseURL, - ManifestChecksum: params.ManifestChecksum, - RecoverChecksum: params.RecoverChecksum, - Created: params.SealedAt, - Anonymous: params.Anonymous, - Language: params.Language, - ManifestEmbedded: params.ManifestEmbedded, - TlockEnabled: params.TlockEnabled, + ProjectName: params.ProjectName, + Holder: params.Friend.Name, + Share: params.Share, + Threshold: params.Threshold, + Total: params.Total, + Version: params.Version, + GitHubReleaseURL: params.GitHubReleaseURL, + ManifestChecksum: params.ManifestChecksum, + RecoverChecksum: params.RecoverChecksum, + Created: params.SealedAt, + Language: params.Language, + ManifestEmbedded: params.ManifestEmbedded, + TlockEnabled: params.TlockEnabled, + OwnerWroteNothing: params.OwnerWroteNothing, } // Generate README.txt @@ -212,7 +199,6 @@ func GenerateBundle(params BundleParams) error { ProjectName: readmeData.ProjectName, Holder: readmeData.Holder, Share: readmeData.Share, - OtherFriends: readmeData.OtherFriends, Threshold: readmeData.Threshold, Total: params.Total, Version: readmeData.Version, @@ -220,7 +206,6 @@ func GenerateBundle(params BundleParams) error { ManifestChecksum: readmeData.ManifestChecksum, RecoverChecksum: readmeData.RecoverChecksum, Created: readmeData.Created, - Anonymous: readmeData.Anonymous, RecoveryURL: params.RecoveryURL, Language: params.Language, ManifestEmbedded: params.ManifestEmbedded, diff --git a/internal/bundle/extract.go b/internal/bundle/extract.go index 7083aad..605325a 100644 --- a/internal/bundle/extract.go +++ b/internal/bundle/extract.go @@ -5,9 +5,9 @@ import ( "fmt" "io" - "github.com/eljojo/rememory/internal/core" - "github.com/eljojo/rememory/internal/html" - "github.com/eljojo/rememory/internal/translations" + "github.com/Bitcoin-Butlers/kaitiaki/internal/core" + "github.com/Bitcoin-Butlers/kaitiaki/internal/html" + "github.com/Bitcoin-Butlers/kaitiaki/internal/translations" ) // ExtractShareFromZip opens a bundle ZIP and parses the share from the diff --git a/internal/bundle/extract_test.go b/internal/bundle/extract_test.go index efe953c..3a6da07 100644 --- a/internal/bundle/extract_test.go +++ b/internal/bundle/extract_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/eljojo/rememory/internal/core" + "github.com/Bitcoin-Butlers/kaitiaki/internal/core" ) func testShare() *core.Share { diff --git a/internal/bundle/metadata_test.go b/internal/bundle/metadata_test.go index 556be57..c59a549 100644 --- a/internal/bundle/metadata_test.go +++ b/internal/bundle/metadata_test.go @@ -9,8 +9,8 @@ import ( "testing" "time" - "github.com/eljojo/rememory/internal/core" - "github.com/eljojo/rememory/internal/project" + "github.com/Bitcoin-Butlers/kaitiaki/internal/core" + "github.com/Bitcoin-Butlers/kaitiaki/internal/project" "gopkg.in/yaml.v3" ) diff --git a/internal/bundle/readme.go b/internal/bundle/readme.go index 004722e..40148c3 100644 --- a/internal/bundle/readme.go +++ b/internal/bundle/readme.go @@ -7,9 +7,8 @@ import ( "golang.org/x/text/unicode/norm" - "github.com/eljojo/rememory/internal/core" - "github.com/eljojo/rememory/internal/project" - "github.com/eljojo/rememory/internal/translations" + "github.com/Bitcoin-Butlers/kaitiaki/internal/core" + "github.com/Bitcoin-Butlers/kaitiaki/internal/translations" ) // ReadmeData contains all data needed to generate README.txt @@ -17,7 +16,6 @@ type ReadmeData struct { ProjectName string Holder string Share *core.Share - OtherFriends []project.Friend Threshold int Total int Version string @@ -25,11 +23,16 @@ type ReadmeData struct { ManifestChecksum string RecoverChecksum string Created time.Time - Anonymous bool Language string // Bundle language (e.g. "en", "es"); defaults to "en" ManifestEmbedded bool // true when manifest is embedded in recover.html OwnerKeyPresent bool // true when the bundle contains OWNER.age TlockEnabled bool // true when manifest uses time-lock encryption + + // OwnerWroteNothing is true when the owner left no text at all. It is a + // flag and never the text itself: every word an owner writes is sealed in + // the encrypted archive, so this struct is not given the content and the + // README cannot print it by mistake. See sealed_texts.go. + OwnerWroteNothing bool } // writeWordGrid writes a two-column word grid to the string builder. @@ -82,31 +85,36 @@ func GenerateReadme(data ReadmeData) string { // Warning sb.WriteString(fmt.Sprintf("!! %s\n", t("warning_title"))) - if data.Anonymous { - sb.WriteString(fmt.Sprintf(" %s\n\n", t("warning_message_shares"))) - } else { - sb.WriteString(fmt.Sprintf(" %s\n\n", t("warning_message_friends"))) - } + sb.WriteString(fmt.Sprintf(" %s\n\n", t("warning_message"))) - // Other share holders (skip for anonymous mode) - if !data.Anonymous { + // No roster, no method, no chain copy. Every one of them is sealed in the + // encrypted archive now, so they reach a reader only when enough guardians + // combine their pieces. See sealed_texts.go for why. + + // Say so when the owner wrote nothing. An heir 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 this, that difference matters. + if data.OwnerWroteNothing { sb.WriteString("--------------------------------------------------------------------------------\n") - sb.WriteString(fmt.Sprintf("%s\n", t("other_holders"))) + sb.WriteString(fmt.Sprintf("%s\n", t("no_instructions_title"))) sb.WriteString("--------------------------------------------------------------------------------\n") - for _, friend := range data.OtherFriends { - sb.WriteString(fmt.Sprintf("%s\n", friend.Name)) - if friend.Contact != "" { - sb.WriteString(fmt.Sprintf(" %s\n", t("contact_label", friend.Contact))) - } - sb.WriteString("\n") - } + sb.WriteString(fmt.Sprintf("%s\n\n", t("no_instructions"))) } + // Who else holds a piece. The bundle cannot say, so it says where to look. + sb.WriteString("--------------------------------------------------------------------------------\n") + sb.WriteString(fmt.Sprintf("%s\n", t("who_else_title"))) + sb.WriteString("--------------------------------------------------------------------------------\n") + sb.WriteString(fmt.Sprintf("%s\n\n", t("who_else"))) + // Sharing your share (what to do when someone asks) sb.WriteString("--------------------------------------------------------------------------------\n") sb.WriteString(fmt.Sprintf("%s\n", t("sharing_title"))) sb.WriteString("--------------------------------------------------------------------------------\n") sb.WriteString(fmt.Sprintf("%s\n\n", t("sharing_verify"))) + // A guardian used to be able to ring another guardian to check. They know + // nobody now, so the estate papers are the credential instead. + sb.WriteString(fmt.Sprintf("%s\n\n", t("sharing_verify_estate"))) sb.WriteString(fmt.Sprintf(" - %s\n", t("sharing_easiest"))) sb.WriteString(fmt.Sprintf(" - %s\n", t("sharing_readme_only"))) sb.WriteString(fmt.Sprintf(" - %s\n", t("sharing_words_phone"))) @@ -130,26 +138,15 @@ func GenerateReadme(data ReadmeData) string { if data.OwnerKeyPresent { sb.WriteString(fmt.Sprintf("%s\n\n", t("owner_note"))) } - if data.Anonymous { - sb.WriteString(fmt.Sprintf("%s\n", t("recover_anon_step3"))) - sb.WriteString(fmt.Sprintf(" %s\n", t("recover_anon_step3_drag"))) - sb.WriteString(fmt.Sprintf(" %s\n\n", t("recover_anon_step3_paste"))) - if data.Threshold > 0 { - sb.WriteString(fmt.Sprintf("%s\n\n", t("recover_anon_step4_auto", data.Threshold))) - } - sb.WriteString(fmt.Sprintf("%s\n\n", t("recover_anon_step5"))) - } else { - sb.WriteString(fmt.Sprintf("%s\n", t("recover_step3_contact"))) - sb.WriteString(fmt.Sprintf(" %s\n\n", t("recover_step3_ask"))) - sb.WriteString(fmt.Sprintf("%s\n", t("recover_step4"))) - sb.WriteString(fmt.Sprintf(" %s\n", t("recover_step4_drag"))) - sb.WriteString(fmt.Sprintf(" %s\n\n", t("recover_step4_paste"))) - sb.WriteString(fmt.Sprintf("%s\n", t("recover_step5_checkmarks"))) - if data.Threshold > 0 { - sb.WriteString(fmt.Sprintf(" %s\n\n", t("recover_step5_auto", data.Threshold))) - } - sb.WriteString(fmt.Sprintf("%s\n\n", t("recover_step6"))) + // One set of steps, for every bundle. The other set told the reader to + // open a contact list and ask the people on it. There is no list. + sb.WriteString(fmt.Sprintf("%s\n", t("recover_step3"))) + sb.WriteString(fmt.Sprintf(" %s\n", t("recover_step3_drag"))) + sb.WriteString(fmt.Sprintf(" %s\n\n", t("recover_step3_paste"))) + if data.Threshold > 0 { + sb.WriteString(fmt.Sprintf("%s\n\n", t("recover_step4_auto", data.Threshold))) } + sb.WriteString(fmt.Sprintf("%s\n\n", t("recover_step5"))) if data.TlockEnabled { sb.WriteString(fmt.Sprintf("%s\n\n", t("recover_offline_tlock"))) } else { @@ -201,7 +198,7 @@ func GenerateReadme(data ReadmeData) string { sb.WriteString("================================================================================\n") sb.WriteString("METADATA FOOTER (machine-parseable)\n") sb.WriteString("================================================================================\n") - sb.WriteString(fmt.Sprintf("kaitiaki-version: %s\n", data.Version)) + sb.WriteString(fmt.Sprintf("inheritance-version: %s\n", data.Version)) sb.WriteString(fmt.Sprintf("created: %s\n", data.Created.Format(time.RFC3339))) sb.WriteString(fmt.Sprintf("project: %s\n", data.ProjectName)) if data.Threshold > 0 { diff --git a/internal/bundle/sealed_texts.go b/internal/bundle/sealed_texts.go new file mode 100644 index 0000000..1b14cf7 --- /dev/null +++ b/internal/bundle/sealed_texts.go @@ -0,0 +1,130 @@ +package bundle + +import ( + "strings" + "time" + + "github.com/Bitcoin-Butlers/kaitiaki/internal/translations" +) + +// The texts an owner may write, and the one rule they share: every one of them +// is sealed INSIDE the encrypted archive, so it appears only when enough +// guardians combine their pieces. +// +// A README is built to be forwarded. A guardian's own copy tells them to send +// it to whoever asks for their piece, so nothing that names people, places or +// the wallet may travel in it. That includes the chain copy: its ciphertext +// needs one of the wallet's own keys, but a guardian who never reads it does +// not know a chain copy exists, where to look, or that a key they hold would +// open it. +// +// The names and the headers are decided here rather than in the browser, so a +// bundle made by the command line matches one made in a browser. +const ( + PeopleAndPlacesFileName = "WHERE-THE-KEYS-ARE.txt" + RecoveryStepsFileName = "HOW-THE-WALLET-WORKS.txt" + ChainCopyFileName = "CHAIN-COPY.txt" +) + +// SealedFileNames lists every file this package seals inside the archive. +// Tests walk it to assert that no README, PDF or recover page carries any of +// their content. +var SealedFileNames = []string{ + PeopleAndPlacesFileName, + RecoveryStepsFileName, + ChainCopyFileName, +} + +func langOrEnglish(lang string) string { + if lang == "" { + return "en" + } + return lang +} + +// PeopleAndPlacesFile turns the owner's key locations into the file that +// carries them. +func PeopleAndPlacesFile(text, lang string) (name string, content []byte) { + lang = langOrEnglish(lang) + header := translations.T("readme", lang, "people_places_file_header") + body := strings.TrimRight(text, "\n") + return PeopleAndPlacesFileName, []byte(header + "\n" + body + "\n") +} + +// RecoveryStepsFile turns the owner's method into the file that carries it. +// +// This text sat in the plaintext README until 2026-09-24, on the reasoning +// that a lone guardian may safely hold it. It may not: read beside the roster +// it told a colluding guardian how the wallet works and who to recruit. +func RecoveryStepsFile(text, lang string) (name string, content []byte) { + lang = langOrEnglish(lang) + t := func(key string) string { return translations.T("readme", lang, key) } + var sb strings.Builder + sb.WriteString(t("owner_steps_title") + "\n\n") + sb.WriteString(t("owner_steps_intro") + "\n") + sb.WriteString(t("recovery_steps_file_note") + "\n\n") + sb.WriteString(strings.TrimRight(text, "\n") + "\n") + return RecoveryStepsFileName, []byte(sb.String()) +} + +// ChainCopyFile turns the published chain copy into the file that carries it. +// +// The txid travels with the ciphertext. Leaving it behind in a README would +// undo the move: a reader who holds one of the wallet's keys needs only the +// transaction id to fetch the same payload from the chain. +// +// An owner who publishes AFTER sealing cannot get their id in here at all: the +// archive is encrypted and its key is already split. That is why the placement +// session publishes before it seals, and why the estate insert carries the id +// as well. See docs/wayfinder/guardian-privacy/tickets/08-where-the-txid-lives.md. +func ChainCopyFile(payload, txid, lang string) (name string, content []byte) { + lang = langOrEnglish(lang) + t := func(key string) string { return translations.T("readme", lang, key) } + var sb strings.Builder + sb.WriteString(t("chain_copy_title") + "\n\n") + sb.WriteString(t("chain_copy_intro") + "\n\n") + sb.WriteString(t("chain_copy_may_be_older") + "\n\n") + sb.WriteString(strings.TrimRight(payload, "\n") + "\n\n") + sb.WriteString(t("chain_copy_txid") + " ") + if txid != "" { + sb.WriteString(txid + "\n") + } else { + // No blank line to fill in. This file is sealed inside an encrypted + // archive, so nobody can write on it after the fact. Say where the id + // is instead. + sb.WriteString("\n" + t("chain_copy_txid_blank") + "\n") + } + return ChainCopyFileName, []byte(sb.String()) +} + +// SealedTexts is everything an owner writes. They travel together because +// they share one rule, and deciding which of them gets sealed in two places +// is how the CLI and the browser drift apart. +type SealedTexts struct { + PeopleAndPlaces string + RecoverySteps string + ChainPayload string + // ChainTxid is only ever known here when the owner published BEFORE + // sealing. See ChainCopyFile. + ChainTxid string +} + +// SealedFiles turns the owner's texts into the files the archive carries. +// The CLI and the browser both call this, so a bundle made either way holds +// the same files under the same names. +func SealedFiles(texts SealedTexts, lang string, modTime time.Time) []ZipFile { + var out []ZipFile + add := func(name string, content []byte) { + out = append(out, ZipFile{Name: name, Content: content, ModTime: modTime}) + } + if texts.PeopleAndPlaces != "" { + add(PeopleAndPlacesFile(texts.PeopleAndPlaces, lang)) + } + if texts.RecoverySteps != "" { + add(RecoveryStepsFile(texts.RecoverySteps, lang)) + } + if texts.ChainPayload != "" { + add(ChainCopyFile(texts.ChainPayload, texts.ChainTxid, lang)) + } + return out +} diff --git a/internal/bundle/three_texts_test.go b/internal/bundle/three_texts_test.go new file mode 100644 index 0000000..9e6c7e3 --- /dev/null +++ b/internal/bundle/three_texts_test.go @@ -0,0 +1,200 @@ +package bundle + +import ( + "strings" + "testing" + "time" + + "github.com/Bitcoin-Butlers/kaitiaki/internal/core" +) + +// Everything the owner writes is sealed inside the encrypted archive, so it +// reaches a reader only when enough guardians combine their pieces. +// +// README.txt is built to be forwarded: a guardian's own copy tells them to +// send it to whoever asks for their piece. Until 2026-09-24 it also carried +// the roster, the owner's method and the chain copy, so a single guardian +// read all three and a colluding one knew who else to recruit. + +const ( + steps = "2 of 3. Open Sparrow, load the descriptor, connect any two signers." + locations = "Key 1: the safe at home. Key 2: Hannah has it. Key 3: bank box." + payload = "QklQMTM4AQAFLr8hjVQ5qqHdxyiEOfHcVsZlnWdF" + txid = "4801ea9c10e14a5ea5c0e5e68bfe08fd2422005ea0a3a9631fead29ce910a4df" +) + +func baseData() ReadmeData { + return ReadmeData{ + ProjectName: "Test Project", + Holder: "Alice", + Threshold: 2, + Total: 3, + Version: "v0.0.22", + Created: time.Date(2026, 9, 22, 12, 0, 0, 0, time.UTC), + Language: "en", + Share: core.NewShare(2, 1, 3, 2, "Alice", []byte{1, 2, 3, 4, 5, 6, 7, 8}), + } +} + +// The strongest test in this file. A README travels to whoever asks a guardian +// for their piece, so none of the owner's words may be in one. +// +// ReadmeData has no field that could hold them, which is the real guarantee. +// This test cannot enforce that: it greps one rendered output, so a new field +// that nothing populates would slip past it. The compiler is what stops the +// field coming back; this pins the rendered text. +func TestNoOwnerTextCanReachTheReadme(t *testing.T) { + got := GenerateReadme(baseData()) + + // Walk the list itself, so a fourth sealed file added later is covered + // here without anyone remembering to add it. + for _, name := range SealedFileNames { + if strings.Contains(got, name) { + t.Errorf("a README must not name a sealed file, found %q", name) + } + } + + for _, secret := range []string{ + steps, locations, payload, txid, + "Hannah", "bank box", "safe at home", + "THE CHAIN COPY", "the owner wrote this", + "any ONE of the wallet's keys", + } { + if strings.Contains(got, secret) { + t.Errorf("a README must never carry the owner's words, found %q", secret) + } + } +} + +// A guardian learns their own name and nobody else's. +func TestTheReadmeNamesNoOtherGuardian(t *testing.T) { + d := baseData() + got := GenerateReadme(d) + + if !strings.Contains(got, d.Holder) { + t.Error("a guardian must be able to tell the bundle is theirs") + } + for _, marker := range []string{"Bob", "bob@example.com", "OTHER GUARDIANS", "Contact:"} { + if strings.Contains(got, marker) { + t.Errorf("a README must name nobody but its holder, found %q", marker) + } + } +} + +// The recovery steps must not send the reader to a list that no longer exists. +func TestTheReadmeDoesNotSendAnyoneToAContactList(t *testing.T) { + got := GenerateReadme(baseData()) + for _, marker := range []string{"contact list", "next to each guardian's name"} { + if strings.Contains(got, marker) { + t.Errorf("the steps must not describe a list the page does not show, found %q", marker) + } + } +} + +func TestKeyLocationsGoInTheSealedArchiveFile(t *testing.T) { + name, content := PeopleAndPlacesFile(locations, "en") + if name != PeopleAndPlacesFileName { + t.Errorf("file name: got %q want %q", name, PeopleAndPlacesFileName) + } + body := string(content) + if !strings.Contains(body, locations) { + t.Error("the owner's text must be in the file") + } + if !strings.Contains(body, "enough guardians combine their pieces") { + t.Error("the file must say why it was sealed, so a reader knows what they are holding") + } +} + +func TestTheMethodGoesInTheSealedArchiveFile(t *testing.T) { + name, content := RecoveryStepsFile(steps, "en") + if name != RecoveryStepsFileName { + t.Errorf("file name: got %q want %q", name, RecoveryStepsFileName) + } + body := string(content) + if !strings.Contains(body, steps) { + t.Error("the owner's method must be in the file") + } + if !strings.Contains(body, "enough guardians combine their pieces") { + t.Error("the file must say why it was sealed") + } + if !strings.Contains(body, "owner wrote this") { + t.Error("the file must say these are the owner's words, not the tool's") + } +} + +// The transaction id travels with the ciphertext. Splitting them would undo +// the move, because the id alone fetches the same payload off the chain. +func TestTheChainCopyGoesInTheSealedArchiveFileWithItsTxid(t *testing.T) { + name, content := ChainCopyFile(payload, txid, "en") + if name != ChainCopyFileName { + t.Errorf("file name: got %q want %q", name, ChainCopyFileName) + } + body := string(content) + for _, want := range []string{payload, txid, "any ONE of the wallet's keys", "trust this bundle"} { + if !strings.Contains(body, want) { + t.Errorf("the sealed chain copy must carry %q", want) + } + } +} + +// An owner who published after sealing has no id in here, and no way to add +// one: this file is inside an encrypted archive. It must say so rather than +// offer a line to write on, which was the bug until 2026-09-24. +func TestAnUnknownTxidSaysWhereToLookInstead(t *testing.T) { + _, content := ChainCopyFile(payload, "", "en") + body := string(content) + if strings.Contains(body, "____") { + t.Error("a sealed file must not offer a line to write on, because nobody can write on it") + } + if !strings.Contains(body, "estate page") { + t.Error("a reader with no id must be told where the id is") + } + if strings.Contains(body, txid) { + t.Error("no transaction id may be invented") + } +} + +func TestAnEmptyBundleSaysSo(t *testing.T) { + // An heir holding a bundle with no instructions cannot otherwise tell + // whether that was the owner's decision or a lost file. At the moment + // they are reading this, that difference matters. + d := baseData() + d.OwnerWroteNothing = true + got := GenerateReadme(d) + if !strings.Contains(got, "did not leave instructions") { + t.Error("a bundle with no instructions must say so") + } + if !strings.Contains(got, "not missing a page") { + t.Error("the heir must be told nothing has gone wrong") + } + if strings.Contains(got, "names of the other guardians") { + t.Error("the notice must not promise a roster the bundle no longer carries") + } +} + +func TestABundleWithWordsDoesNotSaySo(t *testing.T) { + // The words themselves are sealed in the archive. The README carries only + // the fact that there were some, so it stays silent. + got := GenerateReadme(baseData()) + if strings.Contains(got, "did not leave instructions") { + t.Error("a bundle that carries the owner's words must not claim otherwise") + } +} + +// A bundle names nobody, so it has to say where the names are. Otherwise an +// heir with one bundle has a count of missing pieces and no thread to pull. +func TestTheReadmeSaysWhereTheRosterLives(t *testing.T) { + got := GenerateReadme(baseData()) + + for _, want := range []string{"will or estate papers", "does not say"} { + if !strings.Contains(got, want) { + t.Errorf("the README must point an heir at the roster, missing %q", want) + } + } + + // And a guardian who is asked for their piece can no longer ring another + // guardian to check. The estate papers are the credential instead. + if !strings.Contains(got, "comes with their estate papers") { + t.Error("the README must tell a guardian how to check a request is real") + } +} diff --git a/internal/cmd/bundle.go b/internal/cmd/bundle.go index c191146..080ca62 100644 --- a/internal/cmd/bundle.go +++ b/internal/cmd/bundle.go @@ -5,9 +5,9 @@ import ( "os" "path/filepath" - "github.com/eljojo/rememory/internal/bundle" - "github.com/eljojo/rememory/internal/core" - "github.com/eljojo/rememory/internal/project" + "github.com/Bitcoin-Butlers/kaitiaki/internal/bundle" + "github.com/Bitcoin-Butlers/kaitiaki/internal/core" + "github.com/Bitcoin-Butlers/kaitiaki/internal/project" "github.com/spf13/cobra" ) @@ -18,7 +18,7 @@ var bundleCmd = &cobra.Command{ - Lost the original bundle files - Want to update bundles with a newer version of recover.html -Note: 'kaitiaki seal' automatically generates bundles, so you typically +Note: 'inheritance seal' automatically generates bundles, so you typically don't need to run this command separately. Each bundle contains: @@ -45,7 +45,7 @@ func runBundle(cmd *cobra.Command, args []string) error { projectDir, err := project.FindProjectDir(cwd) if err != nil { - return fmt.Errorf("no kaitiaki project found (run 'kaitiaki init' first)") + return fmt.Errorf("no inheritance project found (run 'inheritance init' first)") } // Load project @@ -56,7 +56,7 @@ func runBundle(cmd *cobra.Command, args []string) error { // Check if sealed if p.Sealed == nil { - return fmt.Errorf("project must be sealed before generating bundles (run 'kaitiaki seal' first)") + return fmt.Errorf("project must be sealed before generating bundles (run 'inheritance seal' first)") } // Generate bundles diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go index f10fbe2..05fc18f 100644 --- a/internal/cmd/cmd_test.go +++ b/internal/cmd/cmd_test.go @@ -5,8 +5,8 @@ import ( "testing" "time" - "github.com/eljojo/rememory/internal/core" - "github.com/eljojo/rememory/internal/project" + "github.com/Bitcoin-Butlers/kaitiaki/internal/core" + "github.com/Bitcoin-Butlers/kaitiaki/internal/project" ) func TestFormatSize(t *testing.T) { diff --git a/internal/cmd/demo.go b/internal/cmd/demo.go index 95644d9..2a707f1 100644 --- a/internal/cmd/demo.go +++ b/internal/cmd/demo.go @@ -5,7 +5,7 @@ import ( "os" "path/filepath" - "github.com/eljojo/rememory/internal/project" + "github.com/Bitcoin-Butlers/kaitiaki/internal/project" "github.com/spf13/cobra" ) @@ -14,7 +14,7 @@ var demoCmd = &cobra.Command{ Short: "Create a demo project with sample data", Long: `Create a complete demo project with sample guardians and secret files. -This is useful for testing the recovery workflow or demonstrating Kaitiaki. +This is useful for testing the recovery workflow or demonstrating Bitcoin Inheritance. The demo project includes: - 5 guardians: Alice, Bob, Camila (Spanish), Dominique (French), Elias (German) @@ -24,8 +24,8 @@ The demo project includes: - Camila, Dominique, and Elias's bundles are in their language Example: - kaitiaki demo - kaitiaki demo my-demo-project`, + inheritance demo + inheritance demo my-demo-project`, Args: cobra.MaximumNArgs(1), RunE: runDemo, } @@ -84,7 +84,7 @@ func runDemo(cmd *cobra.Command, args []string) error { demoSecretContent := `# Demo Secret File -This is a demonstration of Kaitiaki's secret recovery system. +This is a demonstration of Bitcoin Inheritance's secret recovery system. In a real scenario, this file might contain: - Password manager recovery codes diff --git a/internal/cmd/doc.go b/internal/cmd/doc.go index 651cd43..630b1fb 100644 --- a/internal/cmd/doc.go +++ b/internal/cmd/doc.go @@ -28,7 +28,7 @@ func runDoc(cmd *cobra.Command, args []string) error { switch docFormat { case "man": header := &doc.GenManHeader{ - Title: "KAITIAKI", + Title: "INHERITANCE", Section: "1", } if err := doc.GenManTree(rootCmd, header, outputDir); err != nil { diff --git a/internal/cmd/html.go b/internal/cmd/html.go index 4903e90..0a9242d 100644 --- a/internal/cmd/html.go +++ b/internal/cmd/html.go @@ -5,7 +5,7 @@ import ( "os" "path/filepath" - "github.com/eljojo/rememory/internal/html" + "github.com/Bitcoin-Butlers/kaitiaki/internal/html" "github.com/spf13/cobra" ) @@ -25,11 +25,11 @@ The create and recover HTML files are self-contained with embedded WASM binary, JavaScript, and CSS. They work fully offline. Examples: - kaitiaki html about > about.html - kaitiaki html create > maker.html - kaitiaki html docs > docs.html - kaitiaki html recover > recover.html - kaitiaki html site -o dist/`, + inheritance html about > about.html + inheritance html create > maker.html + inheritance html docs > docs.html + inheritance html recover > recover.html + inheritance html site -o dist/`, Args: cobra.ExactArgs(1), RunE: runHTML, } @@ -77,11 +77,14 @@ func runHTML(cmd *cobra.Command, args []string) error { } content = html.GenerateMakerHTML(createWASM, html.MakerHTMLOptions{}) + case "descriptor": + content = html.GenerateDescriptorHTML(false) + case "site": return runHTMLSite(cmd) default: - return fmt.Errorf("unknown subcommand: %s (use 'about', 'create', 'docs', 'recover', or 'site')", subcommand) + return fmt.Errorf("unknown subcommand: %s (use 'about', 'create', 'descriptor', 'docs', 'recover', or 'site')", subcommand) } // Output to file or stdout @@ -127,6 +130,7 @@ func runHTMLSite(cmd *cobra.Command) error { {"index.html", aboutHTML}, // Copy for GitHub Pages root URL {"maker.html", html.GenerateMakerHTML(createWASM, html.MakerHTMLOptions{})}, {"docs.html", html.GenerateDocsHTML("en", false)}, + {"descriptor.html", html.GenerateDescriptorHTML(false)}, {"recover.html", html.GenerateRecoverHTML(nil, html.RecoverHTMLOptions{NoTlock: noTlock})}, } diff --git a/internal/cmd/init.go b/internal/cmd/init.go index 06864bf..0395693 100644 --- a/internal/cmd/init.go +++ b/internal/cmd/init.go @@ -8,23 +8,23 @@ import ( "strconv" "strings" - "github.com/eljojo/rememory/internal/project" - "github.com/eljojo/rememory/internal/translations" + "github.com/Bitcoin-Butlers/kaitiaki/internal/project" + "github.com/Bitcoin-Butlers/kaitiaki/internal/translations" "github.com/spf13/cobra" ) var initCmd = &cobra.Command{ Use: "init [name]", - Short: "Create a new kaitiaki project", - Long: `Create a new kaitiaki project with a manifest directory and configuration. + Short: "Create a new inheritance project", + Long: `Create a new inheritance project with a manifest directory and configuration. The project will contain: - project.yml: Configuration with friends' contact information - manifest/: Directory for your secret files Example: - kaitiaki init my-recovery-2026 - kaitiaki init my-recovery --from ../old-project`, + inheritance init my-recovery-2026 + inheritance init my-recovery --from ../old-project`, Args: cobra.MaximumNArgs(1), RunE: runInit, } @@ -34,9 +34,7 @@ var ( initName string initThreshold int initFriends []string - initAnonymous bool initHideQuorum bool - initShares int initLanguage string ) @@ -53,9 +51,7 @@ func init() { initCmd.Flags().StringVar(&initName, "name", "", "Project name (defaults to directory name)") initCmd.Flags().IntVar(&initThreshold, "threshold", 0, "Number of shares needed to recover") initCmd.Flags().StringArrayVar(&initFriends, "friend", nil, "Friend in format 'Name' or 'Name,contact info' (repeatable)") - initCmd.Flags().BoolVar(&initAnonymous, "anonymous", false, "Anonymous mode (no contact info for shareholders)") initCmd.Flags().BoolVar(&initHideQuorum, "hide-quorum", false, "Omit total/threshold from shares and bundle documents") - initCmd.Flags().IntVar(&initShares, "shares", 0, "Number of shares (for anonymous mode)") initCmd.Flags().StringVar(&initLanguage, "language", "", "Default bundle language (en)") } @@ -97,63 +93,12 @@ func runInit(cmd *cobra.Command, args []string) error { return fmt.Errorf("directory already exists: %s", dir) } - fmt.Printf("Creating new kaitiaki project: %s/\n\n", dirName) + fmt.Printf("Creating new inheritance project: %s/\n\n", dirName) var friends []project.Friend var threshold int - var anonymous bool - // Anonymous mode - if initAnonymous { - anonymous = true - reader := bufio.NewReader(os.Stdin) - - numShares := initShares - if numShares == 0 { - fmt.Print("How many shares? [5]: ") - numStr, _ := reader.ReadString('\n') - numStr = strings.TrimSpace(numStr) - numShares = 5 - if numStr != "" { - n, err := strconv.Atoi(numStr) - if err != nil || n < 2 { - return fmt.Errorf("invalid number of shares (minimum 2)") - } - numShares = n - } - } - - threshold = initThreshold - if threshold == 0 { - defaultThreshold := (numShares + 1) / 2 - if defaultThreshold < 2 { - defaultThreshold = 2 - } - fmt.Printf("How many shares needed to recover? [%d]: ", defaultThreshold) - threshStr, _ := reader.ReadString('\n') - threshStr = strings.TrimSpace(threshStr) - threshold = defaultThreshold - if threshStr != "" { - t, err := strconv.Atoi(threshStr) - if err != nil || t < 2 || t > numShares { - return fmt.Errorf("invalid threshold (must be 2-%d)", numShares) - } - threshold = t - } - } - - if threshold < 2 || threshold > numShares { - return fmt.Errorf("invalid threshold: must be between 2 and %d", numShares) - } - - // Generate synthetic friends - friends = make([]project.Friend, numShares) - for i := 0; i < numShares; i++ { - friends[i] = project.Friend{Name: fmt.Sprintf("Share %d", i+1)} - } - - fmt.Printf("\nAnonymous mode: %d shares, threshold %d of %d\n\n", numShares, threshold, numShares) - } else if len(initFriends) > 0 { + if len(initFriends) > 0 { // Non-interactive mode: use flags friends, err = parseFriendFlags(initFriends) if err != nil { @@ -255,7 +200,7 @@ func runInit(cmd *cobra.Command, args []string) error { } // Create the project - p, err := project.NewWithOptions(dir, name, threshold, friends, anonymous) + p, err := project.New(dir, name, threshold, friends) if err != nil { return fmt.Errorf("creating project: %w", err) } @@ -285,7 +230,7 @@ func runInit(cmd *cobra.Command, args []string) error { fmt.Printf(" - project.yml (edit to update friends)\n") fmt.Printf(" - manifest/README.md (add your secrets here)\n") fmt.Println() - fmt.Println("Next: Add files to manifest/, then run `kaitiaki seal`") + fmt.Println("Next: Add files to manifest/, then run `inheritance seal`") return nil } diff --git a/internal/cmd/pages.go b/internal/cmd/pages.go index 6c5c9d2..0b46038 100644 --- a/internal/cmd/pages.go +++ b/internal/cmd/pages.go @@ -4,8 +4,8 @@ import ( "fmt" "os" - "github.com/eljojo/rememory/internal/html" - "github.com/eljojo/rememory/internal/project" + "github.com/Bitcoin-Butlers/kaitiaki/internal/html" + "github.com/Bitcoin-Butlers/kaitiaki/internal/project" ) // generatePages creates output/pages/ with recover.html and MANIFEST.age for static hosting. diff --git a/internal/cmd/recover.go b/internal/cmd/recover.go index 86e1380..1e4c7fd 100644 --- a/internal/cmd/recover.go +++ b/internal/cmd/recover.go @@ -8,10 +8,10 @@ import ( "strings" "time" - "github.com/eljojo/rememory/internal/bundle" - "github.com/eljojo/rememory/internal/core" - "github.com/eljojo/rememory/internal/html" - "github.com/eljojo/rememory/internal/manifest" + "github.com/Bitcoin-Butlers/kaitiaki/internal/bundle" + "github.com/Bitcoin-Butlers/kaitiaki/internal/core" + "github.com/Bitcoin-Butlers/kaitiaki/internal/html" + "github.com/Bitcoin-Butlers/kaitiaki/internal/manifest" "github.com/spf13/cobra" ) @@ -28,9 +28,9 @@ recover.html files. The manifest is extracted from the first ZIP or HTML that contains one, unless --manifest is set. Example: - kaitiaki recover bundle-alice.zip bundle-bob.zip - kaitiaki recover alice/recover.html bob/recover.html carol/recover.html - kaitiaki recover SHARE-alice.txt SHARE-bob.txt -m MANIFEST.age`, + inheritance recover bundle-alice.zip bundle-bob.zip + inheritance recover alice/recover.html bob/recover.html carol/recover.html + inheritance recover SHARE-alice.txt SHARE-bob.txt -m MANIFEST.age`, Args: cobra.MinimumNArgs(1), RunE: runRecover, } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 6c3d48b..696315d 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -13,14 +13,14 @@ var version = "dev" var buildDate = "" var rootCmd = &cobra.Command{ - Use: "kaitiaki", + Use: "inheritance", Short: "A digital safe with multiple keys, held by people you trust", - Long: `Kaitiaki is a digital safe with multiple keys. It encrypts your files with age, + Long: `Bitcoin Inheritance is a digital safe with multiple keys. It encrypts your files with age, splits the key using Shamir's Secret Sharing, and creates recovery bundles for each person. -Create a project: kaitiaki init my-recovery -Seal the manifest: kaitiaki seal -Recover from shares: kaitiaki recover bundle-alice.zip bundle-bob.zip`, +Create a project: inheritance init my-recovery +Seal the manifest: inheritance seal +Recover from shares: inheritance recover bundle-alice.zip bundle-bob.zip`, } func Execute(v, bd string) error { @@ -47,7 +47,7 @@ func checkBuildAge() { return } fmt.Fprintf(os.Stderr, "\n%s You're running version %s, from %s.\n", yellow("A newer version may be available."), version, buildDate) - fmt.Fprintf(os.Stderr, " Check https://github.com/eljojo/rememory/releases/latest\n\n") + fmt.Fprintf(os.Stderr, " Check https://github.com/Bitcoin-Butlers/kaitiaki/releases/latest\n\n") } // Color helpers (ANSI escape codes) diff --git a/internal/cmd/seal.go b/internal/cmd/seal.go index 996c358..1513cac 100644 --- a/internal/cmd/seal.go +++ b/internal/cmd/seal.go @@ -8,11 +8,11 @@ import ( "path/filepath" "time" - "github.com/eljojo/rememory/internal/bundle" - "github.com/eljojo/rememory/internal/core" - "github.com/eljojo/rememory/internal/crypto" - "github.com/eljojo/rememory/internal/manifest" - "github.com/eljojo/rememory/internal/project" + "github.com/Bitcoin-Butlers/kaitiaki/internal/bundle" + "github.com/Bitcoin-Butlers/kaitiaki/internal/core" + "github.com/Bitcoin-Butlers/kaitiaki/internal/crypto" + "github.com/Bitcoin-Butlers/kaitiaki/internal/manifest" + "github.com/Bitcoin-Butlers/kaitiaki/internal/project" "github.com/spf13/cobra" ) @@ -30,7 +30,7 @@ This command: 5. Generates ZIP bundles for distribution 6. Writes checksums to project.yml -Run this command inside a project directory (created with 'kaitiaki init').`, +Run this command inside a project directory (created with 'inheritance init').`, RunE: runSeal, } @@ -126,16 +126,37 @@ func sealProject(p *project.Project, recoveryURL string, noEmbedManifest bool, t } } + // The owner's own texts go INSIDE the archive, beside their files, so a + // reader meets them only after enough guardians combine their pieces. + // They are built in memory and handed to the archive: writing them into + // manifest/ would overwrite an edit the owner made there by hand, and + // would leave their words lying in plaintext on disk after every seal. + // See internal/bundle/sealed_texts.go. + sealedFiles := bundle.SealedFiles(bundle.SealedTexts{ + RecoverySteps: p.RecoverySteps, + ChainPayload: p.ChainPayload, + ChainTxid: p.ChainTxid, + }, p.Language, time.Now()) + + extras := make([]manifest.ExtraFile, 0, len(sealedFiles)) + for _, f := range sealedFiles { + extras = append(extras, manifest.ExtraFile{Name: f.Name, Content: f.Content}) + fileCount++ + } + dirSize, err := manifest.DirSize(manifestDir) if err != nil { return fmt.Errorf("calculating manifest size: %w", err) } + for _, e := range extras { + dirSize += int64(len(e.Content)) + } fmt.Printf("Archiving manifest/ (%d files, %s)...\n", fileCount, formatSize(dirSize)) // Archive the manifest directory var archiveBuf bytes.Buffer - archiveResult, err := manifest.ArchiveZip(&archiveBuf, manifestDir) + archiveResult, err := manifest.ArchiveZip(&archiveBuf, manifestDir, extras...) if err != nil { return fmt.Errorf("archiving manifest: %w", err) } diff --git a/internal/cmd/serve.go b/internal/cmd/serve.go index ad75689..7934408 100644 --- a/internal/cmd/serve.go +++ b/internal/cmd/serve.go @@ -6,15 +6,15 @@ import ( "strconv" "strings" - "github.com/eljojo/rememory/internal/html" - "github.com/eljojo/rememory/internal/serve" + "github.com/Bitcoin-Butlers/kaitiaki/internal/html" + "github.com/Bitcoin-Butlers/kaitiaki/internal/serve" "github.com/spf13/cobra" ) var serveCmd = &cobra.Command{ Use: "serve", - Short: "Start a self-hosted Kaitiaki server", - Long: `Start a self-hosted Kaitiaki web server for creating and recovering bundles. + Short: "Start a self-hosted Bitcoin Inheritance server", + Long: `Start a self-hosted Bitcoin Inheritance web server for creating and recovering bundles. The server provides a web interface for bundle creation (using WASM) and recovery (using native JavaScript crypto). An admin password protects @@ -25,16 +25,16 @@ The first visit prompts for an admin password. After that: - If a manifest exists, the recover page is shown Examples: - kaitiaki serve - kaitiaki serve --port 3000 --data /var/lib/kaitiaki - kaitiaki serve --max-manifest-size 100MB`, + inheritance serve + inheritance serve --port 3000 --data /var/lib/inheritance + inheritance serve --max-manifest-size 100MB`, RunE: runServe, } func init() { serveCmd.Flags().StringP("port", "p", "8080", "Port to listen on") serveCmd.Flags().String("host", "127.0.0.1", "Host to bind to") - serveCmd.Flags().StringP("data", "d", "./kaitiaki-data", "Data directory for storing bundles and config") + serveCmd.Flags().StringP("data", "d", "./inheritance-data", "Data directory for storing bundles and config") serveCmd.Flags().String("max-manifest-size", "50MB", "Maximum MANIFEST.age size (e.g. 50MB, 1GB)") rootCmd.AddCommand(serveCmd) } @@ -54,10 +54,10 @@ func flagOrEnv(cmd *cobra.Command, flagName, envName string) string { } func runServe(cmd *cobra.Command, args []string) error { - port := flagOrEnv(cmd, "port", "REMEMORY_PORT") - host := flagOrEnv(cmd, "host", "REMEMORY_HOST") - dataDir := flagOrEnv(cmd, "data", "REMEMORY_DATA") - maxSizeStr := flagOrEnv(cmd, "max-manifest-size", "REMEMORY_MAX_MANIFEST_SIZE") + port := flagOrEnv(cmd, "port", "INHERITANCE_PORT") + host := flagOrEnv(cmd, "host", "INHERITANCE_HOST") + dataDir := flagOrEnv(cmd, "data", "INHERITANCE_DATA") + maxSizeStr := flagOrEnv(cmd, "max-manifest-size", "INHERITANCE_MAX_MANIFEST_SIZE") maxSize, err := parseSize(maxSizeStr) if err != nil { @@ -82,7 +82,7 @@ func runServe(cmd *cobra.Command, args []string) error { } addr := host + ":" + port - fmt.Printf("Kaitiaki server listening on http://%s\n", addr) + fmt.Printf("Bitcoin Inheritance server listening on http://%s\n", addr) fmt.Printf("Data directory: %s\n", dataDir) return srv.ListenAndServe(addr) } diff --git a/internal/cmd/status.go b/internal/cmd/status.go index 9ccc04c..c9bc950 100644 --- a/internal/cmd/status.go +++ b/internal/cmd/status.go @@ -6,15 +6,15 @@ import ( "path/filepath" "time" - "github.com/eljojo/rememory/internal/core" - "github.com/eljojo/rememory/internal/project" + "github.com/Bitcoin-Butlers/kaitiaki/internal/core" + "github.com/Bitcoin-Butlers/kaitiaki/internal/project" "github.com/spf13/cobra" ) var statusCmd = &cobra.Command{ Use: "status", Short: "Show project status and summary", - Long: `Displays the current state of the kaitiaki project including seal status, friends, and bundle information.`, + Long: `Displays the current state of the inheritance project including seal status, friends, and bundle information.`, RunE: runStatus, } @@ -31,7 +31,7 @@ func runStatus(cmd *cobra.Command, args []string) error { projectDir, err := project.FindProjectDir(cwd) if err != nil { - return fmt.Errorf("no kaitiaki project found (run 'kaitiaki init' first)") + return fmt.Errorf("no inheritance project found (run 'inheritance init' first)") } // Load project @@ -50,7 +50,7 @@ func runStatus(cmd *cobra.Command, args []string) error { fmt.Printf("Manifest Checksum: %s\n", truncateHash(p.Sealed.ManifestChecksum)) } else { fmt.Printf("Sealed: %s\n", yellow("No")) - fmt.Println(" Run 'kaitiaki seal' to encrypt and split the passphrase") + fmt.Println(" Run 'inheritance seal' to encrypt and split the passphrase") } // Threshold @@ -79,7 +79,7 @@ func runStatus(cmd *cobra.Command, args []string) error { fmt.Printf("Bundles: %s (%d bundles in %s)\n", green("Generated"), bundleCount, bundlesDir) } else if p.Sealed != nil { fmt.Printf("Bundles: %s\n", yellow("Not yet generated")) - fmt.Println(" Run 'kaitiaki bundle' to create distribution bundles") + fmt.Println(" Run 'inheritance bundle' to create distribution bundles") } else { fmt.Printf("Bundles: %s (seal first)\n", yellow("Not available")) } diff --git a/internal/cmd/verify.go b/internal/cmd/verify.go index db3160c..82e5bd3 100644 --- a/internal/cmd/verify.go +++ b/internal/cmd/verify.go @@ -5,8 +5,8 @@ import ( "os" "path/filepath" - "github.com/eljojo/rememory/internal/crypto" - "github.com/eljojo/rememory/internal/project" + "github.com/Bitcoin-Butlers/kaitiaki/internal/crypto" + "github.com/Bitcoin-Butlers/kaitiaki/internal/project" "github.com/spf13/cobra" ) @@ -46,7 +46,7 @@ func runVerify(cmd *cobra.Command, args []string) error { } if p.Sealed == nil { - return fmt.Errorf("project has not been sealed yet; run 'kaitiaki seal' first") + return fmt.Errorf("project has not been sealed yet; run 'inheritance seal' first") } allOK := true diff --git a/internal/cmd/verify_bundle.go b/internal/cmd/verify_bundle.go index 70681c4..6d9ba94 100644 --- a/internal/cmd/verify_bundle.go +++ b/internal/cmd/verify_bundle.go @@ -3,7 +3,7 @@ package cmd import ( "fmt" - "github.com/eljojo/rememory/internal/bundle" + "github.com/Bitcoin-Butlers/kaitiaki/internal/bundle" "github.com/spf13/cobra" ) diff --git a/internal/core/core_test.go b/internal/core/core_test.go index 7b97b70..eb85dbe 100644 --- a/internal/core/core_test.go +++ b/internal/core/core_test.go @@ -315,16 +315,16 @@ func TestCompactEncodeFormat(t *testing.T) { share := NewShare(1, 2, 5, 3, "Bob", []byte{0xDE, 0xAD, 0xBE, 0xEF}) compact := share.CompactEncode() - if !strings.HasPrefix(compact, "RM1:") { - t.Errorf("should start with RM1:, got %q", compact) + if !strings.HasPrefix(compact, "IH1:") { + t.Errorf("should start with IH1:, got %q", compact) } parts := strings.Split(compact, ":") if len(parts) != 6 { t.Fatalf("expected 6 parts, got %d: %q", len(parts), compact) } - if parts[0] != "RM1" { - t.Errorf("version prefix: got %q, want RM1", parts[0]) + if parts[0] != "IH1" { + t.Errorf("version prefix: got %q, want IH1", parts[0]) } if parts[1] != "2" { t.Errorf("index: got %q, want 2", parts[1]) diff --git a/internal/core/golden_test.go b/internal/core/golden_test.go index 2eb320e..8508bf5 100644 --- a/internal/core/golden_test.go +++ b/internal/core/golden_test.go @@ -328,14 +328,25 @@ func TestGoldenShareParsing(t *testing.T) { t.Errorf("Verify: %v", err) } + // The fixtures carry the old markers on purpose: they are + // the proof that a share written before 2026-09-24 still + // parses. Encode writes the current markers, so compare + // against the fixture with only that line renamed. Every + // other byte must still match exactly. + wantPEM := strings.ReplaceAll(gs.PEM, "-----BEGIN REMEMORY SHARE-----", ShareBegin) + wantPEM = strings.ReplaceAll(wantPEM, "-----END REMEMORY SHARE-----", ShareEnd) reEncoded := share.Encode() - if reEncoded != gs.PEM { - t.Errorf("PEM re-encode mismatch:\ngot:\n%s\nwant:\n%s", reEncoded, gs.PEM) + if reEncoded != wantPEM { + t.Errorf("PEM re-encode mismatch:\ngot:\n%s\nwant:\n%s", reEncoded, wantPEM) } + // Same rule as the PEM markers: the fixture keeps the old + // prefix as the legacy-parse proof, Encode writes the + // current one, every other byte must match. + wantCompact := strings.Replace(gs.Compact, "RM", CompactPrefix, 1) compact := share.CompactEncode() - if compact != gs.Compact { - t.Errorf("compact: got %q, want %q", compact, gs.Compact) + if compact != wantCompact { + t.Errorf("compact: got %q, want %q", compact, wantCompact) } decoded, err := ParseCompact(compact) diff --git a/internal/core/offline_test.go b/internal/core/offline_test.go index 60f9e4f..7102857 100644 --- a/internal/core/offline_test.go +++ b/internal/core/offline_test.go @@ -27,10 +27,10 @@ func drandHosts() map[string]bool { // even from libraries that create their own http.Client (like drand-client). // Same principle as the Playwright offline-by-default fixture in e2e/fixtures.ts. // -// When REMEMORY_TEST_TLOCK=1, only drand endpoints are allowed. +// When INHERITANCE_TEST_TLOCK=1, only drand endpoints are allowed. func TestMain(m *testing.M) { allowed := map[string]bool{} - if os.Getenv("REMEMORY_TEST_TLOCK") == "1" { + if os.Getenv("INHERITANCE_TEST_TLOCK") == "1" { allowed = drandHosts() } diff --git a/internal/core/share.go b/internal/core/share.go index 41b63d2..347c3c8 100644 --- a/internal/core/share.go +++ b/internal/core/share.go @@ -14,12 +14,26 @@ import ( ) const ( - ShareBegin = "-----BEGIN REMEMORY SHARE-----" - ShareEnd = "-----END REMEMORY SHARE-----" + // What Encode writes. Changed 2026-09-24 when the upstream name left + // the project. + ShareBegin = "-----BEGIN INHERITANCE SHARE-----" + ShareEnd = "-----END INHERITANCE SHARE-----" + + // What ParseShare also accepts. A share is a file somebody may have + // been holding for years, so the old markers keep working forever. + // Never remove these: a guardian's README does not get reissued + // because we renamed something. + legacyShareBegin = "-----BEGIN REMEMORY SHARE-----" + legacyShareEnd = "-----END REMEMORY SHARE-----" + + // The compact form printed under every QR code. Same rule as the + // markers: write the current one, read both forever. + CompactPrefix = "IH" + legacyCompactPrefix = "RM" // DefaultRecoveryURL is the default base URL for QR codes in PDFs. // Points to the recover.html hosted on bitcoinbutlers.com. - DefaultRecoveryURL = "https://www.bitcoinbutlers.com/tools/kaitiaki/recover.html" + DefaultRecoveryURL = "https://www.bitcoinbutlers.com/tools/inheritance/recover.html" ) // Share represents a single Shamir share with metadata. @@ -98,14 +112,18 @@ func ParseShare(content []byte) (*Share, error) { text := string(content) // Find the PEM block - beginIdx := strings.Index(text, ShareBegin) - endIdx := strings.Index(text, ShareEnd) + begin, end := ShareBegin, ShareEnd + if !strings.Contains(text, begin) && strings.Contains(text, legacyShareBegin) { + begin, end = legacyShareBegin, legacyShareEnd + } + beginIdx := strings.Index(text, begin) + endIdx := strings.Index(text, end) if beginIdx == -1 || endIdx == -1 || endIdx <= beginIdx { return nil, fmt.Errorf("invalid share format: missing BEGIN/END markers") } // Extract content between markers - inner := text[beginIdx+len(ShareBegin) : endIdx] + inner := text[beginIdx+len(begin) : endIdx] lines := strings.Split(strings.TrimSpace(inner), "\n") share := &Share{} @@ -219,7 +237,7 @@ func (s *Share) Verify() error { func (s *Share) CompactEncode() string { data := base64.RawURLEncoding.EncodeToString(s.Data) check := shortChecksum(s.Data) - return fmt.Sprintf("RM%d:%d:%d:%d:%s:%s", s.Version, s.Index, s.Total, s.Threshold, data, check) + return fmt.Sprintf("%s%d:%d:%d:%d:%s:%s", CompactPrefix, s.Version, s.Index, s.Total, s.Threshold, data, check) } // ParseCompact parses a compact-encoded share string back into a Share. @@ -231,8 +249,8 @@ func ParseCompact(s string) (*Share, error) { } prefix := parts[0] - if !strings.HasPrefix(prefix, "RM") { - return nil, fmt.Errorf("invalid compact share: must start with 'RM', got %q", prefix) + if !strings.HasPrefix(prefix, CompactPrefix) && !strings.HasPrefix(prefix, legacyCompactPrefix) { + return nil, fmt.Errorf("invalid compact share: must start with %q or %q, got %q", CompactPrefix, legacyCompactPrefix, prefix) } version, err := strconv.Atoi(prefix[2:]) diff --git a/internal/core/share_markers_test.go b/internal/core/share_markers_test.go new file mode 100644 index 0000000..1cde8c3 --- /dev/null +++ b/internal/core/share_markers_test.go @@ -0,0 +1,62 @@ +package core + +import ( + "bytes" + "strings" + "testing" +) + +// A guardian's README is not reissued because we renamed something. A share +// written with the pre-2026-09-24 markers must parse forever. +func TestALegacyShareStillParses(t *testing.T) { + cur := NewShare(2, 1, 3, 2, "Alice", []byte{1, 2, 3, 4, 5, 6, 7, 8}) + legacy := strings.ReplaceAll(cur.Encode(), ShareBegin, "-----BEGIN REMEMORY SHARE-----") + legacy = strings.ReplaceAll(legacy, ShareEnd, "-----END REMEMORY SHARE-----") + + if strings.Contains(legacy, "INHERITANCE SHARE") { + t.Fatal("the fixture was not converted, so this test proves nothing") + } + + got, err := ParseShare([]byte(legacy)) + if err != nil { + t.Fatalf("a share with the old markers must still parse: %v", err) + } + if got.Holder != "Alice" || !bytes.Equal(got.Data, cur.Data) { + t.Errorf("legacy parse lost content: holder %q, data %x", got.Holder, got.Data) + } + + // And re-encoding it brings it up to the current markers. + if !strings.Contains(got.Encode(), ShareBegin) { + t.Error("re-encoding a legacy share must write the current markers") + } +} + +// What a guardian actually reads must not name the project we forked from. +func TestTheCurrentMarkerNamesThisProject(t *testing.T) { + enc := NewShare(2, 1, 3, 2, "Alice", []byte{1, 2, 3, 4}).Encode() + if strings.Contains(strings.ToUpper(enc), "REMEMORY") { + t.Error("a freshly written share must not carry the upstream name") + } + if !strings.Contains(enc, "-----BEGIN INHERITANCE SHARE-----") { + t.Error("a freshly written share must carry the current marker") + } +} + +// The compact form is printed under the QR code on every guardian's PDF, so +// it is read by a person and must not name the upstream project either. +func TestTheCompactPrefixNamesThisProject(t *testing.T) { + s := NewShare(2, 1, 3, 2, "Alice", []byte{1, 2, 3, 4}) + compact := s.CompactEncode() + if !strings.HasPrefix(compact, "IH") { + t.Errorf("a freshly written compact share must start with IH, got %q", compact) + } + + legacy := "RM" + strings.TrimPrefix(compact, "IH") + got, err := ParseCompact(legacy) + if err != nil { + t.Fatalf("a compact share with the old prefix must still parse: %v", err) + } + if !bytes.Equal(got.Data, s.Data) { + t.Error("legacy compact parse lost the share data") + } +} diff --git a/internal/core/sharefmt.go b/internal/core/sharefmt.go new file mode 100644 index 0000000..8481692 --- /dev/null +++ b/internal/core/sharefmt.go @@ -0,0 +1,59 @@ +package core + +import "fmt" + +// ShareFormatTS is the TypeScript source of the share-format constants, +// generated from the Go constants above so the browser and the command line +// cannot disagree about the format. +// +// Before 2026-09-24 these values were typed out three times: here, in +// crypto/share.ts, and again as a regex in app.ts. Renaming them moved two +// copies and left the third, and the browser silently stopped reading a +// pasted README. Nothing failed until a paste came back empty. +// +// Regenerate with: go test ./internal/core/ -run ShareFormat -generate +func ShareFormatTS() string { + return fmt.Sprintf(`// Code generated from internal/core/share.go. DO NOT EDIT. +// +// Regenerate with: +// go test ./internal/core/ -run ShareFormat -generate +// +// The share markers and the compact prefix are one format with two readers, +// Go and this file. They are generated so the two cannot drift. +// +// The legacy values are read forever: a guardian's README is not reissued +// because the project was renamed. + +export const PEM_BEGIN = %q; +export const PEM_END = %q; +export const LEGACY_PEM_BEGIN = %q; +export const LEGACY_PEM_END = %q; + +export const COMPACT_PREFIX = %q; +export const LEGACY_COMPACT_PREFIX = %q; + +/** Matches either spelling of the PEM block, capturing its contents. */ +export const SHARE_BLOCK_REGEX = + /-----BEGIN (?:%s|%s) SHARE-----([\s\S]*?)-----END (?:%s|%s) SHARE-----/; + +/** Matches either compact prefix. */ +export const COMPACT_REGEX = + /^(?:%s|%s)(\d+):(\d+):(\d+):(\d+):([A-Za-z0-9_-]+):([0-9a-f]{4})$/; + +/** Matches a whole compact share line, for sniffing pasted text. */ +export const COMPACT_LINE_REGEX = + /^(?:%s|%s)\d+:\d+:\d+:\d+:[A-Za-z0-9_-]+:[0-9a-f]{4}$/; +`, + ShareBegin, ShareEnd, legacyShareBegin, legacyShareEnd, + CompactPrefix, legacyCompactPrefix, + shareWord, legacyShareWord, shareWord, legacyShareWord, + CompactPrefix, legacyCompactPrefix, + CompactPrefix, legacyCompactPrefix, + ) +} + +// The bare words inside the PEM markers, used to build the browser's regex. +const ( + shareWord = "INHERITANCE" + legacyShareWord = "REMEMORY" +) diff --git a/internal/core/sharefmt_test.go b/internal/core/sharefmt_test.go new file mode 100644 index 0000000..31943d7 --- /dev/null +++ b/internal/core/sharefmt_test.go @@ -0,0 +1,46 @@ +package core + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// The generated file must be current. This replaced a guardrail that read the +// TypeScript looking for the right strings: that one only shouted after +// somebody had already typed the wrong thing, and it passed once while half +// of one regex was broken. There is nothing to type now, so there is one +// question left, and this asks it. +func TestShareFormatTSIsCurrent(t *testing.T) { + path := filepath.Join("..", "html", "assets", "src", "crypto", "share-format.ts") + want := ShareFormatTS() + + if *generate { + if err := os.WriteFile(path, []byte(want), 0644); err != nil { + t.Fatalf("writing %s: %v", path, err) + } + t.Logf("regenerated %s", path) + return + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading %s: %v (run: go test ./internal/core/ -run ShareFormat -generate)", path, err) + } + if string(got) != want { + t.Errorf("%s is stale. Regenerate with:\n go test ./internal/core/ -run ShareFormat -generate", + filepath.Base(path)) + } +} + +// And the markers a guardian reads must not name the project we forked from. +func TestTheGeneratedFormatNamesThisProject(t *testing.T) { + ts := ShareFormatTS() + if !strings.Contains(ts, "BEGIN INHERITANCE SHARE") { + t.Error("the generated format must write our own marker") + } + if !strings.Contains(ts, "REMEMORY") { + t.Error("the generated format must still READ the old marker") + } +} diff --git a/internal/core/tlock_test.go b/internal/core/tlock_test.go index f570a0b..962f24d 100644 --- a/internal/core/tlock_test.go +++ b/internal/core/tlock_test.go @@ -243,8 +243,8 @@ func TestParseTimelockValue(t *testing.T) { } func TestTlockEncryptDecryptIntegration(t *testing.T) { - if os.Getenv("REMEMORY_TEST_TLOCK") != "1" { - t.Skip("set REMEMORY_TEST_TLOCK=1 to run tlock integration tests (requires internet)") + if os.Getenv("INHERITANCE_TEST_TLOCK") != "1" { + t.Skip("set INHERITANCE_TEST_TLOCK=1 to run tlock integration tests (requires internet)") } plaintext := []byte("the secret message for tlock integration test") @@ -274,8 +274,8 @@ func TestTlockEncryptDecryptIntegration(t *testing.T) { } func TestTlockFutureRoundCannotDecrypt(t *testing.T) { - if os.Getenv("REMEMORY_TEST_TLOCK") != "1" { - t.Skip("set REMEMORY_TEST_TLOCK=1 to run tlock integration tests (requires internet)") + if os.Getenv("INHERITANCE_TEST_TLOCK") != "1" { + t.Skip("set INHERITANCE_TEST_TLOCK=1 to run tlock integration tests (requires internet)") } plaintext := []byte("this should not be decryptable yet") @@ -351,8 +351,8 @@ func TestQuicknetConstantsMatchChainHash(t *testing.T) { // the real drand quicknet network. This catches the case where the drand // network has been re-keyed or our constants have drifted from reality. func TestQuicknetConstantsMatchNetwork(t *testing.T) { - if os.Getenv("REMEMORY_TEST_TLOCK") != "1" { - t.Skip("set REMEMORY_TEST_TLOCK=1 to validate constants against the live drand network") + if os.Getenv("INHERITANCE_TEST_TLOCK") != "1" { + t.Skip("set INHERITANCE_TEST_TLOCK=1 to validate constants against the live drand network") } network, err := tlockhttp.NewNetwork(DrandEndpoints[0], QuicknetChainHash) @@ -384,8 +384,8 @@ func TestQuicknetConstantsMatchNetwork(t *testing.T) { // (using embedded constants, no HTTP) produces ciphertext that the network- // connected decryptor can successfully decrypt. func TestOfflineEncryptProducesValidCiphertext(t *testing.T) { - if os.Getenv("REMEMORY_TEST_TLOCK") != "1" { - t.Skip("set REMEMORY_TEST_TLOCK=1 to run tlock integration tests (requires internet)") + if os.Getenv("INHERITANCE_TEST_TLOCK") != "1" { + t.Skip("set INHERITANCE_TEST_TLOCK=1 to run tlock integration tests (requires internet)") } plaintext := []byte("offline encryption integration test") diff --git a/internal/core/urls.go b/internal/core/urls.go index 290069d..3eab8cf 100644 --- a/internal/core/urls.go +++ b/internal/core/urls.go @@ -1,9 +1,9 @@ package core // GitHubRepo is the canonical repository URL of this fork. -// Attribution links to the upstream project (eljojo/rememory) are written +// Upstream attribution lives in NOTICE, which Apache-2.0 requires. Pages are written // out in full where they appear; they do not go through this constant. const GitHubRepo = "https://github.com/Bitcoin-Butlers/kaitiaki" // GitHubPages is where the static pages and their screenshots are served. -const GitHubPages = "https://www.bitcoinbutlers.com/tools/kaitiaki" +const GitHubPages = "https://www.bitcoinbutlers.com/tools/inheritance" diff --git a/internal/crypto/crypto_test.go b/internal/crypto/crypto_test.go index cb0a441..ba7b81d 100644 --- a/internal/crypto/crypto_test.go +++ b/internal/crypto/crypto_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/eljojo/rememory/internal/core" + "github.com/Bitcoin-Butlers/kaitiaki/internal/core" ) func TestGeneratePassphrase(t *testing.T) { diff --git a/internal/descriptorbackup/CONTEXT.md b/internal/descriptorbackup/CONTEXT.md new file mode 100644 index 0000000..ac3b173 --- /dev/null +++ b/internal/descriptorbackup/CONTEXT.md @@ -0,0 +1,65 @@ +# descriptorbackup + +The words this package, its tests, the tickets and Ben all use for the same +thing. Use these and no synonym. + +## The two formats + +**Threshold format.** Any **k of n** of the wallet's extended public keys +rebuild the descriptor. A port of joshdoman/multisig-backup, kept byte for +byte compatible on purpose, so a client can paste our text into +multisigbackup.com and recover with no Bitcoin Butlers software in the path. +Only the ENCRYPT path lives here. The heir-side recover page stays in +TypeScript, so a grieving heir never loads the maker's WASM to read a backup. + +**BIP-138.** **Any one** key opens it. Carries the descriptor as a BIP-380 +content item, and the owner's recovery steps beside it as a `0x03` String item. +Say the one-key difference out loud in a placement session, because it changes +who can read the backup. + +The page picks the format. Recovery steps present means BIP-138. The owner is +never asked to understand both. + +## Terms + +- **the chain** — the blockchain, in prose and in the UI. Never "blockchain". +- **descriptor backup** — the artifact this package makes. Never "session". +- **stripped descriptor** — the readable text in front of the threshold + format's ciphertext: script type, threshold and derivation paths. A + recovering heir needs them to know which keys to derive. +- **lookup tags** — four bytes per unordered pair of master fingerprints, so a + scanner can find a backup from any two fingerprints. They reveal nothing + without the keys. +- **individual secret** — one 32-byte entry per key in a BIP-138 backup, + `xor(shared secret, taggedHash("BIP138_INDIVIDUAL_SECRET", key))`. It is a + derived secret and never a key. +- **entry bucket** — the count a BIP-138 entry list is padded to: 5, 10 or 20. + A 2-of-3 pads to five, a 3-of-7 to ten. Padding stops an onlooker counting + the cosigners. +- **decoy** — a random 32-byte entry added to reach the bucket. + +## Quirks that are not bugs + +Two pieces of this look wrong and must not be "cleaned up" without a new +published vector, because the same logic runs on multisigbackup.com and our +bytes have to match it. + +- `NumberToBytes(0)` returns NO bytes, so share index 0 contributes nothing to + its key material. +- `sortsBefore` compares JavaScript's string form of a byte array, so + `[2,0,0,0]` sorts AFTER `[10,0,0,0]`. That is not byte order, and byte order + would put the lookup tags in a different order than the fallback tool. + +## Tests + +- `go test ./internal/descriptorbackup/` runs the BIP's own vectors and the + published mainnet vector. +- `make test-xlang` proves Go and TypeScript open each other's backups. Run it + after ANY change here. The browser writes with the Go code compiled to WASM + and the heir-side page reads with the TypeScript, so a disagreement makes a + client's backup unreadable by the page we point their heir at. + +The published vector cannot be reproduced byte for byte. Shamir picks random +x-coordinates and coefficients per run, so the share bytes differ every time. +Compare the stripped descriptor, the encrypted key block, the lookup tags and +the total length, which is what the TypeScript test does too. diff --git a/internal/descriptorbackup/base58.go b/internal/descriptorbackup/base58.go new file mode 100644 index 0000000..4979dae --- /dev/null +++ b/internal/descriptorbackup/base58.go @@ -0,0 +1,59 @@ +package descriptorbackup + +import ( + "bytes" + "crypto/sha256" + "errors" + "math/big" +) + +const base58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + +var base58Index = func() map[byte]int { + m := make(map[byte]int, len(base58Alphabet)) + for i := 0; i < len(base58Alphabet); i++ { + m[base58Alphabet[i]] = i + } + return m +}() + +// base58Decode turns a base58 string into bytes, keeping leading zero bytes +// for every leading '1', the way Bitcoin's encoding requires. +func base58Decode(s string) ([]byte, error) { + n := new(big.Int) + radix := big.NewInt(58) + for i := 0; i < len(s); i++ { + v, ok := base58Index[s[i]] + if !ok { + return nil, errors.New("this is not valid base58 text") + } + n.Mul(n, radix) + n.Add(n, big.NewInt(int64(v))) + } + body := n.Bytes() + zeros := 0 + for zeros < len(s) && s[zeros] == '1' { + zeros++ + } + out := make([]byte, zeros+len(body)) + copy(out[zeros:], body) + return out, nil +} + +// base58CheckDecode strips and verifies the four-byte double-SHA256 checksum. +func base58CheckDecode(s string) ([]byte, error) { + raw, err := base58Decode(s) + if err != nil { + return nil, err + } + if len(raw) < 5 { + return nil, errors.New("this base58 text is too short to carry a checksum") + } + body, want := raw[:len(raw)-4], raw[len(raw)-4:] + first := sha256.Sum256(body) + second := sha256.Sum256(first[:]) + if !bytes.Equal(second[:4], want) { + return nil, errors.New("the checksum does not match, so this key is mistyped or damaged") + } + return body, nil +} diff --git a/internal/descriptorbackup/bip138.go b/internal/descriptorbackup/bip138.go new file mode 100644 index 0000000..9ad1a28 --- /dev/null +++ b/internal/descriptorbackup/bip138.go @@ -0,0 +1,819 @@ +// Package descriptorbackup encrypts a multisig descriptor so that the +// wallet's own keys unlock it, in the two formats Bitcoin Inheritance writes +// to the chain. +// +// This file is the Go port of internal/html/assets/src/crypto/bip138.ts. Any +// key opens a BIP-138 backup, where the threshold format in descriptor.go +// needs k of them. Say that out loud in the placement session, because it +// changes who can read the backup. +// +// Primitives come from the standard library and golang.org/x/crypto. This +// file writes plumbing only. +package descriptorbackup + +import ( + "bytes" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "regexp" + "sort" + + "golang.org/x/crypto/chacha20poly1305" +) + +// Magic is the ASCII text "BIP138" that opens every backup. +var Magic = []byte{0x42, 0x49, 0x50, 0x31, 0x33, 0x38} + +const ( + // Version is the only format version the BIP defines. + Version = 0x01 + // EncryptionChaCha20Poly1305 is the only cipher the BIP defines. + EncryptionChaCha20Poly1305 = 0x01 + // BIP380 is the BIP number for output descriptors. + BIP380 = 380 + // ContentTypeBIP marks a content item named by BIP number. + ContentTypeBIP = 0x01 + // ContentTypeString marks a UTF-8 string content item. + ContentTypeString = 0x03 +) + +// numsXOnly is the BIP-341 NUMS point. Its private key is unknown by +// construction, so it is public knowledge and must never seed a backup key. +const numsXOnly = "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0" + +// SecretEntryBuckets are the entry counts BIP-138 asks an encoder to pad to, +// so that counting the entries does not count the cosigners. +var SecretEntryBuckets = []int{5, 10, 20} + +// Backup is a decoded BIP-138 file. +type Backup struct { + Version int + DerivationPaths [][]uint32 + IndividualSecrets [][]byte + Encryption int + Nonce []byte + Ciphertext []byte +} + +// ContentItem is one entry inside the encrypted payload. Exactly one of BIP or +// String carries meaning, chosen by Type. +type ContentItem struct { + Type int + BIP int + Content []byte +} + +// -------------------------------------------------------------------------- +// Bytes +// -------------------------------------------------------------------------- + +func xorBytes(a, b []byte) []byte { + out := make([]byte, len(a)) + for i := range a { + out[i] = a[i] ^ b[i] + } + return out +} + +// TaggedHash is the BIP-340 tagged hash: sha256(sha256(tag) | sha256(tag) | message). +func TaggedHash(tag string, message []byte) []byte { + t := sha256.Sum256([]byte(tag)) + h := sha256.New() + h.Write(t[:]) + h.Write(t[:]) + h.Write(message) + return h.Sum(nil) +} + +// EncodeCompactSize writes Bitcoin's compact-size integer. +func EncodeCompactSize(n int) []byte { + switch { + case n < 0: + panic("a length cannot be negative") + case n < 0xfd: + return []byte{byte(n)} + case n <= 0xffff: + out := make([]byte, 3) + out[0] = 0xfd + binary.LittleEndian.PutUint16(out[1:], uint16(n)) + return out + case n <= 0xffffffff: + out := make([]byte, 5) + out[0] = 0xfe + binary.LittleEndian.PutUint32(out[1:], uint32(n)) + return out + default: + out := make([]byte, 9) + out[0] = 0xff + binary.LittleEndian.PutUint64(out[1:], uint64(n)) + return out + } +} + +func readCompactSize(b []byte, at int) (value int, next int, err error) { + if at >= len(b) { + return 0, 0, errors.New("the backup ends in the middle of a length") + } + first := b[at] + switch { + case first < 0xfd: + return int(first), at + 1, nil + case first == 0xfd: + if at+3 > len(b) { + return 0, 0, errors.New("the backup ends in the middle of a length") + } + return int(binary.LittleEndian.Uint16(b[at+1:])), at + 3, nil + case first == 0xfe: + if at+5 > len(b) { + return 0, 0, errors.New("the backup ends in the middle of a length") + } + return int(binary.LittleEndian.Uint32(b[at+1:])), at + 5, nil + default: + if at+9 > len(b) { + return 0, 0, errors.New("the backup ends in the middle of a length") + } + return int(binary.LittleEndian.Uint64(b[at+1:])), at + 9, nil + } +} + +// sortUniqueBytes sorts byte slices by their own bytes and drops duplicates. +func sortUniqueBytes(in [][]byte) [][]byte { + sorted := make([][]byte, len(in)) + copy(sorted, in) + sort.SliceStable(sorted, func(i, j int) bool { return bytes.Compare(sorted[i], sorted[j]) < 0 }) + out := make([][]byte, 0, len(sorted)) + for _, v := range sorted { + if len(out) == 0 || !bytes.Equal(out[len(out)-1], v) { + out = append(out, v) + } + } + return out +} + +// -------------------------------------------------------------------------- +// Secrets +// -------------------------------------------------------------------------- + +// ToXOnly drops the parity byte from a 33-byte compressed key. +func ToXOnly(pubkey []byte) ([]byte, error) { + switch len(pubkey) { + case 32: + return pubkey, nil + case 33: + return pubkey[1:], nil + default: + return nil, fmt.Errorf("a public key must be 32 or 33 bytes, got %d", len(pubkey)) + } +} + +// NormalizeKeys takes the keys to x-only, sorts them, drops duplicates and +// removes the NUMS point, as the BIP requires. Two keys that differ only in +// parity share an x coordinate and collapse to one entry. +func NormalizeKeys(pubkeys [][]byte) ([][]byte, error) { + xOnly := make([][]byte, 0, len(pubkeys)) + for _, p := range pubkeys { + x, err := ToXOnly(p) + if err != nil { + return nil, err + } + if hex.EncodeToString(x) == numsXOnly { + continue + } + xOnly = append(xOnly, x) + } + return sortUniqueBytes(xOnly), nil +} + +// DeriveSecrets returns the shared decryption secret and one individual secret +// per key, in sorted key order. +func DeriveSecrets(pubkeys [][]byte) (secret []byte, individual [][]byte, err error) { + keys, err := NormalizeKeys(pubkeys) + if err != nil { + return nil, nil, err + } + if len(keys) == 0 { + return nil, nil, errors.New("a backup needs at least one usable public key") + } + var joined []byte + for _, k := range keys { + joined = append(joined, k...) + } + secret = TaggedHash("BIP138_DECRYPTION_SECRET", joined) + individual = make([][]byte, 0, len(keys)) + for _, k := range keys { + individual = append(individual, xorBytes(secret, TaggedHash("BIP138_INDIVIDUAL_SECRET", k))) + } + return secret, individual, nil +} + +// -------------------------------------------------------------------------- +// Field encoding +// -------------------------------------------------------------------------- + +const hardened = 0x80000000 + +var derivationStep = regexp.MustCompile(`^(\d+)(['h])?$`) + +// ParseDerivationPath reads "m/48'/0'/0'/2'" or "m/48h/0h/0h/2h". +func ParseDerivationPath(path string) ([]uint32, error) { + body := path + if len(body) > 0 && body[0] == 'm' { + body = body[1:] + } + if len(body) > 0 && body[0] == '/' { + body = body[1:] + } + if body == "" { + return nil, nil + } + parts := splitOn(body, '/') + out := make([]uint32, 0, len(parts)) + for _, part := range parts { + m := derivationStep.FindStringSubmatch(part) + if m == nil { + return nil, fmt.Errorf("%q is not a valid derivation step", part) + } + var index uint64 + for _, c := range m[1] { + index = index*10 + uint64(c-'0') + if index > 0x7fffffff { + return nil, fmt.Errorf("%q is not a valid derivation step", part) + } + } + v := uint32(index) + if m[2] != "" { + v += hardened + } + out = append(out, v) + } + return out, nil +} + +func splitOn(s string, sep byte) []string { + var out []string + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == sep { + out = append(out, s[start:i]) + start = i + 1 + } + } + return append(out, s[start:]) +} + +// EncodeDerivationPaths writes the path list, sorted and de-duplicated so the +// order leaks nothing about the encoder. +func EncodeDerivationPaths(paths [][]uint32) ([]byte, error) { + encoded := make([][]byte, 0, len(paths)) + for _, children := range paths { + if len(children) == 0 || len(children) > 255 { + return nil, errors.New("a derivation path must have 1 to 255 steps") + } + out := make([]byte, 1+4*len(children)) + out[0] = byte(len(children)) + for i, child := range children { + binary.BigEndian.PutUint32(out[1+4*i:], child) + } + encoded = append(encoded, out) + } + unique := sortUniqueBytes(encoded) + if len(unique) > 255 { + return nil, errors.New("a backup can carry at most 255 derivation paths") + } + out := []byte{byte(len(unique))} + for _, p := range unique { + out = append(out, p...) + } + return out, nil +} + +// IsCommonDerivationPath is true for the account paths every compliant wallet +// tries on its own during recovery. +func IsCommonDerivationPath(children []uint32) bool { + inRange := func(v uint32) bool { return v >= hardened && v-hardened <= 9 } + if len(children) != 3 && len(children) != 4 { + return false + } + purpose, coin, account := children[0], children[1], children[2] + if coin != 0+hardened && coin != 1+hardened { + return false + } + if !inRange(account) { + return false + } + if len(children) == 3 { + for _, p := range []uint32{44, 49, 84, 86, 87} { + if purpose == p+hardened { + return true + } + } + return false + } + script := children[3] + return purpose == 48+hardened && (script == 1+hardened || script == 2+hardened) +} + +// DropCommonDerivationPaths removes the paths a recovering wallet would try +// anyway. Writing them out costs bytes and tells an observer which script +// family the backup belongs to, for no gain. +func DropCommonDerivationPaths(paths [][]uint32) [][]uint32 { + out := make([][]uint32, 0, len(paths)) + for _, p := range paths { + if !IsCommonDerivationPath(p) { + out = append(out, p) + } + } + return out +} + +// EncodeIndividualSecrets writes the entry list, sorted by its own bytes so +// the file order hides which key is whose. +func EncodeIndividualSecrets(secrets [][]byte) ([]byte, error) { + for _, s := range secrets { + if len(s) != 32 { + return nil, errors.New("an individual secret must be 32 bytes") + } + } + unique := sortUniqueBytes(secrets) + if len(unique) == 0 || len(unique) > 255 { + return nil, errors.New("a backup carries 1 to 255 individual secrets") + } + out := []byte{byte(len(unique))} + for _, s := range unique { + out = append(out, s...) + } + return out, nil +} + +// EncodeBipContentType writes content type 0x01: a BIP number as a big-endian +// 16-bit integer. +func EncodeBipContentType(bip int) ([]byte, error) { + if bip < 0 || bip > 0xffff { + return nil, errors.New("invalid BIP number") + } + return []byte{ContentTypeBIP, byte(bip >> 8), byte(bip)}, nil +} + +// EncodePayload writes the content items that go inside the encryption. +func EncodePayload(items []ContentItem) ([]byte, error) { + if len(items) == 0 { + return nil, errors.New("a payload must carry at least one content item") + } + var out []byte + for _, item := range items { + switch item.Type { + case ContentTypeString: + // The BIP: "For all TYPE values except 0x01, TYPE_LENGTH MUST be + // present", and "0x03: TYPE_PARAMS MUST be empty". So the type + // byte is followed by a zero-length TYPE_PARAMS. + out = append(out, ContentTypeString, 0x00) + default: + head, err := EncodeBipContentType(item.BIP) + if err != nil { + return nil, err + } + out = append(out, head...) + } + out = append(out, EncodeCompactSize(len(item.Content))...) + out = append(out, item.Content...) + } + return out, nil +} + +// DecodePayload reads the content items back. +func DecodePayload(payload []byte) ([]ContentItem, error) { + var items []ContentItem + at := 0 + for at < len(payload) { + typ := int(payload[at]) + // 0x00 ends the items. Everything after it is padding. + if typ == 0x00 { + break + } + at++ + if typ == ContentTypeBIP { + if at+2 > len(payload) { + return nil, errors.New("this backup is truncated") + } + bip := int(payload[at])<<8 | int(payload[at+1]) + at += 2 + length, next, err := readCompactSize(payload, at) + if err != nil { + return nil, err + } + at = next + if at+length > len(payload) { + return nil, errors.New("this backup is truncated") + } + items = append(items, ContentItem{Type: ContentTypeBIP, BIP: bip, Content: payload[at : at+length]}) + at += length + continue + } + if typ == ContentTypeString { + // TYPE_LENGTH, then TYPE_PARAMS (empty for a string), then the content. + paramLen, paramNext, err := readCompactSize(payload, at) + if err != nil { + return nil, err + } + at = paramNext + paramLen + length, next, err := readCompactSize(payload, at) + if err != nil { + return nil, err + } + at = next + if at+length > len(payload) { + return nil, errors.New("this backup is truncated") + } + items = append(items, ContentItem{Type: ContentTypeString, Content: payload[at : at+length]}) + at += length + continue + } + if typ >= 0x80 { + return nil, fmt.Errorf("this backup uses content type 0x%x, which this version cannot read", typ) + } + // A known-shape unknown type: skip its params, then skip its content. + pv, pn, err := readCompactSize(payload, at) + if err != nil { + return nil, err + } + at = pn + pv + bv, bn, err := readCompactSize(payload, at) + if err != nil { + return nil, err + } + at = bn + bv + } + return items, nil +} + +// -------------------------------------------------------------------------- +// Container +// -------------------------------------------------------------------------- + +// EncodeOptions controls one backup. +type EncodeOptions struct { + // Pubkeys is every public key that may open this backup. + Pubkeys [][]byte + Items []ContentItem + // DecoySecrets are extra 32-byte entries that hide how many keys are real. + DecoySecrets [][]byte + // PadSecretsTo pads the entry list up to this many entries. Use + // SecretEntryBucket to choose the value. The BIP tells an encoder to pad to + // a bucket, so that counting the entries does not reveal how many + // cosigners a wallet has. + PadSecretsTo int + DerivationPaths [][]uint32 + // Nonce is 12 bytes and never all zero. Supply it only to reproduce a vector. + Nonce []byte +} + +// SecretEntryBucket returns the smallest bucket that holds keyCount secrets. +// A 2-of-3 pads to five. A 3-of-7 pads to ten. +func SecretEntryBucket(keyCount int) int { + for _, bucket := range SecretEntryBuckets { + if keyCount <= bucket { + return bucket + } + } + stepped := ((keyCount + 19) / 20) * 20 + if stepped > 255 { + return 255 + } + return stepped +} + +// padWithDecoys adds random 32-byte entries until the list holds target +// distinct ones. A decoy that collided with a real secret would be dropped by +// the encoder's de-duplication and would quietly shrink the count, so this +// checks. +func padWithDecoys(secrets [][]byte, target int) ([][]byte, error) { + if target <= len(secrets) { + return secrets, nil + } + if target > 255 { + return nil, errors.New("a backup carries at most 255 individual secrets") + } + seen := make(map[string]bool, target) + for _, s := range secrets { + seen[string(s)] = true + } + padded := make([][]byte, len(secrets), target) + copy(padded, secrets) + for len(padded) < target { + decoy := make([]byte, 32) + if _, err := rand.Read(decoy); err != nil { + return nil, err + } + if seen[string(decoy)] { + continue + } + seen[string(decoy)] = true + padded = append(padded, decoy) + } + return padded, nil +} + +func randomNonce() ([]byte, error) { + for { + nonce := make([]byte, 12) + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + for _, b := range nonce { + if b != 0 { + return nonce, nil + } + } + } +} + +// EncodeBackup writes a complete BIP-138 backup. +func EncodeBackup(options EncodeOptions) ([]byte, error) { + secret, individual, err := DeriveSecrets(options.Pubkeys) + if err != nil { + return nil, err + } + + nonce := options.Nonce + if nonce == nil { + if nonce, err = randomNonce(); err != nil { + return nil, err + } + } + if len(nonce) != 12 { + return nil, errors.New("the nonce must be 12 bytes") + } + allZero := true + for _, b := range nonce { + if b != 0 { + allZero = false + break + } + } + if allZero { + return nil, errors.New("the nonce must not be all zero") + } + + payload, err := EncodePayload(options.Items) + if err != nil { + return nil, err + } + aead, err := chacha20poly1305.New(secret) + if err != nil { + return nil, err + } + ciphertext := aead.Seal(nil, nonce, payload, nil) + + entries := append(append([][]byte{}, individual...), options.DecoySecrets...) + entries, err = padWithDecoys(entries, options.PadSecretsTo) + if err != nil { + return nil, err + } + + paths, err := EncodeDerivationPaths(DropCommonDerivationPaths(options.DerivationPaths)) + if err != nil { + return nil, err + } + secretBlock, err := EncodeIndividualSecrets(entries) + if err != nil { + return nil, err + } + + out := append([]byte{}, Magic...) + out = append(out, Version) + out = append(out, paths...) + out = append(out, secretBlock...) + out = append(out, EncryptionChaCha20Poly1305) + out = append(out, nonce...) + out = append(out, EncodeCompactSize(len(ciphertext))...) + out = append(out, ciphertext...) + return out, nil +} + +// DecodeBackup reads the container without decrypting it. +func DecodeBackup(b []byte) (*Backup, error) { + if len(b) < len(Magic)+1 || !bytes.Equal(b[:len(Magic)], Magic) { + return nil, errors.New("this is not a BIP-138 backup") + } + at := len(Magic) + version := int(b[at]) + at++ + if version != Version { + return nil, fmt.Errorf("this backup is format version %d, which this version cannot read", version) + } + + if at >= len(b) { + return nil, errors.New("this backup is truncated") + } + pathCount := int(b[at]) + at++ + paths := make([][]uint32, 0, pathCount) + for i := 0; i < pathCount; i++ { + if at >= len(b) { + return nil, errors.New("this backup is truncated") + } + childCount := int(b[at]) + at++ + if at+4*childCount > len(b) { + return nil, errors.New("this backup is truncated") + } + children := make([]uint32, 0, childCount) + for c := 0; c < childCount; c++ { + children = append(children, binary.BigEndian.Uint32(b[at:])) + at += 4 + } + paths = append(paths, children) + } + + if at >= len(b) { + return nil, errors.New("this backup is truncated") + } + secretCount := int(b[at]) + at++ + if secretCount == 0 { + return nil, errors.New("this backup carries no individual secrets") + } + if at+32*secretCount > len(b) { + return nil, errors.New("this backup is truncated") + } + secrets := make([][]byte, 0, secretCount) + for i := 0; i < secretCount; i++ { + secrets = append(secrets, b[at:at+32]) + at += 32 + } + + if at+13 > len(b) { + return nil, errors.New("this backup is truncated") + } + encryption := int(b[at]) + at++ + nonce := b[at : at+12] + at += 12 + allZero := true + for _, x := range nonce { + if x != 0 { + allZero = false + break + } + } + if allZero { + return nil, errors.New("this backup has an all-zero nonce, which is not allowed") + } + + length, next, err := readCompactSize(b, at) + if err != nil { + return nil, err + } + at = next + if at+length > len(b) { + return nil, errors.New("this backup is truncated") + } + // Any bytes after the ciphertext are reserved and ignored on purpose. + return &Backup{ + Version: version, + DerivationPaths: paths, + IndividualSecrets: secrets, + Encryption: encryption, + Nonce: nonce, + Ciphertext: b[at : at+length], + }, nil +} + +// DecryptBackup opens a backup with one public key. It tries every entry in +// the file, so decoy entries cost the reader nothing but a few failed +// authentications. +func DecryptBackup(b []byte, pubkey []byte) ([]ContentItem, error) { + backup, err := DecodeBackup(b) + if err != nil { + return nil, err + } + if backup.Encryption != EncryptionChaCha20Poly1305 { + return nil, fmt.Errorf("this backup uses cipher %d, which this version cannot read", backup.Encryption) + } + x, err := ToXOnly(pubkey) + if err != nil { + return nil, err + } + mine := TaggedHash("BIP138_INDIVIDUAL_SECRET", x) + + for _, entry := range backup.IndividualSecrets { + aead, err := chacha20poly1305.New(xorBytes(entry, mine)) + if err != nil { + continue + } + payload, err := aead.Open(nil, backup.Nonce, backup.Ciphertext, nil) + if err != nil { + // Not our entry, or a decoy. Try the next one. + continue + } + // The key worked. Anything that fails from here is about the contents, + // so it must never be reported as a key problem. An heir told to find + // more keys goes hunting for keys they already have enough of. + items, err := DecodePayload(payload) + if err != nil { + return nil, fmt.Errorf("your key opened this backup, but this page cannot read what is inside it: %w", err) + } + return items, nil + } + return nil, errors.New("none of the keys you supplied can open this backup") +} + +// -------------------------------------------------------------------------- +// Descriptors +// -------------------------------------------------------------------------- + +var keyExpression = regexp.MustCompile(`([xyztuvUVYZ]pub[a-zA-Z0-9]{107})((?:/(?:\d+['h]?|<[\d;'h]+>|\*))*)`) + +// DescriptorPubkeys returns the root public keys of a descriptor's eligible +// key expressions. Only extended keys with a trailing derivation step or +// wildcard count. A bare xpub is refused, because its root key is also the key +// that appears on chain, so one observed spend would hand an outsider the +// decryption secret. +func DescriptorPubkeys(descriptor string) (pubkeys [][]byte, excluded []string, err error) { + for _, m := range keyExpression.FindAllStringSubmatch(descriptor, -1) { + xpub, trailing := m[1], m[2] + if trailing == "" { + excluded = append(excluded, xpub) + continue + } + raw, err := base58CheckDecode(xpub) + if err != nil { + return nil, nil, err + } + if len(raw) < 78 { + return nil, nil, errors.New("an extended public key must be 78 bytes") + } + // The last 33 bytes of the 78-byte payload are the compressed key. + pubkeys = append(pubkeys, raw[45:]) + } + if len(pubkeys) == 0 { + return nil, nil, errors.New("this descriptor has no key that BIP-138 can use. Every key needs a derivation step or a wildcard") + } + return pubkeys, excluded, nil +} + +// EncryptDescriptor wraps a descriptor as a BIP-380 content item, with any +// extra items beside it, and encodes a backup. +func EncryptDescriptor(descriptor string, extra []ContentItem, options EncodeOptions) (backup []byte, text string, excluded []string, err error) { + pubkeys, excluded, err := DescriptorPubkeys(descriptor) + if err != nil { + return nil, "", nil, err + } + options.Pubkeys = pubkeys + options.Items = append([]ContentItem{{Type: ContentTypeBIP, BIP: BIP380, Content: []byte(descriptor)}}, extra...) + if options.PadSecretsTo == 0 { + options.PadSecretsTo = SecretEntryBucket(len(pubkeys)) + } + backup, err = EncodeBackup(options) + if err != nil { + return nil, "", nil, err + } + return backup, base64.StdEncoding.EncodeToString(backup), excluded, nil +} + +// DecryptDescriptor opens a BIP-380 descriptor backup with one extended +// public key. +func DecryptDescriptor(backup []byte, xpub string) (string, error) { + raw, err := base58CheckDecode(xpub) + if err != nil { + return "", err + } + if len(raw) < 78 { + return "", errors.New("an extended public key must be 78 bytes") + } + items, err := DecryptBackup(backup, raw[45:]) + if err != nil { + return "", err + } + for _, item := range items { + if item.Type == ContentTypeBIP && item.BIP == BIP380 { + return string(item.Content), nil + } + } + return "", errors.New("this backup holds no descriptor") +} + +// DecryptDescriptorText opens a base64 backup with one extended public key. +func DecryptDescriptorText(text, xpub string) (string, error) { + raw, err := base64.StdEncoding.DecodeString(trimSpace(text)) + if err != nil { + return "", err + } + return DecryptDescriptor(raw, xpub) +} + +func trimSpace(s string) string { + start, end := 0, len(s) + for start < end && (s[start] == ' ' || s[start] == '\n' || s[start] == '\t' || s[start] == '\r') { + start++ + } + for end > start && (s[end-1] == ' ' || s[end-1] == '\n' || s[end-1] == '\t' || s[end-1] == '\r') { + end-- + } + return s[start:end] +} diff --git a/internal/descriptorbackup/bip138_test.go b/internal/descriptorbackup/bip138_test.go new file mode 100644 index 0000000..6adab0b --- /dev/null +++ b/internal/descriptorbackup/bip138_test.go @@ -0,0 +1,348 @@ +package descriptorbackup + +import ( + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "golang.org/x/crypto/chacha20poly1305" +) + +const vectorDir = "../html/assets/src/crypto/testdata/bip138" + +func loadVectors(t *testing.T, name string, out any) { + t.Helper() + b, err := os.ReadFile(filepath.Join(vectorDir, name)) + if err != nil { + t.Fatalf("reading %s: %v", name, err) + } + if err := json.Unmarshal(b, out); err != nil { + t.Fatalf("parsing %s: %v", name, err) + } +} + +func mustHex(t *testing.T, s string) []byte { + t.Helper() + b, err := hex.DecodeString(s) + if err != nil { + t.Fatalf("bad hex %q: %v", s, err) + } + return b +} + +func TestVectorEncryptionSecret(t *testing.T) { + var vs []struct { + Description string `json:"description"` + Keys []string `json:"keys"` + DecryptionSecret string `json:"decryption_secret"` + IndividualSecrets []string `json:"individual_secrets"` + } + loadVectors(t, "encryption_secret.json", &vs) + if len(vs) == 0 { + t.Fatal("no vectors") + } + for _, v := range vs { + t.Run(v.Description, func(t *testing.T) { + keys := make([][]byte, 0, len(v.Keys)) + for _, k := range v.Keys { + keys = append(keys, mustHex(t, k)) + } + secret, individual, err := DeriveSecrets(keys) + if err != nil { + t.Fatalf("DeriveSecrets: %v", err) + } + if got := hex.EncodeToString(secret); got != v.DecryptionSecret { + t.Errorf("decryption secret\n got %s\nwant %s", got, v.DecryptionSecret) + } + if len(individual) != len(v.IndividualSecrets) { + t.Fatalf("individual secret count: got %d want %d", len(individual), len(v.IndividualSecrets)) + } + for i, want := range v.IndividualSecrets { + if got := hex.EncodeToString(individual[i]); got != want { + t.Errorf("individual secret %d\n got %s\nwant %s", i, got, want) + } + } + }) + } +} + +func TestVectorDerivationPath(t *testing.T) { + var vs []struct { + Description string `json:"description"` + Paths []string `json:"paths"` + Expected *string `json:"expected"` + } + loadVectors(t, "derivation_path.json", &vs) + for _, v := range vs { + t.Run(v.Description, func(t *testing.T) { + paths := make([][]uint32, 0, len(v.Paths)) + for _, p := range v.Paths { + children, err := ParseDerivationPath(p) + if err != nil { + // The draft marks a case that must be refused with a null + // expectation, and some of those are refused at parse time. + if v.Expected == nil { + return + } + t.Fatalf("ParseDerivationPath(%q): %v", p, err) + } + paths = append(paths, children) + } + got, err := EncodeDerivationPaths(paths) + if v.Expected == nil { + if err == nil { + t.Fatalf("expected a refusal, got %s", hex.EncodeToString(got)) + } + return + } + if err != nil { + t.Fatalf("EncodeDerivationPaths: %v", err) + } + if hex.EncodeToString(got) != *v.Expected { + t.Errorf("\n got %s\nwant %s", hex.EncodeToString(got), *v.Expected) + } + }) + } +} + +func TestVectorIndividualSecrets(t *testing.T) { + var vs []struct { + Description string `json:"description"` + Secrets []string `json:"secrets"` + Expected *string `json:"expected"` + } + loadVectors(t, "individual_secrets.json", &vs) + for _, v := range vs { + t.Run(v.Description, func(t *testing.T) { + secrets := make([][]byte, 0, len(v.Secrets)) + for _, s := range v.Secrets { + secrets = append(secrets, mustHex(t, s)) + } + got, err := EncodeIndividualSecrets(secrets) + if v.Expected == nil { + if err == nil { + t.Fatalf("expected a refusal, got %s", hex.EncodeToString(got)) + } + return + } + if err != nil { + t.Fatalf("EncodeIndividualSecrets: %v", err) + } + if hex.EncodeToString(got) != *v.Expected { + t.Errorf("\n got %s\nwant %s", hex.EncodeToString(got), *v.Expected) + } + }) + } +} + +func TestVectorContentType(t *testing.T) { + var vs []struct { + Description string `json:"description"` + Valid bool `json:"valid"` + Content string `json:"content"` + } + loadVectors(t, "content_type.json", &vs) + checked := 0 + for _, v := range vs { + if !v.Valid || len(v.Content) != 6 || v.Content[:2] != "01" { + continue + } + t.Run(v.Description, func(t *testing.T) { + raw := mustHex(t, v.Content) + bip := int(raw[1])<<8 | int(raw[2]) + got, err := EncodeBipContentType(bip) + if err != nil { + t.Fatalf("EncodeBipContentType: %v", err) + } + if hex.EncodeToString(got) != v.Content { + t.Errorf("\n got %s\nwant %s", hex.EncodeToString(got), v.Content) + } + }) + checked++ + } + if checked == 0 { + t.Fatal("no BIP-number content-type vectors were exercised") + } +} + +func TestVectorEncryptedBackup(t *testing.T) { + type extraItem struct { + Content string `json:"content"` + Plaintext string `json:"plaintext"` + } + var vs []struct { + Description string `json:"description"` + Valid *bool `json:"valid"` + Content string `json:"content"` + Keys []string `json:"keys"` + DecoyIndividualSecrets []string `json:"decoy_individual_secrets"` + DerivationPaths []string `json:"derivation_paths"` + Plaintext string `json:"plaintext"` + Extra []extraItem `json:"extra"` + Nonce string `json:"nonce"` + Expected string `json:"expected"` + } + loadVectors(t, "encrypted_backup.json", &vs) + + checked := 0 + for _, v := range vs { + if v.Valid != nil && !*v.Valid { + continue + } + // The encoder writes BIP-number content items. A vector with a + // vendor-specific type is covered by the decode test instead. + if len(v.Content) < 2 || v.Content[:2] != "01" { + continue + } + t.Run(v.Description, func(t *testing.T) { + keys := make([][]byte, 0, len(v.Keys)) + for _, k := range v.Keys { + keys = append(keys, mustHex(t, k)) + } + decoys := make([][]byte, 0, len(v.DecoyIndividualSecrets)) + for _, d := range v.DecoyIndividualSecrets { + decoys = append(decoys, mustHex(t, d)) + } + paths := make([][]uint32, 0, len(v.DerivationPaths)) + for _, p := range v.DerivationPaths { + children, err := ParseDerivationPath(p) + if err != nil { + t.Fatalf("ParseDerivationPath(%q): %v", p, err) + } + paths = append(paths, children) + } + + // plaintext is UTF-8 text in these vectors, never hex. + items := []ContentItem{{Type: ContentTypeBIP, BIP: bipFromContent(t, v.Content), Content: []byte(v.Plaintext)}} + for _, e := range v.Extra { + items = append(items, ContentItem{Type: ContentTypeBIP, BIP: bipFromContent(t, e.Content), Content: []byte(e.Plaintext)}) + } + + got, err := EncodeBackup(EncodeOptions{ + Pubkeys: keys, + Items: items, + DecoySecrets: decoys, + DerivationPaths: paths, + Nonce: mustHex(t, v.Nonce), + }) + if err != nil { + t.Fatalf("EncodeBackup: %v", err) + } + if hex.EncodeToString(got) != v.Expected { + t.Errorf("\n got %s\nwant %s", hex.EncodeToString(got), v.Expected) + } + }) + checked++ + } + if checked == 0 { + t.Fatal("no whole-backup vectors were exercised") + } +} + +// bipFromContent reads the BIP number out of a content-type header hex string +// such as "01017c", the way the draft's vectors express it. +func bipFromContent(t *testing.T, content string) int { + t.Helper() + n, err := strconv.ParseInt(content[2:], 16, 32) + if err != nil { + t.Fatalf("bad content type %q: %v", content, err) + } + return int(n) +} + +func TestVectorCipher(t *testing.T) { + var vs []struct { + Description string `json:"description"` + Nonce string `json:"nonce"` + Plaintext string `json:"plaintext"` + Secret string `json:"secret"` + Ciphertext *string `json:"ciphertext"` + } + loadVectors(t, "chacha20poly1305_encryption.json", &vs) + checked := 0 + for _, v := range vs { + if v.Ciphertext == nil { + continue + } + t.Run(v.Description, func(t *testing.T) { + aead, err := chacha20poly1305.New(mustHex(t, v.Secret)) + if err != nil { + t.Fatalf("chacha20poly1305.New: %v", err) + } + sealed := aead.Seal(nil, mustHex(t, v.Nonce), mustHex(t, v.Plaintext), nil) + if hex.EncodeToString(sealed) != *v.Ciphertext { + t.Errorf("\n got %s\nwant %s", hex.EncodeToString(sealed), *v.Ciphertext) + } + }) + checked++ + } + if checked == 0 { + t.Fatal("no cipher vectors were exercised") + } +} + +// buildBackupWithPayload assembles a container by hand around a payload that +// is already sealed, so a test can put unreadable contents inside a backup the +// key genuinely opens. +func buildBackupWithPayload(t *testing.T, pubkey []byte, payload []byte) []byte { + t.Helper() + secret, individual, err := DeriveSecrets([][]byte{pubkey}) + if err != nil { + t.Fatalf("DeriveSecrets: %v", err) + } + nonce := []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12} + aead, err := chacha20poly1305.New(secret) + if err != nil { + t.Fatalf("chacha20poly1305.New: %v", err) + } + ciphertext := aead.Seal(nil, nonce, payload, nil) + + secretBlock, err := EncodeIndividualSecrets(individual) + if err != nil { + t.Fatalf("EncodeIndividualSecrets: %v", err) + } + out := append([]byte{}, Magic...) + out = append(out, Version) + out = append(out, 0x00) // no derivation paths + out = append(out, secretBlock...) + out = append(out, EncryptionChaCha20Poly1305) + out = append(out, nonce...) + out = append(out, EncodeCompactSize(len(ciphertext))...) + return append(out, ciphertext...) +} + +func TestUnreadableContentsDoNotBlameTheKey(t *testing.T) { + pubkey := mustHex(t, "02e6642fd69bd211f93f7f1f36ca51a26a5290eb2dd1b0d8279a87bb0d480c8443") + // Content type 0x80 and above is one this version must refuse. + backup := buildBackupWithPayload(t, pubkey, []byte{0x80, 0x00, 0x00}) + + _, err := DecryptBackup(backup, pubkey) + if err == nil { + t.Fatal("expected a refusal for contents this version cannot read") + } + msg := err.Error() + if strings.Contains(msg, "none of the keys") { + t.Errorf("the key was correct, so the error must not blame it:\n %s", msg) + } + if !strings.Contains(msg, "opened this backup") { + t.Errorf("the error should say the key worked:\n %s", msg) + } +} + +func TestWrongKeyStillBlamesTheKey(t *testing.T) { + pubkey := mustHex(t, "02e6642fd69bd211f93f7f1f36ca51a26a5290eb2dd1b0d8279a87bb0d480c8443") + other := mustHex(t, "0339710356a496726c84692621b2b6e3645dd35bc0026c587f16411897990a1e1f") + backup := buildBackupWithPayload(t, pubkey, []byte{0x01, 0x01, 0x7c, 0x01, 0x78}) + + _, err := DecryptBackup(backup, other) + if err == nil { + t.Fatal("a backup must not open with a key that is not in it") + } + if !strings.Contains(err.Error(), "none of the keys") { + t.Errorf("a genuinely wrong key should say so:\n %s", err.Error()) + } +} diff --git a/internal/descriptorbackup/conformance/main.go b/internal/descriptorbackup/conformance/main.go new file mode 100644 index 0000000..bc68e5f --- /dev/null +++ b/internal/descriptorbackup/conformance/main.go @@ -0,0 +1,102 @@ +// Command conformance writes backups with the Go implementation so that the +// TypeScript one can try to open them. +// +// The two implementations must agree, because the browser writes with one and +// the heir-side recover page reads with the other. Run it through +// `make test-xlang`, which pipes the output into the TypeScript checker. +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + + db "github.com/Bitcoin-Butlers/kaitiaki/internal/descriptorbackup" + "golang.org/x/crypto/chacha20poly1305" +) + +// unreadableBackup builds a backup the key genuinely opens, holding a content +// type this version must refuse. It exists so the checker can prove neither +// implementation blames the key for contents it cannot read. +func unreadableBackup(pubkey []byte) string { + secret, individual, err := db.DeriveSecrets([][]byte{pubkey}) + if err != nil { + panic(err) + } + nonce := []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12} + aead, err := chacha20poly1305.New(secret) + if err != nil { + panic(err) + } + ciphertext := aead.Seal(nil, nonce, []byte{0x80, 0x00, 0x00}, nil) + block, err := db.EncodeIndividualSecrets(individual) + if err != nil { + panic(err) + } + out := append([]byte{}, db.Magic...) + out = append(out, db.Version, 0x00) + out = append(out, block...) + out = append(out, db.EncryptionChaCha20Poly1305) + out = append(out, nonce...) + out = append(out, db.EncodeCompactSize(len(ciphertext))...) + out = append(out, ciphertext...) + return base64.StdEncoding.EncodeToString(out) +} + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: conformance ") + os.Exit(2) + } + var vector struct { + Descriptor string `json:"descriptor"` + Xpubs []string `json:"xpubs"` + } + raw, err := os.ReadFile("internal/html/assets/src/crypto/testdata/descriptor-vector.json") + if err != nil { + panic(err) + } + if err := json.Unmarshal(raw, &vector); err != nil { + panic(err) + } + + note := "2 of 3. Sparrow. Connect any two signers." + + threshold, err := db.EncryptDescriptorThreshold(vector.Descriptor, nil) + if err != nil { + panic(err) + } + _, plain, _, err := db.EncryptDescriptor(vector.Descriptor, nil, db.EncodeOptions{}) + if err != nil { + panic(err) + } + _, withNote, _, err := db.EncryptDescriptor(vector.Descriptor, []db.ContentItem{ + {Type: db.ContentTypeString, Content: []byte(note)}, + }, db.EncodeOptions{}) + if err != nil { + panic(err) + } + + pubkeys, _, err := db.DescriptorPubkeys(vector.Descriptor) + if err != nil { + panic(err) + } + + out, err := json.MarshalIndent(map[string]any{ + "unreadable": unreadableBackup(pubkeys[1]), + "descriptor": vector.Descriptor, + "xpubs": vector.Xpubs, + "note": note, + "threshold": threshold.EncryptedText, + "bip138": plain, + "bip138Note": withNote, + }, "", " ") + if err != nil { + panic(err) + } + if err := os.WriteFile(os.Args[1], out, 0o644); err != nil { + panic(err) + } + fmt.Println("wrote", os.Args[1]) +} diff --git a/internal/descriptorbackup/descriptor.go b/internal/descriptorbackup/descriptor.go new file mode 100644 index 0000000..bf8afde --- /dev/null +++ b/internal/descriptorbackup/descriptor.go @@ -0,0 +1,332 @@ +// Threshold format: encrypt a multisig descriptor so that any k of its n +// extended public keys can rebuild it. +// +// This is the Go port of internal/html/assets/src/crypto/descriptor.ts, which +// is itself a port of the scheme in joshdoman/multisig-backup (MIT), kept byte +// for byte compatible on purpose. A client who holds our ciphertext can paste +// it into multisigbackup.com and recover there, with no Bitcoin Butlers +// software in the path. That promise is the reason for every quirk this file +// reproduces, so do not "clean up" the layout below without a new test vector. +// +// Only the encrypt path lives here. The heir-side recover page stays in +// TypeScript so that a grieving heir never loads the maker's WASM to read a +// backup. +package descriptorbackup + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "math/bits" + "regexp" + "strconv" + "strings" + + vault "github.com/hashicorp/vault/shamir" + "golang.org/x/crypto/chacha20" + "golang.org/x/crypto/chacha20poly1305" + "golang.org/x/crypto/hkdf" +) + +const ( + // xfpBytes is what a master fingerprint contributes. + xfpBytes = 4 + // secretBytes is the entropy behind every backup. + secretBytes = 16 +) + +var zeroNonce = make([]byte, 12) + +// Multisig is one multisig group pulled out of a descriptor. +type Multisig struct { + RequiredSigs int + Xfps [][]byte + Xpubs [][]byte + DerivationPaths []string + NumXfps int + NumXpubs int +} + +// EncryptResult is the output of the threshold format. +type EncryptResult struct { + // EncryptedText is the stripped descriptor followed by unpadded base64. + EncryptedText string + // MissingXfps is true when the descriptor omitted some fingerprints, + // which hurts recovery. + MissingXfps bool + // IsTestnet is true when any key is a testnet key. + IsTestnet bool +} + +// -------------------------------------------------------------------------- +// Byte helpers. These match multisig-backup exactly, including its edge cases. +// -------------------------------------------------------------------------- + +func joinBytes(arrays ...[]byte) []byte { + total := 0 + for _, a := range arrays { + total += len(a) + } + out := make([]byte, 0, total) + for _, a := range arrays { + out = append(out, a...) + } + return out +} + +// NumberToBytes writes a number big-endian, shortest form, and EMPTY for zero. +// +// Share index 0 therefore contributes no bytes at all to its key material. +// That looks like a bug and is not one to fix here: the same function runs on +// multisigbackup.com, so changing it would make our ciphertext unreadable +// there. +func NumberToBytes(n int) []byte { + if n <= 0 { + return []byte{} + } + length := (bits.Len(uint(n)) + 7) / 8 + out := make([]byte, length) + for i := 0; i < length; i++ { + out[i] = byte(n >> (8 * (length - i - 1))) + } + return out +} + +func base64Unpadded(b []byte) string { + return base64.RawStdEncoding.EncodeToString(b) +} + +// deriveKey is HKDF-SHA256 with an empty salt and empty info, 256 bits out. +func deriveKey(secret []byte) ([]byte, error) { + out := make([]byte, 32) + if _, err := hkdf.New(sha256.New, secret, nil, nil).Read(out); err != nil { + return nil, err + } + return out, nil +} + +// sortsBefore is true when a sorts before b the way multisig-backup sorts them. +// +// Upstream writes (a < b) on two Uint8Arrays. JavaScript turns each one into +// its comma-joined decimal string first, so [2,0,0,0] sorts AFTER [10,0,0,0] +// because "2," beats "10,". That is not byte order, and using byte order here +// would put the lookup tags in a different order than the tool a client falls +// back to. Pinned by the tag bytes in the golden vector. +func sortsBefore(a, b []byte) bool { + return jsString(a) < jsString(b) +} + +// jsString reproduces JavaScript's String(Uint8Array): decimal values joined +// by commas. +func jsString(b []byte) string { + parts := make([]string, len(b)) + for i, v := range b { + parts[i] = strconv.Itoa(int(v)) + } + return strings.Join(parts, ",") +} + +// -------------------------------------------------------------------------- +// Parsing +// -------------------------------------------------------------------------- + +var ( + multiGroup = regexp.MustCompile(`multi(?:_a)?\(([^)]*)\)`) + multiHead = regexp.MustCompile(`multi(?:_a)?\((\d+),([^)]+)`) + xfpPattern = regexp.MustCompile(`\[([a-f0-9]{8})/`) + xpubPattern = regexp.MustCompile(`([xyztuvUVYZ]pub[a-zA-Z0-9]{107})`) + pathPattern = regexp.MustCompile(`\[([0-9/'h]*)\]`) + testnetPattern = regexp.MustCompile(`[tuvUV]pub[a-zA-Z0-9]{107}`) + stripXpub = regexp.MustCompile(`[xyztuvUVYZ]pub[a-zA-Z0-9]{107}/?`) + stripXfp = regexp.MustCompile(`\[[a-f0-9]{8}/`) + bip32Step = regexp.MustCompile(`^\d+['h]?$`) +) + +func isValidBip32Path(path string) bool { + if path == "" { + return false + } + body := strings.TrimPrefix(path, "m/") + for _, part := range strings.Split(body, "/") { + if !bip32Step.MatchString(part) { + return false + } + } + return true +} + +// ParseDescriptor pulls every multisig group out of a descriptor. +func ParseDescriptor(descriptor string) ([]Multisig, error) { + if strings.Contains(descriptor, "tr(") { + // Taproot key aggregation puts the keys somewhere this layout cannot + // describe. Upstream refuses it too, so a refusal here keeps both + // tools agreeing about which descriptors have a backup at all. + return nil, errors.New("taproot descriptors are not supported yet") + } + groups := multiGroup.FindAllString(descriptor, -1) + if groups == nil { + return nil, errors.New(`not a multisig descriptor. It must contain "[sorted]multi[_a](...)"`) + } + + var out []Multisig + for _, group := range groups { + parts := multiHead.FindStringSubmatch(group) + if parts == nil { + return nil, errors.New("invalid descriptor format") + } + required, err := strconv.Atoi(parts[1]) + if err != nil { + return nil, errors.New("invalid descriptor format") + } + body := parts[2] + + var xfps [][]byte + for _, m := range xfpPattern.FindAllStringSubmatch(body, -1) { + raw, err := hex.DecodeString(m[1]) + if err != nil { + return nil, err + } + xfps = append(xfps, raw) + } + var xpubs [][]byte + for _, m := range xpubPattern.FindAllStringSubmatch(body, -1) { + raw, err := base58CheckDecode(m[1]) + if err != nil { + return nil, err + } + xpubs = append(xpubs, raw) + } + var paths []string + for _, m := range pathPattern.FindAllStringSubmatch(body, -1) { + if isValidBip32Path(m[1]) { + paths = append(paths, m[1]) + } + } + + out = append(out, Multisig{ + RequiredSigs: required, + Xfps: xfps, + Xpubs: xpubs, + DerivationPaths: paths, + NumXpubs: len(strings.Split(body, ",")), + NumXfps: strings.Count(body, "["), + }) + } + return out, nil +} + +// -------------------------------------------------------------------------- +// Encrypt +// -------------------------------------------------------------------------- + +// EncryptDescriptorThreshold encrypts a descriptor so that any k of its n +// extended public keys can rebuild it. Pass a secret only to reproduce a +// vector; otherwise leave it nil and fresh entropy is used. +func EncryptDescriptorThreshold(descriptor string, secret []byte) (*EncryptResult, error) { + multisigs, err := ParseDescriptor(descriptor) + if err != nil { + return nil, err + } + // A descriptor with more than one multisig group cannot be promised. + // multisigbackup.com, the tool a client falls back to, reads the second + // and later groups from the wrong offset and cannot open such a backup at + // all. Refusing here is the only honest answer: the alternative is a + // permanent, public, unopenable backup, which is worse than no backup. + if len(multisigs) > 1 { + return nil, errors.New("this descriptor has more than one multisig group, and this format cannot back it up safely. Back up the wallet another way") + } + + entropy := secret + if entropy == nil { + entropy = make([]byte, secretBytes) + if _, err := rand.Read(entropy); err != nil { + return nil, err + } + } + if len(entropy) != secretBytes { + return nil, fmt.Errorf("the secret must be %d bytes", secretBytes) + } + derivedKey, err := deriveKey(entropy) + if err != nil { + return nil, err + } + + var shares, xfpPairHashes, allXfps, allXpubs [][]byte + for _, ms := range multisigs { + if len(ms.Xpubs) < ms.RequiredSigs { + return nil, errors.New("the descriptor has fewer keys than it requires signatures") + } + // One tag per unordered pair, so a scanner can find the backup from + // any two fingerprints. The tag reveals nothing without the keys. + for i := 0; i < len(ms.Xfps); i++ { + for j := i + 1; j < len(ms.Xfps); j++ { + a, b := ms.Xfps[i], ms.Xfps[j] + if !sortsBefore(a, b) { + a, b = b, a + } + sum := sha256.Sum256(joinBytes(a, b)) + xfpPairHashes = append(xfpPairHashes, sum[:]) + } + } + + if len(ms.Xpubs) > 1 && ms.RequiredSigs > 1 { + parts, err := vault.Split(entropy, len(ms.Xpubs), ms.RequiredSigs) + if err != nil { + return nil, err + } + shares = append(shares, parts...) + } else { + // One key, or a 1-of-n: every holder gets the whole secret. + for range ms.Xpubs { + shares = append(shares, entropy) + } + } + + allXfps = append(allXfps, ms.Xfps...) + allXpubs = append(allXpubs, ms.Xpubs...) + } + + // Fingerprints first, then key bodies, in the order they appear. + plainParts := append([][]byte{}, allXfps...) + for _, xpub := range allXpubs { + plainParts = append(plainParts, xpub[4:]) + } + plaintext := joinBytes(plainParts...) + + stream, err := chacha20.NewUnauthenticatedCipher(derivedKey, zeroNonce) + if err != nil { + return nil, err + } + encryptedData := make([]byte, len(plaintext)) + stream.XORKeyStream(encryptedData, plaintext) + + var data [][]byte + for i, xpub := range allXpubs { + // Each share is locked to one key. The ciphertext and the index go + // into the key material, so no two shares ever share a key. That is + // what makes the all-zero nonce safe here. + keySum := sha256.Sum256(joinBytes(xpub[4:], encryptedData, NumberToBytes(i))) + aead, err := chacha20poly1305.New(keySum[:]) + if err != nil { + return nil, err + } + data = append(data, aead.Seal(nil, zeroNonce, shares[i], nil)) + } + data = append(data, encryptedData) + for _, h := range xfpPairHashes { + data = append(data, h[:xfpBytes]) + } + + stripped := stripXpub.ReplaceAllString(descriptor, "") + stripped = stripXfp.ReplaceAllString(stripped, "[") + stripped = strings.Split(stripped, "#")[0] + + return &EncryptResult{ + EncryptedText: stripped + base64Unpadded(joinBytes(data...)), + MissingXfps: len(allXfps) < len(allXpubs), + IsTestnet: testnetPattern.MatchString(descriptor), + }, nil +} diff --git a/internal/descriptorbackup/descriptor_test.go b/internal/descriptorbackup/descriptor_test.go new file mode 100644 index 0000000..731a9f8 --- /dev/null +++ b/internal/descriptorbackup/descriptor_test.go @@ -0,0 +1,131 @@ +package descriptorbackup + +import ( + "encoding/base64" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// fixedSecret is the 16-byte secret the published vector was made with, so +// that the deterministic parts of the output are reproducible. +var fixedSecret = []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + +type descriptorVector struct { + Descriptor string `json:"descriptor"` + EncryptedText string `json:"encryptedText"` + Xpubs []string `json:"xpubs"` +} + +func loadDescriptorVector(t *testing.T) descriptorVector { + t.Helper() + b, err := os.ReadFile(filepath.Join("../html/assets/src/crypto/testdata", "descriptor-vector.json")) + if err != nil { + t.Fatalf("reading the vector: %v", err) + } + var v descriptorVector + if err := json.Unmarshal(b, &v); err != nil { + t.Fatalf("parsing the vector: %v", err) + } + return v +} + +// deterministicParts mirrors the TypeScript test helper of the same name. The +// Shamir shares carry random x-coordinates and coefficients, so they differ on +// every run and are excluded on purpose. Everything else must match upstream. +func deterministicParts(t *testing.T, encryptedText string, numXfps, numXpubs, numPairs int) (stripped, ciphertext, tags string) { + t.Helper() + cut := strings.LastIndex(encryptedText, ")") + if cut < 0 { + t.Fatalf("this is not an encrypted descriptor") + } + stripped = encryptedText[:cut+1] + raw, err := base64.RawStdEncoding.DecodeString(encryptedText[cut+1:]) + if err != nil { + t.Fatalf("the text after the policy is not valid base64: %v", err) + } + // Each sealed share is the share plus a 16-byte authentication tag. + shareLen := 17 + 16 + dataLen := xfpBytes*numXfps + 74*numXpubs + at := shareLen * numXpubs + if at+dataLen > len(raw) { + t.Fatalf("the backup is shorter than its own layout: %d bytes", len(raw)) + } + ciphertext = hex.EncodeToString(raw[at : at+dataLen]) + at += dataLen + tags = hex.EncodeToString(raw[at : at+xfpBytes*numPairs]) + return stripped, ciphertext, tags +} + +func TestThresholdReproducesTheVector(t *testing.T) { + v := loadDescriptorVector(t) + got, err := EncryptDescriptorThreshold(v.Descriptor, fixedSecret) + if err != nil { + t.Fatalf("EncryptDescriptorThreshold: %v", err) + } + + gotStripped, gotCipher, gotTags := deterministicParts(t, got.EncryptedText, 3, 3, 3) + wantStripped, wantCipher, wantTags := deterministicParts(t, v.EncryptedText, 3, 3, 3) + + if gotStripped != wantStripped { + t.Errorf("stripped descriptor\n got %s\nwant %s", gotStripped, wantStripped) + } + if gotCipher != wantCipher { + t.Errorf("encrypted key block\n got %s\nwant %s", gotCipher, wantCipher) + } + if gotTags != wantTags { + t.Errorf("lookup tags\n got %s\nwant %s", gotTags, wantTags) + } + if len(got.EncryptedText) != len(v.EncryptedText) { + t.Errorf("total length: got %d want %d", len(got.EncryptedText), len(v.EncryptedText)) + } + if got.MissingXfps { + t.Error("MissingXfps should be false for the vector") + } + if got.IsTestnet { + t.Error("IsTestnet should be false for the vector") + } +} + +func TestNumberToBytesKeepsUpstreamEdgeCases(t *testing.T) { + // Zero contributes NO bytes. Pinned because multisigbackup.com does the + // same, and changing it would make our ciphertext unreadable there. + for _, c := range []struct { + n int + want string + }{ + {0, ""}, + {1, "01"}, + {255, "ff"}, + {256, "0100"}, + {65535, "ffff"}, + {65536, "010000"}, + } { + if got := hex.EncodeToString(NumberToBytes(c.n)); got != c.want { + t.Errorf("NumberToBytes(%d) = %q, want %q", c.n, got, c.want) + } + } +} + +func TestSortsBeforeUsesJavaScriptStringOrder(t *testing.T) { + // [2,0,0,0] sorts AFTER [10,0,0,0] because "2," beats "10,". Byte order + // would say the opposite, and would put the lookup tags in a different + // order than the tool a client falls back to. + a := []byte{2, 0, 0, 0} + b := []byte{10, 0, 0, 0} + if sortsBefore(a, b) { + t.Error("[2,0,0,0] must sort after [10,0,0,0], the way JavaScript compares them") + } + if !sortsBefore(b, a) { + t.Error("[10,0,0,0] must sort before [2,0,0,0]") + } +} + +func TestTaprootIsRefused(t *testing.T) { + if _, err := ParseDescriptor("tr(xpub.../<0;1>/*)"); err == nil { + t.Error("a taproot descriptor must be refused, the same as upstream") + } +} diff --git a/internal/html/assets/about.html b/internal/html/assets/about.html index b8d73e5..a65f8c6 100644 --- a/internal/html/assets/about.html +++ b/internal/html/assets/about.html @@ -1,12 +1,12 @@
-

Kaitiaki

+

Bitcoin Inheritance

A digital safe with more than one key, held by people you trust.

- Kaitiaki encrypts your files and splits the key among people you choose, with + Bitcoin Inheritance encrypts your files and splits the key among people you choose, with Shamir's Secret Sharing. You set how many of them must come together to recover the files. Two of three, three of five, whatever fits. No single guardian can open anything alone. @@ -23,15 +23,15 @@

Kaitiaki

-

Kaitiaki and Bitcoin Butlers

+

Bitcoin Inheritance and Bitcoin Butlers

- Kaitiaki is the Bitcoin Butlers fork of Rememory by eljojo. Kaitiaki is the te reo Māori word for a guardian. We use the tool for the records a multisig wallet needs beside its seeds. Those records are the wallet descriptor, the cosigner public keys and the instructions your family will need one day. + Bitcoin Inheritance is built by Bitcoin Butlers and is free software under the Apache licence; the source and its full attribution are in the repository. We use the tool for the records a multisig wallet needs beside its seeds. Those records are the wallet descriptor, the cosigner public keys and the instructions your family will need one day.

The tool is free, forever, and it runs on your own computer. The service is the practice. A Butler guides your placement session over video, and an annual drill rehearses the recovery with your guardians. We see screens, never bytes, and we never hold a bundle.

- Seed words never go in a bundle. Seeds belong on steel or in a codex32 kit. Read about the placement session. + Seed words never go in a bundle. Seeds belong on steel or in a codex32 kit. Read about the placement session.

@@ -60,10 +60,10 @@

What each guardian gets

Each guardian receives a self-contained bundle. It is a ZIP file with a recovery tool that works in any browser, offline, with no installation. If this project disappears, recovery still works.

- Every bundle includes contact details for the other guardians, so they can coordinate without you. + No bundle names another guardian. A guardian learns who else holds a piece from the owner's will or estate papers, never from what they are holding. That is what stops any two of them agreeing to open it between themselves.

    -
  • README.txt and README.pdf. Instructions, their own piece of the key, and the contact list.
  • +
  • README.txt and README.pdf. Instructions, their own piece of the key, and where to look for the other guardians.
  • recover.html. The recovery tool. For small archives the encrypted files are embedded in it.
  • MANIFEST.age. Your encrypted files, as a separate file when the archive is large.
  • METADATA.yaml. What the bundle is, in plain text, with the scheme version.
  • @@ -87,11 +87,38 @@

    On trust and verification

    See how it works

    1. Open Create bundles and add two or three guardians with made-up names.
    2. -
    3. Add one harmless file, then generate and download the bundles.
    4. -
    5. Unzip one bundle and open its recover.html in your browser.
    6. -
    7. Drag the README.txt files from the other bundles onto the page. When enough pieces are in, the file unlocks.
    8. +
    9. Add one harmless file.
    10. +
    11. Write what your heirs need to know. Each box shows you an example.
    12. +
    13. Generate the bundles and download them.
    14. +
    15. Unzip one bundle and open its recover.html. Drag the README.txt files from the other bundles onto the page. When enough pieces are in, the file unlocks.

    This is what a real recovery feels like. Do it once with a test file before you trust the tool with anything important.

    + +
    + Show me what each step looks like +
    +
    + The Guardians step, with two names filled in. +
    Name the people who will hold a piece of your recovery key.
    +
    +
    + The Files to Protect step, with files added. +
    Add the files you want to protect. They never leave your browser.
    +
    +
    + The step that asks what your heirs need to know, with example text in every box. +
    Two groups of prompts. Each one says where its text goes, and each box shows an example until you start typing.
    +
    +
    + The generated bundles, one per guardian. +
    One bundle per guardian, ready to download.
    +
    +
    + The recovery page with pieces arriving. +
    Pieces arriving. When enough are in, the files unlock on their own.
    +
    +
    +
    @@ -102,7 +129,7 @@

    It is:

    • A free tool that runs in your browser, offline*
    • A way to split the key to your backup among people you trust
    • -
    • Open source (Apache-2.0), built on Rememory
    • +
    • Open source (Apache-2.0)
    • Self-contained. Recovery works without this website
    @@ -116,7 +143,7 @@

    It isn't:

-

Kaitiaki keeps growing. Check the changelog to see what's new.

+

Bitcoin Inheritance keeps growing. Check the changelog to see what's new.