Skip to content

Commit 336c1f0

Browse files
committed
Add Word Lists using BIP39: 25 words for 33 bytes of shamir share
Encode each share as 25 human-readable words using the BIP39 English word list. The first 24 words encode the share data (33 bytes), and the 25th word encodes the share index — so recovering via words requires no extra input from the user. Words appear in README.txt, PDF, and can be typed or pasted directly into the recovery tool's paste area. The paste area auto-detects all formats: PEM blocks, compact strings, plain word lists, and numbered two-column grids.
1 parent b108fac commit 336c1f0

28 files changed

Lines changed: 2883 additions & 94 deletions

e2e/recovery.spec.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,82 @@ test.describe('Browser Recovery Tool', () => {
198198
await recovery.expectNeedMoreShares(1);
199199
});
200200

201+
test('recover via typed words in paste area', async ({ page }) => {
202+
const [aliceDir, bobDir] = extractBundles(bundlesDir, ['Alice', 'Bob']);
203+
const recovery = new RecoveryPage(page, aliceDir);
204+
205+
await recovery.open();
206+
207+
// Alice's share is pre-loaded via personalization
208+
await recovery.expectShareCount(1);
209+
210+
// Read Bob's README.txt to extract the share words
211+
const bobReadme = fs.readFileSync(path.join(bobDir, 'README.txt'), 'utf8');
212+
213+
// Extract words from the README.txt "YOUR 25 RECOVERY WORDS:" section
214+
const wordsMatch = bobReadme.match(/YOUR 25 RECOVERY WORDS:\n\n([\s\S]*?)\n\nRead these words/);
215+
expect(wordsMatch).not.toBeNull();
216+
217+
// Parse the two-column word grid into ordered word list
218+
const wordLines = wordsMatch![1].trim().split('\n');
219+
const leftWords: string[] = [];
220+
const rightWords: string[] = [];
221+
const half = 13; // 25 words: 13 left (1-13), 12 right (14-25)
222+
for (const line of wordLines) {
223+
// Each line has format: " 1. word 14. word"
224+
const matches = line.match(/\d+\.\s+(\S+)/g);
225+
if (matches) {
226+
for (const m of matches) {
227+
const wordMatch = m.match(/(\d+)\.\s+(\S+)/);
228+
if (wordMatch) {
229+
const idx = parseInt(wordMatch[1], 10);
230+
const word = wordMatch[2];
231+
if (idx <= half) {
232+
leftWords.push(word);
233+
} else {
234+
rightWords.push(word);
235+
}
236+
}
237+
}
238+
}
239+
}
240+
// Combine: left column (1-13) then right column (14-25)
241+
const words = [...leftWords, ...rightWords].join(' ');
242+
expect(words.split(' ').length).toBe(25);
243+
244+
// Type the 25 words into the paste area (includes index as 25th word)
245+
await recovery.clickPasteButton();
246+
await recovery.expectPasteAreaVisible();
247+
await recovery.pasteShare(words);
248+
await recovery.submitPaste();
249+
250+
// Bob's share should now be added (index extracted from 25th word)
251+
await recovery.expectShareCount(2);
252+
});
253+
254+
test('paste area accepts numbered word grid directly', async ({ page }) => {
255+
const [aliceDir, bobDir] = extractBundles(bundlesDir, ['Alice', 'Bob']);
256+
const recovery = new RecoveryPage(page, aliceDir);
257+
258+
await recovery.open();
259+
await recovery.expectShareCount(1);
260+
261+
// Read Bob's README.txt and extract the word grid section as-is
262+
const bobReadme = fs.readFileSync(path.join(bobDir, 'README.txt'), 'utf8');
263+
const wordsMatch = bobReadme.match(/YOUR 25 RECOVERY WORDS:\n\n([\s\S]*?)\n\nRead these words/);
264+
expect(wordsMatch).not.toBeNull();
265+
const wordGrid = wordsMatch![1]; // The numbered two-column grid
266+
267+
// Paste the word grid into the paste area
268+
await recovery.clickPasteButton();
269+
await recovery.expectPasteAreaVisible();
270+
await recovery.pasteShare(wordGrid);
271+
await recovery.submitPaste();
272+
273+
// Share should be added directly (index from 25th word, no manual input needed)
274+
await recovery.expectShareCount(2);
275+
});
276+
201277
test('detects duplicate shares', async ({ page }) => {
202278
const bundleDir = extractBundle(bundlesDir, 'Alice');
203279
const recovery = new RecoveryPage(page, bundleDir);

internal/bundle/readme.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,29 @@ func GenerateReadme(data ReadmeData) string {
111111

112112
// Share block
113113
sb.WriteString("--------------------------------------------------------------------------------\n")
114-
sb.WriteString("YOUR SHARE (upload this file or copy-paste this block)\n")
114+
sb.WriteString("YOUR SHARE\n")
115115
sb.WriteString("--------------------------------------------------------------------------------\n")
116+
117+
// Word list (primary human-readable format)
118+
words := data.Share.Words()
119+
if len(words) > 0 {
120+
sb.WriteString("YOUR 25 RECOVERY WORDS:\n\n")
121+
half := (len(words) + 1) / 2
122+
for i := 0; i < half; i++ {
123+
left := fmt.Sprintf("%2d. %-14s", i+1, words[i])
124+
if i+half < len(words) {
125+
right := fmt.Sprintf("%2d. %s", i+half+1, words[i+half])
126+
sb.WriteString(fmt.Sprintf("%s%s\n", left, right))
127+
} else {
128+
sb.WriteString(left + "\n")
129+
}
130+
}
131+
sb.WriteString("\nRead these words to the person helping you recover, or type them\n")
132+
sb.WriteString("into the recovery tool at recover.html.\n\n")
133+
}
134+
135+
// PEM block (machine-readable format)
136+
sb.WriteString("MACHINE-READABLE FORMAT (you can also upload this entire file):\n")
116137
sb.WriteString(data.Share.Encode())
117138
sb.WriteString("\n")
118139

0 commit comments

Comments
 (0)