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.
-
+### 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.
-
-
+## What this does not protect against
-
-More pages
-
-
-
-
-
+- **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
-
+