Skip to content

Commit d5dba00

Browse files
committed
PR Review
1 parent 9de5c94 commit d5dba00

6 files changed

Lines changed: 145 additions & 34 deletions

File tree

internal/bundle/readme.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,9 @@ func GenerateReadme(data ReadmeData) string {
115115
sb.WriteString("--------------------------------------------------------------------------------\n")
116116

117117
// Word list (primary human-readable format)
118-
words := data.Share.Words()
118+
words, _ := data.Share.Words()
119119
if len(words) > 0 {
120-
sb.WriteString("YOUR 25 RECOVERY WORDS:\n\n")
120+
sb.WriteString(fmt.Sprintf("YOUR %d RECOVERY WORDS:\n\n", len(words)))
121121
half := (len(words) + 1) / 2
122122
for i := 0; i < half; i++ {
123123
left := fmt.Sprintf("%2d. %-14s", i+1, words[i])

internal/core/golden_test.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,6 @@ func combinations(n, k int) [][]int {
125125
return result
126126
}
127127

128-
129128
// --- Generator ---
130129

131130
// TestGenerateGoldenFixtures generates v2 golden test fixtures.
@@ -187,7 +186,7 @@ func TestGenerateGoldenFixtures(t *testing.T) {
187186
Checksum: share.Checksum,
188187
PEM: share.Encode(),
189188
Compact: share.CompactEncode(),
190-
Words: strings.Join(share.Words(), " "),
189+
Words: func() string { w, _ := share.Words(); return strings.Join(w, " ") }(),
191190
}
192191
}
193192

@@ -582,7 +581,10 @@ func TestGoldenV2WordEncoding(t *testing.T) {
582581
Index: gs.Index,
583582
Data: data,
584583
}
585-
words := share.Words()
584+
words, err := share.Words()
585+
if err != nil {
586+
t.Fatalf("Words() error: %v", err)
587+
}
586588
got := strings.Join(words, " ")
587589
if got != gs.Words {
588590
t.Errorf("word encoding mismatch:\n got: %s\n want: %s", got, gs.Words)

internal/core/wordlist.go

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ func DecodeWords(words []string) ([]byte, error) {
6464
// Convert words to 11-bit indices
6565
indices := make([]int, len(words))
6666
for i, w := range words {
67+
w = strings.ToLower(strings.TrimSpace(w))
6768
idx, ok := wordIndex[w]
6869
if !ok {
6970
suggestion := SuggestWord(w)
@@ -88,6 +89,8 @@ func DecodeWords(words []string) ([]byte, error) {
8889
}
8990

9091
// set11Bits writes an 11-bit value at the given bit offset in data.
92+
// Precondition: the target bits in data must be zero-initialized, as this
93+
// function only sets (ORs) 1-bits and never clears existing bits.
9194
func set11Bits(data []byte, bitOffset int, val int) {
9295
for b := 0; b < 11; b++ {
9396
byteIdx := (bitOffset + b) / 8
@@ -102,10 +105,10 @@ func set11Bits(data []byte, bitOffset int, val int) {
102105

103106
// Word 25 layout (11 bits total):
104107
//
105-
// ┌─────────────┬──────────────────────┐
106-
// │ index (4 hi) │ checksum (7 lo)
107-
// │ bits 10-7 │ bits 6-0
108-
// └─────────────┴──────────────────────┘
108+
// ┌──────────────┬────────────────────┐
109+
// │ index (4 hi) │ checksum (7 lo) │
110+
// │ bits 10-7 │ bits 6-0 │
111+
// └──────────────┴────────────────────┘
109112
//
110113
// Index: share index (1-based) stored in upper 4 bits.
111114
// - Shares 1–15: index stored directly.
@@ -119,10 +122,10 @@ func set11Bits(data []byte, bitOffset int, val int) {
119122
// happen to be valid BIP39 words.
120123
// - False positive rate: 1/128 (~0.8%).
121124
const (
122-
word25IndexBits = 4
123-
word25CheckBits = 7
124-
word25MaxIndex = (1 << word25IndexBits) - 1 // 15
125-
word25CheckMask = (1 << word25CheckBits) - 1 // 0x7F
125+
word25IndexBits = 4
126+
word25CheckBits = 7
127+
word25MaxIndex = (1 << word25IndexBits) - 1 // 15
128+
word25CheckMask = (1 << word25CheckBits) - 1 // 0x7F
126129
)
127130

128131
// word25Checksum computes the 7-bit checksum for the 25th word.
@@ -150,24 +153,27 @@ func word25Decode(val int) (index int, checksum int) {
150153
// Words returns this share's data encoded as 25 BIP39 words.
151154
// The first 24 words encode the share data (33 bytes = 264 bits, 11 bits per word).
152155
// The 25th word packs 4 bits of share index + 7 bits of checksum (see word25 layout above).
153-
// Returns nil for v1 shares, which use base64-encoded data unsuitable for word encoding.
154-
func (s *Share) Words() []string {
156+
// Returns an error for v1 shares or if the share index is negative.
157+
func (s *Share) Words() ([]string, error) {
155158
if s.Version < 2 {
156-
return nil
159+
return nil, fmt.Errorf("word encoding requires share version 2 or later (got v%d)", s.Version)
160+
}
161+
if s.Index < 0 {
162+
return nil, fmt.Errorf("share index must be non-negative (got %d)", s.Index)
157163
}
158164
words := EncodeWords(s.Data)
159165
bip39Idx := word25Encode(s.Index, s.Data)
160166
words = append(words, bip39English[bip39Idx])
161-
return words
167+
return words, nil
162168
}
163169

164170
// DecodeShareWords decodes 25 BIP39 words into share data and index.
165171
// The first 24 words are decoded to bytes; the 25th word carries index + checksum.
166172
// Returns index=0 if the share index was > 15 (the sentinel value).
167173
// Returns an error if the checksum doesn't match (wrong word order, typos, etc.).
168174
func DecodeShareWords(words []string) (data []byte, index int, err error) {
169-
if len(words) < 2 {
170-
return nil, 0, fmt.Errorf("need at least 2 words")
175+
if len(words) != 25 {
176+
return nil, 0, fmt.Errorf("expected 25 words, got %d", len(words))
171177
}
172178

173179
// Look up the 25th word in the BIP39 list

internal/core/wordlist_test.go

Lines changed: 114 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,10 @@ func TestShareWords(t *testing.T) {
155155
data[i] = byte(i)
156156
}
157157
share := NewShare(2, 1, 5, 3, "Alice", data)
158-
words := share.Words()
158+
words, err := share.Words()
159+
if err != nil {
160+
t.Fatalf("Words() error: %v", err)
161+
}
159162
if len(words) != 25 {
160163
t.Errorf("expected 25 words for 33-byte share (24 data + 1 meta), got %d", len(words))
161164
}
@@ -184,8 +187,8 @@ func TestDecodeShareWordsRoundTrip(t *testing.T) {
184187
{"index 5", 5, 5},
185188
{"index 15 (max exact)", 15, 15},
186189
{"index 16 (sentinel)", 16, 0}, // above 15 → stored as 0
187-
{"index 100 (sentinel)", 100, 0}, // above 15 → stored as 0
188-
{"index 255 (sentinel)", 255, 0}, // above 15 → stored as 0
190+
{"index 100 (sentinel)", 100, 0}, // above 15 → stored as 0
191+
{"index 255 (sentinel)", 255, 0}, // above 15 → stored as 0
189192
}
190193

191194
for _, tt := range tests {
@@ -195,7 +198,10 @@ func TestDecodeShareWordsRoundTrip(t *testing.T) {
195198
data[i] = byte(i * 7)
196199
}
197200
share := NewShare(2, tt.index, 5, 3, "Test", data)
198-
words := share.Words()
201+
words, err := share.Words()
202+
if err != nil {
203+
t.Fatalf("Words() error: %v", err)
204+
}
199205
if len(words) != 25 {
200206
t.Fatalf("expected 25 words, got %d", len(words))
201207
}
@@ -222,19 +228,22 @@ func TestWord25ChecksumDetectsTransposition(t *testing.T) {
222228
data[i] = byte(i * 13)
223229
}
224230
share := NewShare(2, 3, 5, 3, "Test", data)
225-
words := share.Words()
231+
words, err := share.Words()
232+
if err != nil {
233+
t.Fatalf("Words() error: %v", err)
234+
}
226235

227236
// Swap words 0 and 1 (adjacent transposition in data words)
228237
swapped := make([]string, len(words))
229238
copy(swapped, words)
230239
swapped[0], swapped[1] = swapped[1], swapped[0]
231240

232-
_, _, err := DecodeShareWords(swapped)
233-
if err == nil {
241+
_, _, decErr := DecodeShareWords(swapped)
242+
if decErr == nil {
234243
t.Error("expected checksum error for transposed words, got nil")
235244
}
236-
if !strings.Contains(err.Error(), "checksum") {
237-
t.Errorf("expected checksum error, got: %v", err)
245+
if !strings.Contains(decErr.Error(), "checksum") {
246+
t.Errorf("expected checksum error, got: %v", decErr)
238247
}
239248
}
240249

@@ -246,7 +255,10 @@ func TestWord25ChecksumDetectsSubstitution(t *testing.T) {
246255
data[i] = byte(i * 13)
247256
}
248257
share := NewShare(2, 3, 5, 3, "Test", data)
249-
words := share.Words()
258+
words, err := share.Words()
259+
if err != nil {
260+
t.Fatalf("Words() error: %v", err)
261+
}
250262

251263
// Replace word 5 with a different BIP39 word
252264
modified := make([]string, len(words))
@@ -258,7 +270,7 @@ func TestWord25ChecksumDetectsSubstitution(t *testing.T) {
258270
}
259271
modified[5] = replacement
260272

261-
_, _, err := DecodeShareWords(modified)
273+
_, _, err = DecodeShareWords(modified)
262274
if err == nil {
263275
t.Error("expected checksum error for substituted word, got nil")
264276
}
@@ -328,3 +340,94 @@ func TestWord25ChecksumDifferentData(t *testing.T) {
328340
t.Logf("warning: checksums collided (1/128 chance) — not a bug, but unexpected")
329341
}
330342
}
343+
344+
func TestWordsV1ShareReturnsError(t *testing.T) {
345+
data := make([]byte, 33)
346+
share := NewShare(1, 1, 5, 3, "Alice", data)
347+
_, err := share.Words()
348+
if err == nil {
349+
t.Fatal("expected error for v1 share")
350+
}
351+
if !strings.Contains(err.Error(), "version 2") {
352+
t.Errorf("expected version error, got: %v", err)
353+
}
354+
}
355+
356+
func TestDecodeShareWordsWrongCount(t *testing.T) {
357+
tests := []struct {
358+
name string
359+
count int
360+
}{
361+
{"0 words", 0},
362+
{"1 word", 1},
363+
{"10 words", 10},
364+
{"24 words", 24},
365+
{"26 words", 26},
366+
}
367+
368+
for _, tt := range tests {
369+
t.Run(tt.name, func(t *testing.T) {
370+
words := make([]string, tt.count)
371+
for i := range words {
372+
words[i] = "abandon"
373+
}
374+
_, _, err := DecodeShareWords(words)
375+
if err == nil {
376+
t.Fatalf("expected error for %d words", tt.count)
377+
}
378+
if !strings.Contains(err.Error(), "expected 25 words") {
379+
t.Errorf("expected word count error, got: %v", err)
380+
}
381+
})
382+
}
383+
}
384+
385+
func TestDecodeWordsMixedCase(t *testing.T) {
386+
data := make([]byte, 33)
387+
for i := range data {
388+
data[i] = byte(i * 7)
389+
}
390+
words := EncodeWords(data)
391+
392+
// Uppercase some words
393+
mixed := make([]string, len(words))
394+
for i, w := range words {
395+
if i%2 == 0 {
396+
mixed[i] = strings.ToUpper(w)
397+
} else {
398+
// Capitalize first letter
399+
mixed[i] = strings.ToUpper(w[:1]) + w[1:]
400+
}
401+
}
402+
403+
decoded, err := DecodeWords(mixed)
404+
if err != nil {
405+
t.Fatalf("DecodeWords should handle mixed case, got error: %v", err)
406+
}
407+
if !bytes.Equal(decoded, data) {
408+
t.Errorf("mixed-case round-trip mismatch")
409+
}
410+
}
411+
412+
func TestSuggestWordNoMatch(t *testing.T) {
413+
// Strings far from any BIP39 word (distance > 2)
414+
tests := []string{"zzzzzzz", "qqqqqq", "xylophone"}
415+
for _, input := range tests {
416+
got := SuggestWord(input)
417+
if got != "" {
418+
t.Errorf("SuggestWord(%q) = %q, want empty string (no close match)", input, got)
419+
}
420+
}
421+
}
422+
423+
func TestWordsNegativeIndexReturnsError(t *testing.T) {
424+
data := make([]byte, 33)
425+
share := NewShare(2, -1, 5, 3, "Alice", data)
426+
_, err := share.Words()
427+
if err == nil {
428+
t.Fatal("expected error for negative index")
429+
}
430+
if !strings.Contains(err.Error(), "non-negative") {
431+
t.Errorf("expected non-negative error, got: %v", err)
432+
}
433+
}

internal/html/assets/src/app.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -551,7 +551,7 @@ declare const t: TranslationFunction;
551551
} else {
552552
// Try to extract BIP39 words from the pasted text
553553
const extractedWords = extractWordsFromText(content);
554-
if (extractedWords.length >= 12) {
554+
if (extractedWords.length >= 25) {
555555
const wordResult = window.rememoryDecodeWords(extractedWords);
556556
if (!wordResult.error && wordResult.index > 0) {
557557
// Valid words found — add share directly (25th word provides the index)
@@ -1168,7 +1168,7 @@ declare const t: TranslationFunction;
11681168
numbered.push({ idx: parseInt(m[1], 10), word: m[2].toLowerCase() });
11691169
}
11701170

1171-
if (numbered.length >= 12) {
1171+
if (numbered.length >= 25) {
11721172
// Sort by number to handle two-column grids correctly
11731173
numbered.sort((a, b) => a.idx - b.idx);
11741174
return numbered.map(e => e.word);

internal/pdf/readme.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,9 +154,9 @@ func GenerateReadme(data ReadmeData) ([]byte, error) {
154154
pdf.Ln(8)
155155

156156
// Word grid (25 recovery words in two columns: 24 data words + 1 index word)
157-
words := data.Share.Words()
157+
words, _ := data.Share.Words()
158158
if len(words) > 0 {
159-
addSection(pdf, "YOUR 25 RECOVERY WORDS")
159+
addSection(pdf, fmt.Sprintf("YOUR %d RECOVERY WORDS", len(words)))
160160
pdf.SetFont(fontMono, "", bodySize)
161161

162162
half := (len(words) + 1) / 2

0 commit comments

Comments
 (0)