diff --git a/Makefile b/Makefile index f49d0af..3bb674b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test test-e2e test-e2e-headed lint clean install wasm ts build-all bump-patch bump-minor bump-major man html serve demo +.PHONY: build test test-e2e test-e2e-headed lint clean install wasm ts build-all bump-patch bump-minor bump-major man html serve demo generate-fixtures BINARY := rememory VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null || echo "dev") @@ -34,6 +34,7 @@ install: wasm go install $(LDFLAGS) ./cmd/rememory test: + @test -f internal/html/assets/app.js && test -f $(BINARY) || $(MAKE) build go test -v ./... test-cover: @@ -59,6 +60,7 @@ clean: rm -f internal/html/assets/recover.wasm internal/html/assets/create.wasm rm -f internal/html/assets/app.js internal/html/assets/create-app.js internal/html/assets/shared.js internal/html/assets/types.js rm -rf dist/ man/ + go clean -testcache # Generate man pages man: build @@ -87,6 +89,10 @@ demo: build ./$(BINARY) demo open demo-recovery/output/bundles/bundle-alice.zip +# Regenerate golden test fixtures (one-time, output is committed) +generate-fixtures: + go test -v -run TestGenerateGoldenFixtures ./internal/core/ -args -generate + # Cross-compile for all platforms (used by CI) build-all: wasm @mkdir -p dist diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 39718c9..aa95185 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -98,6 +98,36 @@ export function extractAnonymousBundles(bundlesDir: string, shareNums: number[]) return shareNums.map(num => extractAnonymousBundle(bundlesDir, num)); } +// Extract the 25 recovery words from a README.txt file as a space-separated string +export function extractWordsFromReadme(readmePath: string): string { + const readme = fs.readFileSync(readmePath, 'utf8'); + const wordsMatch = readme.match(/YOUR 25 RECOVERY WORDS:\n\n([\s\S]*?)\n\nRead these words/); + if (!wordsMatch) throw new Error('Could not find recovery words in README.txt'); + + const wordLines = wordsMatch[1].trim().split('\n'); + const leftWords: string[] = []; + const rightWords: string[] = []; + const half = 13; // 25 words: 13 left (1-13), 12 right (14-25) + for (const line of wordLines) { + const matches = line.match(/\d+\.\s+(\S+)/g); + if (matches) { + for (const m of matches) { + const wordMatch = m.match(/(\d+)\.\s+(\S+)/); + if (wordMatch) { + const idx = parseInt(wordMatch[1], 10); + const word = wordMatch[2]; + if (idx <= half) { + leftWords.push(word); + } else { + rightWords.push(word); + } + } + } + } + } + return [...leftWords, ...rightWords].join(' '); +} + // Page helper class for recovery tool interactions export class RecoveryPage { constructor(private page: Page, private bundleDir: string) {} @@ -111,6 +141,15 @@ export class RecoveryPage { ); } + // Navigate to a standalone recover.html file (no personalization) + async openFile(htmlPath: string): Promise { + await this.page.goto(`file://${htmlPath}`); + await this.page.waitForFunction( + () => (window as any).rememoryAppReady === true, + { timeout: 30000 } + ); + } + // Add shares from README.txt files async addShares(...bundleDirs: string[]): Promise { const readmePaths = bundleDirs.map(dir => path.join(dir, 'README.txt')); diff --git a/e2e/recovery.spec.ts b/e2e/recovery.spec.ts index 0f7d0b6..3340fa7 100644 --- a/e2e/recovery.spec.ts +++ b/e2e/recovery.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from '@playwright/test'; import * as fs from 'fs'; import * as path from 'path'; +import * as os from 'os'; import { getRememoryBin, createTestProject, @@ -8,6 +9,8 @@ import { extractBundle, extractBundles, extractAnonymousBundles, + extractWordsFromReadme, + generateStandaloneHTML, RecoveryPage } from './helpers'; @@ -198,6 +201,52 @@ test.describe('Browser Recovery Tool', () => { await recovery.expectNeedMoreShares(1); }); + test('recover via typed words in paste area', async ({ page }) => { + const [aliceDir, bobDir] = extractBundles(bundlesDir, ['Alice', 'Bob']); + const recovery = new RecoveryPage(page, aliceDir); + + await recovery.open(); + + // Alice's share is pre-loaded via personalization + await recovery.expectShareCount(1); + + // Extract Bob's 25 recovery words from his README.txt + const words = extractWordsFromReadme(path.join(bobDir, 'README.txt')); + expect(words.split(' ').length).toBe(25); + + // Type the 25 words into the paste area (includes index as 25th word) + await recovery.clickPasteButton(); + await recovery.expectPasteAreaVisible(); + await recovery.pasteShare(words); + await recovery.submitPaste(); + + // Bob's share should now be added (index extracted from 25th word) + await recovery.expectShareCount(2); + }); + + test('paste area accepts numbered word grid directly', async ({ page }) => { + const [aliceDir, bobDir] = extractBundles(bundlesDir, ['Alice', 'Bob']); + const recovery = new RecoveryPage(page, aliceDir); + + await recovery.open(); + await recovery.expectShareCount(1); + + // Read Bob's README.txt and extract the word grid section as-is + const bobReadme = fs.readFileSync(path.join(bobDir, 'README.txt'), 'utf8'); + const wordsMatch = bobReadme.match(/YOUR 25 RECOVERY WORDS:\n\n([\s\S]*?)\n\nRead these words/); + expect(wordsMatch).not.toBeNull(); + const wordGrid = wordsMatch![1]; // The numbered two-column grid + + // Paste the word grid into the paste area + await recovery.clickPasteButton(); + await recovery.expectPasteAreaVisible(); + await recovery.pasteShare(wordGrid); + await recovery.submitPaste(); + + // Share should be added directly (index from 25th word, no manual input needed) + await recovery.expectShareCount(2); + }); + test('detects duplicate shares', async ({ page }) => { const bundleDir = extractBundle(bundlesDir, 'Alice'); const recovery = new RecoveryPage(page, bundleDir); @@ -287,3 +336,103 @@ test.describe('Anonymous Bundle Recovery', () => { await recovery.expectShareHolder('Share 2'); }); }); + +test.describe('Generic recover.html (no personalization)', () => { + let projectDir: string; + let bundlesDir: string; + let standaloneRecoverHtml: string; + let tmpDir: string; + + test.beforeAll(async () => { + const bin = getRememoryBin(); + if (!fs.existsSync(bin)) { + console.log(`Skipping tests: rememory binary not found at ${bin}`); + test.skip(); + return; + } + + projectDir = createTestProject(); + bundlesDir = path.join(projectDir, 'output', 'bundles'); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rememory-generic-e2e-')); + standaloneRecoverHtml = generateStandaloneHTML(tmpDir, 'recover'); + }); + + test.afterAll(async () => { + if (projectDir && fs.existsSync(projectDir)) { + fs.rmSync(projectDir, { recursive: true, force: true }); + } + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + test('words-only shares auto-recover when manifest is loaded', async ({ page }) => { + const [aliceDir, bobDir] = extractBundles(bundlesDir, ['Alice', 'Bob']); + const recovery = new RecoveryPage(page, tmpDir); + + await recovery.openFile(standaloneRecoverHtml); + await recovery.expectShareCount(0); + + // Paste Alice's words + const aliceWords = extractWordsFromReadme(path.join(aliceDir, 'README.txt')); + await recovery.clickPasteButton(); + await recovery.pasteShare(aliceWords); + await recovery.submitPaste(); + await recovery.expectShareCount(1); + + // Paste Bob's words + const bobWords = extractWordsFromReadme(path.join(bobDir, 'README.txt')); + await recovery.clickPasteButton(); + await recovery.pasteShare(bobWords); + await recovery.submitPaste(); + await recovery.expectShareCount(2); + + // Load manifest — recovery should auto-trigger (2 shares, threshold unknown) + await recovery.addManifest(aliceDir); + await recovery.expectManifestLoaded(); + + // Recovery should complete automatically + await recovery.expectRecoveryComplete(); + await recovery.expectFileCount(3); + await recovery.expectDownloadVisible(); + }); + + test('words-first entry recovers when second share provides threshold', async ({ page }) => { + const [aliceDir, bobDir] = extractBundles(bundlesDir, ['Alice', 'Bob']); + // Use a dummy bundleDir — we'll open the standalone HTML directly + const recovery = new RecoveryPage(page, tmpDir); + + await recovery.openFile(standaloneRecoverHtml); + + // No personalization — no shares pre-loaded + await recovery.expectShareCount(0); + + // Extract Alice's 25 recovery words from her README.txt + const aliceWords = extractWordsFromReadme(path.join(aliceDir, 'README.txt')); + expect(aliceWords.split(' ').length).toBe(25); + + // Paste Alice's words as the FIRST share (no threshold/total available) + await recovery.clickPasteButton(); + await recovery.expectPasteAreaVisible(); + await recovery.pasteShare(aliceWords); + await recovery.submitPaste(); + + // Alice's share should be added (index extracted from 25th word) + await recovery.expectShareCount(1); + + // Load manifest from Alice's bundle + await recovery.addManifest(aliceDir); + await recovery.expectManifestLoaded(); + + // Add Bob's share via README.txt file drop — this carries threshold/total + await recovery.addShares(bobDir); + + // Bob's share should be added and threshold should now be known + await recovery.expectShareCount(2); + + // Recovery should complete automatically (threshold backfilled from Bob's share) + await recovery.expectRecoveryComplete(); + await recovery.expectFileCount(3); // secret.txt, notes.txt, README.md + await recovery.expectDownloadVisible(); + }); +}); diff --git a/internal/bundle/readme.go b/internal/bundle/readme.go index cdb9209..290884f 100644 --- a/internal/bundle/readme.go +++ b/internal/bundle/readme.go @@ -111,8 +111,29 @@ func GenerateReadme(data ReadmeData) string { // Share block sb.WriteString("--------------------------------------------------------------------------------\n") - sb.WriteString("YOUR SHARE (upload this file or copy-paste this block)\n") + sb.WriteString("YOUR SHARE\n") sb.WriteString("--------------------------------------------------------------------------------\n") + + // Word list (primary human-readable format) + words, _ := data.Share.Words() + if len(words) > 0 { + sb.WriteString(fmt.Sprintf("YOUR %d RECOVERY WORDS:\n\n", len(words))) + half := (len(words) + 1) / 2 + for i := 0; i < half; i++ { + left := fmt.Sprintf("%2d. %-14s", i+1, words[i]) + if i+half < len(words) { + right := fmt.Sprintf("%2d. %s", i+half+1, words[i+half]) + sb.WriteString(fmt.Sprintf("%s%s\n", left, right)) + } else { + sb.WriteString(left + "\n") + } + } + sb.WriteString("\nRead these words to the person helping you recover, or type them\n") + sb.WriteString("into the recovery tool at recover.html.\n\n") + } + + // PEM block (machine-readable format) + sb.WriteString("MACHINE-READABLE FORMAT (you can also upload this entire file):\n") sb.WriteString(data.Share.Encode()) sb.WriteString("\n") diff --git a/internal/cmd/recover.go b/internal/cmd/recover.go index 5809e21..7783f96 100644 --- a/internal/cmd/recover.go +++ b/internal/cmd/recover.go @@ -70,6 +70,9 @@ func runRecover(cmd *cobra.Command, args []string) error { first := shares[0] for i, share := range shares[1:] { + if share.Version != first.Version { + return fmt.Errorf("share %d has different version (v%d vs v%d) — all shares must be from the same bundle", i+2, share.Version, first.Version) + } if share.Total != first.Total { return fmt.Errorf("share %d has different total (%d vs %d)", i+2, share.Total, first.Total) } @@ -101,15 +104,17 @@ func runRecover(cmd *cobra.Command, args []string) error { } // Reconstruct passphrase - passphrase, err := core.Combine(shareData) + recovered, err := core.Combine(shareData) if err != nil { return fmt.Errorf("combining shares: %w", err) } + passphrase := core.RecoverPassphrase(recovered, first.Version) + if recoverPassphrase { fmt.Println() fmt.Println("Recovered passphrase:") - fmt.Println(string(passphrase)) + fmt.Println(passphrase) return nil } @@ -132,7 +137,7 @@ func runRecover(cmd *cobra.Command, args []string) error { } var decryptedBuf bytes.Buffer - if err := core.Decrypt(&decryptedBuf, bytes.NewReader(encryptedData), string(passphrase)); err != nil { + if err := core.Decrypt(&decryptedBuf, bytes.NewReader(encryptedData), passphrase); err != nil { return fmt.Errorf("decryption failed (shares may be corrupted or from different operation): %w", err) } diff --git a/internal/cmd/seal.go b/internal/cmd/seal.go index dcd6985..8035623 100644 --- a/internal/cmd/seal.go +++ b/internal/cmd/seal.go @@ -2,6 +2,7 @@ package cmd import ( "bytes" + "encoding/base64" "fmt" "os" "path/filepath" @@ -104,8 +105,8 @@ func sealProject(p *project.Project, recoveryURL string) error { fmt.Printf(" Warning: %s\n", warning) } - // Generate passphrase - passphrase, err := crypto.GeneratePassphrase(crypto.DefaultPassphraseBytes) + // Generate passphrase (v2: split raw bytes, not the base64 string) + raw, passphrase, err := crypto.GenerateRawPassphrase(crypto.DefaultPassphraseBytes) if err != nil { return fmt.Errorf("generating passphrase: %w", err) } @@ -133,8 +134,8 @@ func sealProject(p *project.Project, recoveryURL string) error { fmt.Printf("Splitting into %d shares (threshold: %d)...\n", len(p.Friends), p.Threshold) - // Split the passphrase - shares, err := core.Split([]byte(passphrase), len(p.Friends), p.Threshold) + // Split the raw bytes (v2: 32 bytes instead of 43-byte base64 string) + shares, err := core.Split(raw, len(p.Friends), p.Threshold) if err != nil { return fmt.Errorf("splitting passphrase: %w", err) } @@ -143,7 +144,7 @@ func sealProject(p *project.Project, recoveryURL string) error { shareInfos := make([]project.ShareInfo, len(shares)) for i, shareData := range shares { friend := p.Friends[i] - share := core.NewShare(i+1, len(p.Friends), p.Threshold, friend.Name, shareData) + share := core.NewShare(2, i+1, len(p.Friends), p.Threshold, friend.Name, shareData) filename := share.Filename() sharePath := filepath.Join(sharesDir, filename) @@ -176,7 +177,7 @@ func sealProject(p *project.Project, recoveryURL string) error { fmt.Println("FAILED") return fmt.Errorf("verification failed: %w", err) } - if string(recovered) != passphrase { + if base64.RawURLEncoding.EncodeToString(recovered) != passphrase { fmt.Println("FAILED") return fmt.Errorf("verification failed: reconstructed passphrase doesn't match") } diff --git a/internal/core/bip39_english.go b/internal/core/bip39_english.go new file mode 100644 index 0000000..58a31e8 --- /dev/null +++ b/internal/core/bip39_english.go @@ -0,0 +1,2057 @@ +package core + +// BIP39 English word list (2048 words) +// Source: https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt +// SHA-256: 2f5eed53a4727b4bf8880d8f3f199efc90e58503646d9ff8eff3a2ed3b24dbda +// Verify upstream: curl -sL https://raw.githubusercontent.com/bitcoin/bips/ed7af6ae7e80c90bcfc69b3936073505e2fc2503/bip-0039/english.txt | shasum -a 256 +// Verify embedded: go test -v -run TestBIP39ListIntegrity ./internal/core/ +var bip39English = [2048]string{ + "abandon", + "ability", + "able", + "about", + "above", + "absent", + "absorb", + "abstract", + "absurd", + "abuse", + "access", + "accident", + "account", + "accuse", + "achieve", + "acid", + "acoustic", + "acquire", + "across", + "act", + "action", + "actor", + "actress", + "actual", + "adapt", + "add", + "addict", + "address", + "adjust", + "admit", + "adult", + "advance", + "advice", + "aerobic", + "affair", + "afford", + "afraid", + "again", + "age", + "agent", + "agree", + "ahead", + "aim", + "air", + "airport", + "aisle", + "alarm", + "album", + "alcohol", + "alert", + "alien", + "all", + "alley", + "allow", + "almost", + "alone", + "alpha", + "already", + "also", + "alter", + "always", + "amateur", + "amazing", + "among", + "amount", + "amused", + "analyst", + "anchor", + "ancient", + "anger", + "angle", + "angry", + "animal", + "ankle", + "announce", + "annual", + "another", + "answer", + "antenna", + "antique", + "anxiety", + "any", + "apart", + "apology", + "appear", + "apple", + "approve", + "april", + "arch", + "arctic", + "area", + "arena", + "argue", + "arm", + "armed", + "armor", + "army", + "around", + "arrange", + "arrest", + "arrive", + "arrow", + "art", + "artefact", + "artist", + "artwork", + "ask", + "aspect", + "assault", + "asset", + "assist", + "assume", + "asthma", + "athlete", + "atom", + "attack", + "attend", + "attitude", + "attract", + "auction", + "audit", + "august", + "aunt", + "author", + "auto", + "autumn", + "average", + "avocado", + "avoid", + "awake", + "aware", + "away", + "awesome", + "awful", + "awkward", + "axis", + "baby", + "bachelor", + "bacon", + "badge", + "bag", + "balance", + "balcony", + "ball", + "bamboo", + "banana", + "banner", + "bar", + "barely", + "bargain", + "barrel", + "base", + "basic", + "basket", + "battle", + "beach", + "bean", + "beauty", + "because", + "become", + "beef", + "before", + "begin", + "behave", + "behind", + "believe", + "below", + "belt", + "bench", + "benefit", + "best", + "betray", + "better", + "between", + "beyond", + "bicycle", + "bid", + "bike", + "bind", + "biology", + "bird", + "birth", + "bitter", + "black", + "blade", + "blame", + "blanket", + "blast", + "bleak", + "bless", + "blind", + "blood", + "blossom", + "blouse", + "blue", + "blur", + "blush", + "board", + "boat", + "body", + "boil", + "bomb", + "bone", + "bonus", + "book", + "boost", + "border", + "boring", + "borrow", + "boss", + "bottom", + "bounce", + "box", + "boy", + "bracket", + "brain", + "brand", + "brass", + "brave", + "bread", + "breeze", + "brick", + "bridge", + "brief", + "bright", + "bring", + "brisk", + "broccoli", + "broken", + "bronze", + "broom", + "brother", + "brown", + "brush", + "bubble", + "buddy", + "budget", + "buffalo", + "build", + "bulb", + "bulk", + "bullet", + "bundle", + "bunker", + "burden", + "burger", + "burst", + "bus", + "business", + "busy", + "butter", + "buyer", + "buzz", + "cabbage", + "cabin", + "cable", + "cactus", + "cage", + "cake", + "call", + "calm", + "camera", + "camp", + "can", + "canal", + "cancel", + "candy", + "cannon", + "canoe", + "canvas", + "canyon", + "capable", + "capital", + "captain", + "car", + "carbon", + "card", + "cargo", + "carpet", + "carry", + "cart", + "case", + "cash", + "casino", + "castle", + "casual", + "cat", + "catalog", + "catch", + "category", + "cattle", + "caught", + "cause", + "caution", + "cave", + "ceiling", + "celery", + "cement", + "census", + "century", + "cereal", + "certain", + "chair", + "chalk", + "champion", + "change", + "chaos", + "chapter", + "charge", + "chase", + "chat", + "cheap", + "check", + "cheese", + "chef", + "cherry", + "chest", + "chicken", + "chief", + "child", + "chimney", + "choice", + "choose", + "chronic", + "chuckle", + "chunk", + "churn", + "cigar", + "cinnamon", + "circle", + "citizen", + "city", + "civil", + "claim", + "clap", + "clarify", + "claw", + "clay", + "clean", + "clerk", + "clever", + "click", + "client", + "cliff", + "climb", + "clinic", + "clip", + "clock", + "clog", + "close", + "cloth", + "cloud", + "clown", + "club", + "clump", + "cluster", + "clutch", + "coach", + "coast", + "coconut", + "code", + "coffee", + "coil", + "coin", + "collect", + "color", + "column", + "combine", + "come", + "comfort", + "comic", + "common", + "company", + "concert", + "conduct", + "confirm", + "congress", + "connect", + "consider", + "control", + "convince", + "cook", + "cool", + "copper", + "copy", + "coral", + "core", + "corn", + "correct", + "cost", + "cotton", + "couch", + "country", + "couple", + "course", + "cousin", + "cover", + "coyote", + "crack", + "cradle", + "craft", + "cram", + "crane", + "crash", + "crater", + "crawl", + "crazy", + "cream", + "credit", + "creek", + "crew", + "cricket", + "crime", + "crisp", + "critic", + "crop", + "cross", + "crouch", + "crowd", + "crucial", + "cruel", + "cruise", + "crumble", + "crunch", + "crush", + "cry", + "crystal", + "cube", + "culture", + "cup", + "cupboard", + "curious", + "current", + "curtain", + "curve", + "cushion", + "custom", + "cute", + "cycle", + "dad", + "damage", + "damp", + "dance", + "danger", + "daring", + "dash", + "daughter", + "dawn", + "day", + "deal", + "debate", + "debris", + "decade", + "december", + "decide", + "decline", + "decorate", + "decrease", + "deer", + "defense", + "define", + "defy", + "degree", + "delay", + "deliver", + "demand", + "demise", + "denial", + "dentist", + "deny", + "depart", + "depend", + "deposit", + "depth", + "deputy", + "derive", + "describe", + "desert", + "design", + "desk", + "despair", + "destroy", + "detail", + "detect", + "develop", + "device", + "devote", + "diagram", + "dial", + "diamond", + "diary", + "dice", + "diesel", + "diet", + "differ", + "digital", + "dignity", + "dilemma", + "dinner", + "dinosaur", + "direct", + "dirt", + "disagree", + "discover", + "disease", + "dish", + "dismiss", + "disorder", + "display", + "distance", + "divert", + "divide", + "divorce", + "dizzy", + "doctor", + "document", + "dog", + "doll", + "dolphin", + "domain", + "donate", + "donkey", + "donor", + "door", + "dose", + "double", + "dove", + "draft", + "dragon", + "drama", + "drastic", + "draw", + "dream", + "dress", + "drift", + "drill", + "drink", + "drip", + "drive", + "drop", + "drum", + "dry", + "duck", + "dumb", + "dune", + "during", + "dust", + "dutch", + "duty", + "dwarf", + "dynamic", + "eager", + "eagle", + "early", + "earn", + "earth", + "easily", + "east", + "easy", + "echo", + "ecology", + "economy", + "edge", + "edit", + "educate", + "effort", + "egg", + "eight", + "either", + "elbow", + "elder", + "electric", + "elegant", + "element", + "elephant", + "elevator", + "elite", + "else", + "embark", + "embody", + "embrace", + "emerge", + "emotion", + "employ", + "empower", + "empty", + "enable", + "enact", + "end", + "endless", + "endorse", + "enemy", + "energy", + "enforce", + "engage", + "engine", + "enhance", + "enjoy", + "enlist", + "enough", + "enrich", + "enroll", + "ensure", + "enter", + "entire", + "entry", + "envelope", + "episode", + "equal", + "equip", + "era", + "erase", + "erode", + "erosion", + "error", + "erupt", + "escape", + "essay", + "essence", + "estate", + "eternal", + "ethics", + "evidence", + "evil", + "evoke", + "evolve", + "exact", + "example", + "excess", + "exchange", + "excite", + "exclude", + "excuse", + "execute", + "exercise", + "exhaust", + "exhibit", + "exile", + "exist", + "exit", + "exotic", + "expand", + "expect", + "expire", + "explain", + "expose", + "express", + "extend", + "extra", + "eye", + "eyebrow", + "fabric", + "face", + "faculty", + "fade", + "faint", + "faith", + "fall", + "false", + "fame", + "family", + "famous", + "fan", + "fancy", + "fantasy", + "farm", + "fashion", + "fat", + "fatal", + "father", + "fatigue", + "fault", + "favorite", + "feature", + "february", + "federal", + "fee", + "feed", + "feel", + "female", + "fence", + "festival", + "fetch", + "fever", + "few", + "fiber", + "fiction", + "field", + "figure", + "file", + "film", + "filter", + "final", + "find", + "fine", + "finger", + "finish", + "fire", + "firm", + "first", + "fiscal", + "fish", + "fit", + "fitness", + "fix", + "flag", + "flame", + "flash", + "flat", + "flavor", + "flee", + "flight", + "flip", + "float", + "flock", + "floor", + "flower", + "fluid", + "flush", + "fly", + "foam", + "focus", + "fog", + "foil", + "fold", + "follow", + "food", + "foot", + "force", + "forest", + "forget", + "fork", + "fortune", + "forum", + "forward", + "fossil", + "foster", + "found", + "fox", + "fragile", + "frame", + "frequent", + "fresh", + "friend", + "fringe", + "frog", + "front", + "frost", + "frown", + "frozen", + "fruit", + "fuel", + "fun", + "funny", + "furnace", + "fury", + "future", + "gadget", + "gain", + "galaxy", + "gallery", + "game", + "gap", + "garage", + "garbage", + "garden", + "garlic", + "garment", + "gas", + "gasp", + "gate", + "gather", + "gauge", + "gaze", + "general", + "genius", + "genre", + "gentle", + "genuine", + "gesture", + "ghost", + "giant", + "gift", + "giggle", + "ginger", + "giraffe", + "girl", + "give", + "glad", + "glance", + "glare", + "glass", + "glide", + "glimpse", + "globe", + "gloom", + "glory", + "glove", + "glow", + "glue", + "goat", + "goddess", + "gold", + "good", + "goose", + "gorilla", + "gospel", + "gossip", + "govern", + "gown", + "grab", + "grace", + "grain", + "grant", + "grape", + "grass", + "gravity", + "great", + "green", + "grid", + "grief", + "grit", + "grocery", + "group", + "grow", + "grunt", + "guard", + "guess", + "guide", + "guilt", + "guitar", + "gun", + "gym", + "habit", + "hair", + "half", + "hammer", + "hamster", + "hand", + "happy", + "harbor", + "hard", + "harsh", + "harvest", + "hat", + "have", + "hawk", + "hazard", + "head", + "health", + "heart", + "heavy", + "hedgehog", + "height", + "hello", + "helmet", + "help", + "hen", + "hero", + "hidden", + "high", + "hill", + "hint", + "hip", + "hire", + "history", + "hobby", + "hockey", + "hold", + "hole", + "holiday", + "hollow", + "home", + "honey", + "hood", + "hope", + "horn", + "horror", + "horse", + "hospital", + "host", + "hotel", + "hour", + "hover", + "hub", + "huge", + "human", + "humble", + "humor", + "hundred", + "hungry", + "hunt", + "hurdle", + "hurry", + "hurt", + "husband", + "hybrid", + "ice", + "icon", + "idea", + "identify", + "idle", + "ignore", + "ill", + "illegal", + "illness", + "image", + "imitate", + "immense", + "immune", + "impact", + "impose", + "improve", + "impulse", + "inch", + "include", + "income", + "increase", + "index", + "indicate", + "indoor", + "industry", + "infant", + "inflict", + "inform", + "inhale", + "inherit", + "initial", + "inject", + "injury", + "inmate", + "inner", + "innocent", + "input", + "inquiry", + "insane", + "insect", + "inside", + "inspire", + "install", + "intact", + "interest", + "into", + "invest", + "invite", + "involve", + "iron", + "island", + "isolate", + "issue", + "item", + "ivory", + "jacket", + "jaguar", + "jar", + "jazz", + "jealous", + "jeans", + "jelly", + "jewel", + "job", + "join", + "joke", + "journey", + "joy", + "judge", + "juice", + "jump", + "jungle", + "junior", + "junk", + "just", + "kangaroo", + "keen", + "keep", + "ketchup", + "key", + "kick", + "kid", + "kidney", + "kind", + "kingdom", + "kiss", + "kit", + "kitchen", + "kite", + "kitten", + "kiwi", + "knee", + "knife", + "knock", + "know", + "lab", + "label", + "labor", + "ladder", + "lady", + "lake", + "lamp", + "language", + "laptop", + "large", + "later", + "latin", + "laugh", + "laundry", + "lava", + "law", + "lawn", + "lawsuit", + "layer", + "lazy", + "leader", + "leaf", + "learn", + "leave", + "lecture", + "left", + "leg", + "legal", + "legend", + "leisure", + "lemon", + "lend", + "length", + "lens", + "leopard", + "lesson", + "letter", + "level", + "liar", + "liberty", + "library", + "license", + "life", + "lift", + "light", + "like", + "limb", + "limit", + "link", + "lion", + "liquid", + "list", + "little", + "live", + "lizard", + "load", + "loan", + "lobster", + "local", + "lock", + "logic", + "lonely", + "long", + "loop", + "lottery", + "loud", + "lounge", + "love", + "loyal", + "lucky", + "luggage", + "lumber", + "lunar", + "lunch", + "luxury", + "lyrics", + "machine", + "mad", + "magic", + "magnet", + "maid", + "mail", + "main", + "major", + "make", + "mammal", + "man", + "manage", + "mandate", + "mango", + "mansion", + "manual", + "maple", + "marble", + "march", + "margin", + "marine", + "market", + "marriage", + "mask", + "mass", + "master", + "match", + "material", + "math", + "matrix", + "matter", + "maximum", + "maze", + "meadow", + "mean", + "measure", + "meat", + "mechanic", + "medal", + "media", + "melody", + "melt", + "member", + "memory", + "mention", + "menu", + "mercy", + "merge", + "merit", + "merry", + "mesh", + "message", + "metal", + "method", + "middle", + "midnight", + "milk", + "million", + "mimic", + "mind", + "minimum", + "minor", + "minute", + "miracle", + "mirror", + "misery", + "miss", + "mistake", + "mix", + "mixed", + "mixture", + "mobile", + "model", + "modify", + "mom", + "moment", + "monitor", + "monkey", + "monster", + "month", + "moon", + "moral", + "more", + "morning", + "mosquito", + "mother", + "motion", + "motor", + "mountain", + "mouse", + "move", + "movie", + "much", + "muffin", + "mule", + "multiply", + "muscle", + "museum", + "mushroom", + "music", + "must", + "mutual", + "myself", + "mystery", + "myth", + "naive", + "name", + "napkin", + "narrow", + "nasty", + "nation", + "nature", + "near", + "neck", + "need", + "negative", + "neglect", + "neither", + "nephew", + "nerve", + "nest", + "net", + "network", + "neutral", + "never", + "news", + "next", + "nice", + "night", + "noble", + "noise", + "nominee", + "noodle", + "normal", + "north", + "nose", + "notable", + "note", + "nothing", + "notice", + "novel", + "now", + "nuclear", + "number", + "nurse", + "nut", + "oak", + "obey", + "object", + "oblige", + "obscure", + "observe", + "obtain", + "obvious", + "occur", + "ocean", + "october", + "odor", + "off", + "offer", + "office", + "often", + "oil", + "okay", + "old", + "olive", + "olympic", + "omit", + "once", + "one", + "onion", + "online", + "only", + "open", + "opera", + "opinion", + "oppose", + "option", + "orange", + "orbit", + "orchard", + "order", + "ordinary", + "organ", + "orient", + "original", + "orphan", + "ostrich", + "other", + "outdoor", + "outer", + "output", + "outside", + "oval", + "oven", + "over", + "own", + "owner", + "oxygen", + "oyster", + "ozone", + "pact", + "paddle", + "page", + "pair", + "palace", + "palm", + "panda", + "panel", + "panic", + "panther", + "paper", + "parade", + "parent", + "park", + "parrot", + "party", + "pass", + "patch", + "path", + "patient", + "patrol", + "pattern", + "pause", + "pave", + "payment", + "peace", + "peanut", + "pear", + "peasant", + "pelican", + "pen", + "penalty", + "pencil", + "people", + "pepper", + "perfect", + "permit", + "person", + "pet", + "phone", + "photo", + "phrase", + "physical", + "piano", + "picnic", + "picture", + "piece", + "pig", + "pigeon", + "pill", + "pilot", + "pink", + "pioneer", + "pipe", + "pistol", + "pitch", + "pizza", + "place", + "planet", + "plastic", + "plate", + "play", + "please", + "pledge", + "pluck", + "plug", + "plunge", + "poem", + "poet", + "point", + "polar", + "pole", + "police", + "pond", + "pony", + "pool", + "popular", + "portion", + "position", + "possible", + "post", + "potato", + "pottery", + "poverty", + "powder", + "power", + "practice", + "praise", + "predict", + "prefer", + "prepare", + "present", + "pretty", + "prevent", + "price", + "pride", + "primary", + "print", + "priority", + "prison", + "private", + "prize", + "problem", + "process", + "produce", + "profit", + "program", + "project", + "promote", + "proof", + "property", + "prosper", + "protect", + "proud", + "provide", + "public", + "pudding", + "pull", + "pulp", + "pulse", + "pumpkin", + "punch", + "pupil", + "puppy", + "purchase", + "purity", + "purpose", + "purse", + "push", + "put", + "puzzle", + "pyramid", + "quality", + "quantum", + "quarter", + "question", + "quick", + "quit", + "quiz", + "quote", + "rabbit", + "raccoon", + "race", + "rack", + "radar", + "radio", + "rail", + "rain", + "raise", + "rally", + "ramp", + "ranch", + "random", + "range", + "rapid", + "rare", + "rate", + "rather", + "raven", + "raw", + "razor", + "ready", + "real", + "reason", + "rebel", + "rebuild", + "recall", + "receive", + "recipe", + "record", + "recycle", + "reduce", + "reflect", + "reform", + "refuse", + "region", + "regret", + "regular", + "reject", + "relax", + "release", + "relief", + "rely", + "remain", + "remember", + "remind", + "remove", + "render", + "renew", + "rent", + "reopen", + "repair", + "repeat", + "replace", + "report", + "require", + "rescue", + "resemble", + "resist", + "resource", + "response", + "result", + "retire", + "retreat", + "return", + "reunion", + "reveal", + "review", + "reward", + "rhythm", + "rib", + "ribbon", + "rice", + "rich", + "ride", + "ridge", + "rifle", + "right", + "rigid", + "ring", + "riot", + "ripple", + "risk", + "ritual", + "rival", + "river", + "road", + "roast", + "robot", + "robust", + "rocket", + "romance", + "roof", + "rookie", + "room", + "rose", + "rotate", + "rough", + "round", + "route", + "royal", + "rubber", + "rude", + "rug", + "rule", + "run", + "runway", + "rural", + "sad", + "saddle", + "sadness", + "safe", + "sail", + "salad", + "salmon", + "salon", + "salt", + "salute", + "same", + "sample", + "sand", + "satisfy", + "satoshi", + "sauce", + "sausage", + "save", + "say", + "scale", + "scan", + "scare", + "scatter", + "scene", + "scheme", + "school", + "science", + "scissors", + "scorpion", + "scout", + "scrap", + "screen", + "script", + "scrub", + "sea", + "search", + "season", + "seat", + "second", + "secret", + "section", + "security", + "seed", + "seek", + "segment", + "select", + "sell", + "seminar", + "senior", + "sense", + "sentence", + "series", + "service", + "session", + "settle", + "setup", + "seven", + "shadow", + "shaft", + "shallow", + "share", + "shed", + "shell", + "sheriff", + "shield", + "shift", + "shine", + "ship", + "shiver", + "shock", + "shoe", + "shoot", + "shop", + "short", + "shoulder", + "shove", + "shrimp", + "shrug", + "shuffle", + "shy", + "sibling", + "sick", + "side", + "siege", + "sight", + "sign", + "silent", + "silk", + "silly", + "silver", + "similar", + "simple", + "since", + "sing", + "siren", + "sister", + "situate", + "six", + "size", + "skate", + "sketch", + "ski", + "skill", + "skin", + "skirt", + "skull", + "slab", + "slam", + "sleep", + "slender", + "slice", + "slide", + "slight", + "slim", + "slogan", + "slot", + "slow", + "slush", + "small", + "smart", + "smile", + "smoke", + "smooth", + "snack", + "snake", + "snap", + "sniff", + "snow", + "soap", + "soccer", + "social", + "sock", + "soda", + "soft", + "solar", + "soldier", + "solid", + "solution", + "solve", + "someone", + "song", + "soon", + "sorry", + "sort", + "soul", + "sound", + "soup", + "source", + "south", + "space", + "spare", + "spatial", + "spawn", + "speak", + "special", + "speed", + "spell", + "spend", + "sphere", + "spice", + "spider", + "spike", + "spin", + "spirit", + "split", + "spoil", + "sponsor", + "spoon", + "sport", + "spot", + "spray", + "spread", + "spring", + "spy", + "square", + "squeeze", + "squirrel", + "stable", + "stadium", + "staff", + "stage", + "stairs", + "stamp", + "stand", + "start", + "state", + "stay", + "steak", + "steel", + "stem", + "step", + "stereo", + "stick", + "still", + "sting", + "stock", + "stomach", + "stone", + "stool", + "story", + "stove", + "strategy", + "street", + "strike", + "strong", + "struggle", + "student", + "stuff", + "stumble", + "style", + "subject", + "submit", + "subway", + "success", + "such", + "sudden", + "suffer", + "sugar", + "suggest", + "suit", + "summer", + "sun", + "sunny", + "sunset", + "super", + "supply", + "supreme", + "sure", + "surface", + "surge", + "surprise", + "surround", + "survey", + "suspect", + "sustain", + "swallow", + "swamp", + "swap", + "swarm", + "swear", + "sweet", + "swift", + "swim", + "swing", + "switch", + "sword", + "symbol", + "symptom", + "syrup", + "system", + "table", + "tackle", + "tag", + "tail", + "talent", + "talk", + "tank", + "tape", + "target", + "task", + "taste", + "tattoo", + "taxi", + "teach", + "team", + "tell", + "ten", + "tenant", + "tennis", + "tent", + "term", + "test", + "text", + "thank", + "that", + "theme", + "then", + "theory", + "there", + "they", + "thing", + "this", + "thought", + "three", + "thrive", + "throw", + "thumb", + "thunder", + "ticket", + "tide", + "tiger", + "tilt", + "timber", + "time", + "tiny", + "tip", + "tired", + "tissue", + "title", + "toast", + "tobacco", + "today", + "toddler", + "toe", + "together", + "toilet", + "token", + "tomato", + "tomorrow", + "tone", + "tongue", + "tonight", + "tool", + "tooth", + "top", + "topic", + "topple", + "torch", + "tornado", + "tortoise", + "toss", + "total", + "tourist", + "toward", + "tower", + "town", + "toy", + "track", + "trade", + "traffic", + "tragic", + "train", + "transfer", + "trap", + "trash", + "travel", + "tray", + "treat", + "tree", + "trend", + "trial", + "tribe", + "trick", + "trigger", + "trim", + "trip", + "trophy", + "trouble", + "truck", + "true", + "truly", + "trumpet", + "trust", + "truth", + "try", + "tube", + "tuition", + "tumble", + "tuna", + "tunnel", + "turkey", + "turn", + "turtle", + "twelve", + "twenty", + "twice", + "twin", + "twist", + "two", + "type", + "typical", + "ugly", + "umbrella", + "unable", + "unaware", + "uncle", + "uncover", + "under", + "undo", + "unfair", + "unfold", + "unhappy", + "uniform", + "unique", + "unit", + "universe", + "unknown", + "unlock", + "until", + "unusual", + "unveil", + "update", + "upgrade", + "uphold", + "upon", + "upper", + "upset", + "urban", + "urge", + "usage", + "use", + "used", + "useful", + "useless", + "usual", + "utility", + "vacant", + "vacuum", + "vague", + "valid", + "valley", + "valve", + "van", + "vanish", + "vapor", + "various", + "vast", + "vault", + "vehicle", + "velvet", + "vendor", + "venture", + "venue", + "verb", + "verify", + "version", + "very", + "vessel", + "veteran", + "viable", + "vibrant", + "vicious", + "victory", + "video", + "view", + "village", + "vintage", + "violin", + "virtual", + "virus", + "visa", + "visit", + "visual", + "vital", + "vivid", + "vocal", + "voice", + "void", + "volcano", + "volume", + "vote", + "voyage", + "wage", + "wagon", + "wait", + "walk", + "wall", + "walnut", + "want", + "warfare", + "warm", + "warrior", + "wash", + "wasp", + "waste", + "water", + "wave", + "way", + "wealth", + "weapon", + "wear", + "weasel", + "weather", + "web", + "wedding", + "weekend", + "weird", + "welcome", + "west", + "wet", + "whale", + "what", + "wheat", + "wheel", + "when", + "where", + "whip", + "whisper", + "wide", + "width", + "wife", + "wild", + "will", + "win", + "window", + "wine", + "wing", + "wink", + "winner", + "winter", + "wire", + "wisdom", + "wise", + "wish", + "witness", + "wolf", + "woman", + "wonder", + "wood", + "wool", + "word", + "work", + "world", + "worry", + "worth", + "wrap", + "wreck", + "wrestle", + "wrist", + "write", + "wrong", + "yard", + "year", + "yellow", + "you", + "young", + "youth", + "zebra", + "zero", + "zone", + "zoo", +} diff --git a/internal/core/core_test.go b/internal/core/core_test.go index c588110..fe294ee 100644 --- a/internal/core/core_test.go +++ b/internal/core/core_test.go @@ -192,7 +192,7 @@ func TestValidateShamirParams(t *testing.T) { } func TestShareEncodeDecode(t *testing.T) { - original := NewShare(1, 5, 3, "Alice", []byte("test-share-data")) + original := NewShare(1, 1, 5, 3, "Alice", []byte("test-share-data")) encoded := original.Encode() @@ -225,7 +225,7 @@ func TestShareEncodeDecode(t *testing.T) { } func TestShareVerify(t *testing.T) { - share := NewShare(1, 5, 3, "Alice", []byte("test-data")) + share := NewShare(1, 1, 5, 3, "Alice", []byte("test-data")) // Valid checksum if err := share.Verify(); err != nil { @@ -251,7 +251,7 @@ func TestShareFilename(t *testing.T) { } for _, tt := range tests { - share := NewShare(1, 3, 2, tt.holder, []byte("data")) + share := NewShare(1, 1, 3, 2, tt.holder, []byte("data")) got := share.Filename() if got != tt.expected { t.Errorf("holder %q: got %q, want %q", tt.holder, got, tt.expected) @@ -260,7 +260,7 @@ func TestShareFilename(t *testing.T) { } func TestCompactEncodeRoundTrip(t *testing.T) { - original := NewShare(1, 5, 3, "Alice", []byte("test-share-data-1234567890")) + original := NewShare(1, 1, 5, 3, "Alice", []byte("test-share-data-1234567890")) compact := original.CompactEncode() @@ -297,7 +297,7 @@ func TestCompactEncodeWithRealShares(t *testing.T) { } for i, shareData := range shares { - share := NewShare(i+1, 5, 3, "", shareData) + share := NewShare(1, i+1, 5, 3, "", shareData) compact := share.CompactEncode() decoded, err := ParseCompact(compact) @@ -311,7 +311,7 @@ func TestCompactEncodeWithRealShares(t *testing.T) { } func TestCompactEncodeFormat(t *testing.T) { - share := NewShare(2, 5, 3, "Bob", []byte{0xDE, 0xAD, 0xBE, 0xEF}) + share := NewShare(1, 2, 5, 3, "Bob", []byte{0xDE, 0xAD, 0xBE, 0xEF}) compact := share.CompactEncode() if !strings.HasPrefix(compact, "RM1:") { @@ -346,7 +346,7 @@ func TestCompactEncodeFormat(t *testing.T) { func TestParseCompactRejectsBadInput(t *testing.T) { // Build a valid compact string to use as a base - share := NewShare(1, 5, 3, "Alice", []byte("valid-data")) + share := NewShare(1, 1, 5, 3, "Alice", []byte("valid-data")) valid := share.CompactEncode() tests := []struct { @@ -381,7 +381,7 @@ func TestParseCompactRejectsBadInput(t *testing.T) { func TestCompactEncodeNoHolderOrCreated(t *testing.T) { // Compact format intentionally omits Holder and Created metadata // to keep the string short for QR codes - share := NewShare(3, 7, 4, "Carol with spaces", []byte("some-share-data")) + share := NewShare(1, 3, 7, 4, "Carol with spaces", []byte("some-share-data")) compact := share.CompactEncode() decoded, err := ParseCompact(compact) if err != nil { diff --git a/internal/core/golden_test.go b/internal/core/golden_test.go new file mode 100644 index 0000000..168f37b --- /dev/null +++ b/internal/core/golden_test.go @@ -0,0 +1,612 @@ +package core + +import ( + "bytes" + "encoding/base64" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +var generate = flag.Bool("generate", false, "regenerate golden test fixtures (writes to testdata/)") + +// --- JSON fixture types --- + +type goldenFixture struct { + Version int `json:"version"` + Passphrase string `json:"passphrase"` + Total int `json:"total"` + Threshold int `json:"threshold"` + Created string `json:"created"` + Shares []goldenShare `json:"shares"` + Manifest goldenManifest `json:"manifest"` +} + +type goldenShare struct { + Index int `json:"index"` + Holder string `json:"holder"` + DataHex string `json:"data_hex"` + Checksum string `json:"checksum"` + PEM string `json:"pem"` + Compact string `json:"compact"` + Words string `json:"words,omitempty"` // space-separated BIP39 words (v2 only) +} + +type goldenManifest struct { + Files map[string]string `json:"files"` +} + +// --- Constants for golden fixtures --- + +const ( + // goldenPassphrase is a fixed base64url string (43 chars, represents 32 bytes). + // This mimics the output of crypto.GeneratePassphrase(32) but is deterministic. + // Decodes to "this_is_a_test_passphrase_v2_gld" (exactly 32 bytes). + // Shamir adds a 1-byte coordinate to each share, so 32-byte passphrase → 33-byte shares. + // 33 bytes = 264 bits → exactly 24 BIP39 words (11 bits each) + 1 index word = 25 words. + goldenPassphrase = "dGhpc19pc19hX3Rlc3RfcGFzc3BocmFzZV92Ml9nbGQ" + + // goldenCreated is the fixed timestamp for all golden shares. + goldenCreated = "2025-01-01 00:00" + + // goldenCreatedFormat is the Go time format for parsing goldenCreated. + goldenCreatedFormat = "2006-01-02 15:04" +) + +var goldenHolders = []string{"Alice", "Bob", "Carol", "David", "Eve"} + +// goldenManifestFiles are the known files inside the golden test manifest. +var goldenManifestFiles = map[string]string{ + "manifest/README.md": "# Golden Test Manifest\n\nThis is a test manifest for v1 golden fixtures.\n", + "manifest/secret.txt": "The secret passphrase is: correct-horse-battery-staple\n", +} + +// --- Helpers --- + +func loadGoldenJSON(t *testing.T, filename string) goldenFixture { + t.Helper() + data, err := os.ReadFile(filepath.Join("testdata", filename)) + if err != nil { + t.Fatalf("reading %s: %v", filename, err) + } + var golden goldenFixture + if err := json.Unmarshal(data, &golden); err != nil { + t.Fatalf("unmarshaling %s: %v", filename, err) + } + return golden +} + +func mustDecodeHex(t *testing.T, s string) []byte { + t.Helper() + data, err := hex.DecodeString(s) + if err != nil { + t.Fatalf("decoding hex: %v", err) + } + return data +} + +// parseCreatedTime parses a created timestamp, trying short format first then RFC3339. +func parseCreatedTime(t *testing.T, s string) time.Time { + t.Helper() + if parsed, err := time.Parse("2006-01-02 15:04", s); err == nil { + return parsed + } + if parsed, err := time.Parse(time.RFC3339, s); err == nil { + return parsed + } + t.Fatalf("cannot parse created time %q", s) + return time.Time{} +} + +// combinations returns all k-element subsets of {0, 1, ..., n-1}. +func combinations(n, k int) [][]int { + var result [][]int + combo := make([]int, k) + var gen func(start, depth int) + gen = func(start, depth int) { + if depth == k { + dup := make([]int, k) + copy(dup, combo) + result = append(result, dup) + return + } + for i := start; i < n; i++ { + combo[depth] = i + gen(i+1, depth+1) + } + } + gen(0, 0) + return result +} + +// --- Generator --- + +// TestGenerateGoldenFixtures generates v2 golden test fixtures. +// V1 fixtures are immutable and checked into the repo — this generator never touches them. +// Run once with: go test -v -run TestGenerateGoldenFixtures -generate ./internal/core/ +func TestGenerateGoldenFixtures(t *testing.T) { + if !*generate { + t.Skip("skipping fixture generation (use -generate flag to regenerate)") + } + + createdTime, err := time.Parse(goldenCreatedFormat, goldenCreated) + if err != nil { + t.Fatalf("parsing created time: %v", err) + } + + // Build manifest archive (shared by both v1 and v2) + archiveData := createTarGz(t, goldenManifestFiles) + + // Decode the passphrase to get the raw 32 bytes (v2 splits these directly) + rawPassphrase, err := base64.RawURLEncoding.DecodeString(goldenPassphrase) + if err != nil { + t.Fatalf("decoding goldenPassphrase: %v", err) + } + + // Split the raw bytes (32 bytes → 33-byte shares) + rawSharesV2, err := Split(rawPassphrase, 5, 3) + if err != nil { + t.Fatalf("splitting raw passphrase: %v", err) + } + + // Verify reconstruction: combine → base64url-encode → should match passphrase + recoveredV2, err := Combine(rawSharesV2[:3]) + if err != nil { + t.Fatalf("combining v2 shares: %v", err) + } + if RecoverPassphrase(recoveredV2, 2) != goldenPassphrase { + t.Fatal("v2 share reconstruction failed — shares are broken") + } + + // Build v2 shares with fixed metadata + sharesV2 := make([]*Share, 5) + goldenSharesV2 := make([]goldenShare, 5) + for i := 0; i < 5; i++ { + share := &Share{ + Version: 2, + Index: i + 1, + Total: 5, + Threshold: 3, + Holder: goldenHolders[i], + Created: createdTime, + Data: rawSharesV2[i], + Checksum: HashBytes(rawSharesV2[i]), + } + sharesV2[i] = share + goldenSharesV2[i] = goldenShare{ + Index: share.Index, + Holder: share.Holder, + DataHex: hex.EncodeToString(share.Data), + Checksum: share.Checksum, + PEM: share.Encode(), + Compact: share.CompactEncode(), + Words: func() string { w, _ := share.Words(); return strings.Join(w, " ") }(), + } + } + + // Encrypt manifest for v2 + var encryptedBufV2 bytes.Buffer + if err := Encrypt(&encryptedBufV2, bytes.NewReader(archiveData), goldenPassphrase); err != nil { + t.Fatalf("encrypting v2 manifest: %v", err) + } + + // Build v2 fixture JSON + fixtureV2 := goldenFixture{ + Version: 2, + Passphrase: goldenPassphrase, + Total: 5, + Threshold: 3, + Created: goldenCreated, + Shares: goldenSharesV2, + Manifest: goldenManifest{ + Files: goldenManifestFiles, + }, + } + + fixtureJSONV2, err := json.MarshalIndent(fixtureV2, "", " ") + if err != nil { + t.Fatalf("marshaling v2 fixture JSON: %v", err) + } + + // Create v2 directories + bundleDirV2 := filepath.Join("testdata", "v2-bundle") + expectedDirV2 := filepath.Join(bundleDirV2, "expected-output") + if err := os.MkdirAll(expectedDirV2, 0755); err != nil { + t.Fatalf("creating v2 directories: %v", err) + } + + // Write v2-golden.json + jsonPathV2 := filepath.Join("testdata", "v2-golden.json") + if err := os.WriteFile(jsonPathV2, fixtureJSONV2, 0644); err != nil { + t.Fatalf("writing %s: %v", jsonPathV2, err) + } + t.Logf("wrote %s", jsonPathV2) + + // Write v2 share PEM files + for _, share := range sharesV2 { + filename := fmt.Sprintf("SHARE-%s.txt", strings.ToLower(share.Holder)) + sharePath := filepath.Join(bundleDirV2, filename) + if err := os.WriteFile(sharePath, []byte(share.Encode()), 0644); err != nil { + t.Fatalf("writing %s: %v", sharePath, err) + } + t.Logf("wrote %s", sharePath) + } + + // Write v2 MANIFEST.age + manifestPathV2 := filepath.Join(bundleDirV2, "MANIFEST.age") + if err := os.WriteFile(manifestPathV2, encryptedBufV2.Bytes(), 0644); err != nil { + t.Fatalf("writing %s: %v", manifestPathV2, err) + } + t.Logf("wrote %s (%d bytes)", manifestPathV2, encryptedBufV2.Len()) + + // Write v2 expected output files + for name, content := range goldenManifestFiles { + outPath := filepath.Join(expectedDirV2, name) + if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil { + t.Fatalf("creating dir for %s: %v", outPath, err) + } + if err := os.WriteFile(outPath, []byte(content), 0644); err != nil { + t.Fatalf("writing %s: %v", outPath, err) + } + t.Logf("wrote %s", outPath) + } + + t.Log("V2 golden fixtures generated successfully.") + t.Log("Commit the testdata/v2-* files. V1 fixtures are immutable and must not be regenerated.") +} + +// --- Golden tests (table-driven across v1 and v2) --- + +// goldenVersion defines a fixture version for table-driven golden tests. +type goldenVersion struct { + name string // "v1" or "v2" + fixture string // JSON fixture filename + bundleDir string // testdata subdirectory with share PEM files and MANIFEST.age +} + +var goldenVersions = []goldenVersion{ + {"v1", "v1-golden.json", "v1-bundle"}, + {"v2", "v2-golden.json", "v2-bundle"}, +} + +// TestGoldenShareParsing parses each fixture share and verifies all fields match. +func TestGoldenShareParsing(t *testing.T) { + for _, ver := range goldenVersions { + t.Run(ver.name, func(t *testing.T) { + golden := loadGoldenJSON(t, ver.fixture) + + for _, gs := range golden.Shares { + t.Run(gs.Holder, func(t *testing.T) { + filename := fmt.Sprintf("SHARE-%s.txt", strings.ToLower(gs.Holder)) + pemData, err := os.ReadFile(filepath.Join("testdata", ver.bundleDir, filename)) + if err != nil { + t.Fatalf("reading %s: %v", filename, err) + } + + share, err := ParseShare(pemData) + if err != nil { + t.Fatalf("ParseShare: %v", err) + } + + if share.Version != golden.Version { + t.Errorf("version: got %d, want %d", share.Version, golden.Version) + } + if share.Index != gs.Index { + t.Errorf("index: got %d, want %d", share.Index, gs.Index) + } + if share.Total != golden.Total { + t.Errorf("total: got %d, want %d", share.Total, golden.Total) + } + if share.Threshold != golden.Threshold { + t.Errorf("threshold: got %d, want %d", share.Threshold, golden.Threshold) + } + if share.Holder != gs.Holder { + t.Errorf("holder: got %q, want %q", share.Holder, gs.Holder) + } + + expectedCreated := parseCreatedTime(t, golden.Created) + if !share.Created.Equal(expectedCreated) { + t.Errorf("created: got %v, want %v", share.Created, expectedCreated) + } + + expectedData := mustDecodeHex(t, gs.DataHex) + if !bytes.Equal(share.Data, expectedData) { + t.Errorf("data mismatch: got %x, want %s", share.Data, gs.DataHex) + } + + if share.Checksum != gs.Checksum { + t.Errorf("checksum: got %q, want %q", share.Checksum, gs.Checksum) + } + + if err := share.Verify(); err != nil { + t.Errorf("Verify: %v", err) + } + + reEncoded := share.Encode() + if reEncoded != gs.PEM { + t.Errorf("PEM re-encode mismatch:\ngot:\n%s\nwant:\n%s", reEncoded, gs.PEM) + } + + compact := share.CompactEncode() + if compact != gs.Compact { + t.Errorf("compact: got %q, want %q", compact, gs.Compact) + } + + decoded, err := ParseCompact(compact) + if err != nil { + t.Fatalf("ParseCompact: %v", err) + } + if !bytes.Equal(decoded.Data, share.Data) { + t.Errorf("compact round-trip data mismatch") + } + if decoded.Version != share.Version { + t.Errorf("compact round-trip version: got %d, want %d", decoded.Version, share.Version) + } + }) + } + }) + } +} + +// TestGoldenCombine combines threshold shares and verifies the passphrase. +func TestGoldenCombine(t *testing.T) { + for _, ver := range goldenVersions { + t.Run(ver.name, func(t *testing.T) { + golden := loadGoldenJSON(t, ver.fixture) + + if len(golden.Shares) < golden.Threshold { + t.Fatalf("not enough shares in fixture: have %d, need %d", len(golden.Shares), golden.Threshold) + } + + shareData := make([][]byte, golden.Threshold) + for i := 0; i < golden.Threshold; i++ { + shareData[i] = mustDecodeHex(t, golden.Shares[i].DataHex) + } + + recovered, err := Combine(shareData) + if err != nil { + t.Fatalf("Combine: %v", err) + } + + passphrase := RecoverPassphrase(recovered, golden.Version) + if passphrase != golden.Passphrase { + t.Errorf("passphrase: got %q, want %q", passphrase, golden.Passphrase) + } + }) + } +} + +// TestGoldenCombineAllSubsets tries all valid k-of-n subsets. +func TestGoldenCombineAllSubsets(t *testing.T) { + for _, ver := range goldenVersions { + t.Run(ver.name, func(t *testing.T) { + golden := loadGoldenJSON(t, ver.fixture) + + allData := make([][]byte, len(golden.Shares)) + for i, gs := range golden.Shares { + allData[i] = mustDecodeHex(t, gs.DataHex) + } + + subsets := combinations(len(golden.Shares), golden.Threshold) + expectedSubsets := 10 // C(5,3) = 10 + if len(subsets) != expectedSubsets { + t.Fatalf("expected %d subsets, got %d", expectedSubsets, len(subsets)) + } + + for _, subset := range subsets { + indices := make([]string, len(subset)) + for i, idx := range subset { + indices[i] = fmt.Sprintf("%d", golden.Shares[idx].Index) + } + name := strings.Join(indices, ",") + + t.Run(name, func(t *testing.T) { + shareData := make([][]byte, len(subset)) + for i, idx := range subset { + shareData[i] = allData[idx] + } + + recovered, err := Combine(shareData) + if err != nil { + t.Fatalf("Combine: %v", err) + } + + passphrase := RecoverPassphrase(recovered, golden.Version) + if passphrase != golden.Passphrase { + t.Errorf("passphrase: got %q, want %q", passphrase, golden.Passphrase) + } + }) + } + }) + } +} + +// TestGoldenCombineBelowThreshold verifies that combining fewer than threshold +// shares does not recover the passphrase (Shamir's information-theoretic security). +func TestGoldenCombineBelowThreshold(t *testing.T) { + for _, ver := range goldenVersions { + t.Run(ver.name, func(t *testing.T) { + golden := loadGoldenJSON(t, ver.fixture) + + allData := make([][]byte, len(golden.Shares)) + for i, gs := range golden.Shares { + allData[i] = mustDecodeHex(t, gs.DataHex) + } + + for size := 1; size < golden.Threshold; size++ { + subsets := combinations(len(golden.Shares), size) + for _, subset := range subsets { + indices := make([]string, len(subset)) + for i, idx := range subset { + indices[i] = fmt.Sprintf("%d", golden.Shares[idx].Index) + } + name := fmt.Sprintf("%d-of-%d[%s]", size, golden.Threshold, strings.Join(indices, ",")) + + t.Run(name, func(t *testing.T) { + shareData := make([][]byte, len(subset)) + for i, idx := range subset { + shareData[i] = allData[idx] + } + + recovered, err := Combine(shareData) + if err != nil { + // Combine rejecting below-threshold input is also acceptable + return + } + + passphrase := RecoverPassphrase(recovered, golden.Version) + if passphrase == golden.Passphrase { + t.Errorf("below-threshold subset recovered the passphrase") + } + }) + } + } + }) + } +} + +// TestGoldenDecrypt combines shares, decrypts the manifest, and verifies output. +func TestGoldenDecrypt(t *testing.T) { + for _, ver := range goldenVersions { + t.Run(ver.name, func(t *testing.T) { + golden := loadGoldenJSON(t, ver.fixture) + + shareNames := []string{"alice", "bob", "carol"} + shareData := make([][]byte, len(shareNames)) + for i, name := range shareNames { + filename := fmt.Sprintf("SHARE-%s.txt", name) + pemData, err := os.ReadFile(filepath.Join("testdata", ver.bundleDir, filename)) + if err != nil { + t.Fatalf("reading %s: %v", filename, err) + } + + share, err := ParseShare(pemData) + if err != nil { + t.Fatalf("ParseShare(%s): %v", filename, err) + } + + if err := share.Verify(); err != nil { + t.Fatalf("Verify(%s): %v", filename, err) + } + + shareData[i] = share.Data + } + + recovered, err := Combine(shareData) + if err != nil { + t.Fatalf("Combine: %v", err) + } + + passphrase := RecoverPassphrase(recovered, golden.Version) + if passphrase != golden.Passphrase { + t.Fatalf("passphrase mismatch: got %q, want %q", passphrase, golden.Passphrase) + } + + manifestAge, err := os.ReadFile(filepath.Join("testdata", ver.bundleDir, "MANIFEST.age")) + if err != nil { + t.Fatalf("reading MANIFEST.age: %v", err) + } + + var decrypted bytes.Buffer + if err := Decrypt(&decrypted, bytes.NewReader(manifestAge), passphrase); err != nil { + t.Fatalf("Decrypt: %v", err) + } + + files, err := ExtractTarGz(decrypted.Bytes()) + if err != nil { + t.Fatalf("ExtractTarGz: %v", err) + } + + if len(files) == 0 { + t.Fatal("no files extracted from manifest") + } + + extracted := make(map[string]string) + for _, f := range files { + extracted[f.Name] = string(f.Data) + } + + if len(extracted) != len(golden.Manifest.Files) { + t.Errorf("file count mismatch: extracted %d, expected %d", len(extracted), len(golden.Manifest.Files)) + } + + for name, expectedContent := range golden.Manifest.Files { + got, ok := extracted[name] + if !ok { + t.Errorf("missing extracted file %q", name) + continue + } + if got != expectedContent { + t.Errorf("file %q: got %q, want %q", name, got, expectedContent) + } + } + + for _, f := range files { + diskPath := filepath.Join("testdata", ver.bundleDir, "expected-output", f.Name) + diskContent, err := os.ReadFile(diskPath) + if err != nil { + t.Errorf("reading expected output %s: %v", diskPath, err) + continue + } + if string(f.Data) != string(diskContent) { + t.Errorf("file %q doesn't match expected-output on disk", f.Name) + } + } + }) + } +} + +// TestGoldenV2WordEncoding tests word encoding round-trips against golden fixtures. +// Words are 25 words: 24 data words + 1 index word. +func TestGoldenV2WordEncoding(t *testing.T) { + golden := loadGoldenJSON(t, "v2-golden.json") + + for _, gs := range golden.Shares { + t.Run(gs.Holder, func(t *testing.T) { + if gs.Words == "" { + t.Skip("no words field in fixture (regenerate with -generate)") + } + + data := mustDecodeHex(t, gs.DataHex) + + // Build a share to get the full 25-word encoding + share := &Share{ + Version: golden.Version, + Index: gs.Index, + Data: data, + } + words, err := share.Words() + if err != nil { + t.Fatalf("Words() error: %v", err) + } + got := strings.Join(words, " ") + if got != gs.Words { + t.Errorf("word encoding mismatch:\n got: %s\n want: %s", got, gs.Words) + } + + // Verify 25 words (24 data + 1 index) + fixtureWords := strings.Split(gs.Words, " ") + if len(fixtureWords) != 25 { + t.Fatalf("expected 25 words in fixture, got %d", len(fixtureWords)) + } + + // Decode fixture words and compare to data bytes + index + decoded, index, err := DecodeShareWords(fixtureWords) + if err != nil { + t.Fatalf("DecodeShareWords: %v", err) + } + if !bytes.Equal(decoded, data) { + t.Errorf("word decoding data mismatch:\n got: %x\n want: %s", decoded, gs.DataHex) + } + if index != gs.Index { + t.Errorf("word decoding index: got %d, want %d", index, gs.Index) + } + }) + } +} diff --git a/internal/core/share.go b/internal/core/share.go index 832fa30..55319bc 100644 --- a/internal/core/share.go +++ b/internal/core/share.go @@ -24,7 +24,7 @@ const ( // Share represents a single Shamir share with metadata. type Share struct { - Version int // Format version (currently 1) + Version int // Format version (1 or 2) Index int // Which share (1-indexed for humans) Total int // Total shares (N) Threshold int // Required shares (K) @@ -35,9 +35,9 @@ type Share struct { } // NewShare creates a Share with the given parameters and computes its checksum. -func NewShare(index, total, threshold int, holder string, data []byte) *Share { +func NewShare(version, index, total, threshold int, holder string, data []byte) *Share { return &Share{ - Version: 1, + Version: version, Index: index, Total: total, Threshold: threshold, @@ -48,6 +48,16 @@ func NewShare(index, total, threshold int, holder string, data []byte) *Share { } } +// RecoverPassphrase converts raw bytes from Combine() into the age passphrase. +// V1 shares contain the passphrase string directly; v2+ shares contain raw bytes +// that must be base64url-encoded. +func RecoverPassphrase(recovered []byte, version int) string { + if version >= 2 { + return base64.RawURLEncoding.EncodeToString(recovered) + } + return string(recovered) +} + // Encode converts the share to a human-readable PEM-like format. func (s *Share) Encode() string { var sb strings.Builder @@ -60,7 +70,13 @@ func (s *Share) Encode() string { if s.Holder != "" { sb.WriteString(fmt.Sprintf("Holder: %s\n", s.Holder)) } - sb.WriteString(fmt.Sprintf("Created: %s\n", s.Created.Format(time.RFC3339))) + // v1 used RFC3339; v2+ uses a shorter human-friendly format. + // Keep v1 encoding compatible with old recovery tools. + timeFormat := "2006-01-02 15:04" + if s.Version < 2 { + timeFormat = time.RFC3339 + } + sb.WriteString(fmt.Sprintf("Created: %s\n", s.Created.Format(timeFormat))) sb.WriteString(fmt.Sprintf("Checksum: %s\n", s.Checksum)) sb.WriteString("\n") sb.WriteString(base64.StdEncoding.EncodeToString(s.Data)) @@ -141,7 +157,10 @@ func ParseShare(content []byte) (*Share, error) { case "Holder": share.Holder = value case "Created": - t, err := time.Parse(time.RFC3339, value) + t, err := time.Parse("2006-01-02 15:04", value) + if err != nil { + t, err = time.Parse(time.RFC3339, value) // fallback for older shares + } if err != nil { return nil, fmt.Errorf("invalid created time: %w", err) } diff --git a/internal/core/testdata/v1-bundle/MANIFEST.age b/internal/core/testdata/v1-bundle/MANIFEST.age new file mode 100644 index 0000000..b64619c Binary files /dev/null and b/internal/core/testdata/v1-bundle/MANIFEST.age differ diff --git a/internal/core/testdata/v1-bundle/SHARE-alice.txt b/internal/core/testdata/v1-bundle/SHARE-alice.txt new file mode 100644 index 0000000..9177df0 --- /dev/null +++ b/internal/core/testdata/v1-bundle/SHARE-alice.txt @@ -0,0 +1,11 @@ +-----BEGIN REMEMORY SHARE----- +Version: 1 +Index: 1 +Total: 5 +Threshold: 3 +Holder: Alice +Created: 2025-01-01T00:00:00Z +Checksum: sha256:7f4db518a8c37308051647652edcf7aebc08b062807e06c540e97e872a62d0c9 + +uAEzSV0GKyHdWb+61VXF2287C1QdWuz5ccYt+XRuH1jHx1M1mMz2OwGFAq5M +-----END REMEMORY SHARE----- diff --git a/internal/core/testdata/v1-bundle/SHARE-bob.txt b/internal/core/testdata/v1-bundle/SHARE-bob.txt new file mode 100644 index 0000000..da0c1df --- /dev/null +++ b/internal/core/testdata/v1-bundle/SHARE-bob.txt @@ -0,0 +1,11 @@ +-----BEGIN REMEMORY SHARE----- +Version: 1 +Index: 2 +Total: 5 +Threshold: 3 +Holder: Bob +Created: 2025-01-01T00:00:00Z +Checksum: sha256:6db66089e82bff2ed01164cd5fa70f30bde33715a3542ae1142d068d4926b38f + +uSMgO8eVKQlBKaXRirWCDHx6A54VYUcpbWa5O7/xV4y3hCcZnr15muoTZuDx +-----END REMEMORY SHARE----- diff --git a/internal/core/testdata/v1-bundle/SHARE-carol.txt b/internal/core/testdata/v1-bundle/SHARE-carol.txt new file mode 100644 index 0000000..30cb8d2 --- /dev/null +++ b/internal/core/testdata/v1-bundle/SHARE-carol.txt @@ -0,0 +1,11 @@ +-----BEGIN REMEMORY SHARE----- +Version: 1 +Index: 3 +Total: 5 +Threshold: 3 +Holder: Carol +Created: 2025-01-01T00:00:00Z +Checksum: sha256:d262a7d3dd0c6968724cc0c7210844125d97f80f36572553d61a7bb356df2e78 + +GcYjMChcemOPWcW/8YGkNTTjaTNbqPy8T1zuPm9gQSw7fpQ4djbwTnS88lX6 +-----END REMEMORY SHARE----- diff --git a/internal/core/testdata/v1-bundle/SHARE-david.txt b/internal/core/testdata/v1-bundle/SHARE-david.txt new file mode 100644 index 0000000..ab4002c --- /dev/null +++ b/internal/core/testdata/v1-bundle/SHARE-david.txt @@ -0,0 +1,11 @@ +-----BEGIN REMEMORY SHARE----- +Version: 1 +Index: 4 +Total: 5 +Threshold: 3 +Holder: David +Created: 2025-01-01T00:00:00Z +Checksum: sha256:d531c07ecd8176b5abcbcdcb0f6180f68b1e3f0f7be7a72f597d94d5f570581b + +JnBIeJshMh11t5AcKde/pwI9i8Z6KOvt4IRkT0JVsgyqXMKFr9bRr8Md6SKL +-----END REMEMORY SHARE----- diff --git a/internal/core/testdata/v1-bundle/SHARE-eve.txt b/internal/core/testdata/v1-bundle/SHARE-eve.txt new file mode 100644 index 0000000..c25439a --- /dev/null +++ b/internal/core/testdata/v1-bundle/SHARE-eve.txt @@ -0,0 +1,11 @@ +-----BEGIN REMEMORY SHARE----- +Version: 1 +Index: 5 +Total: 5 +Threshold: 3 +Holder: Eve +Created: 2025-01-01T00:00:00Z +Checksum: sha256:ac94f56dd4a8d3d65f4256f7800ac99a3ca01bc92bfd8b0ed1e53cafa39c4ddf + +kHCy0KeMfrAlhIxO3wnVwmwztYg4DmIx04SiKR0lQ8/2hPKNgrEoOS+xDWpl +-----END REMEMORY SHARE----- diff --git a/internal/core/testdata/v1-bundle/expected-output/manifest/README.md b/internal/core/testdata/v1-bundle/expected-output/manifest/README.md new file mode 100644 index 0000000..cb851de --- /dev/null +++ b/internal/core/testdata/v1-bundle/expected-output/manifest/README.md @@ -0,0 +1,3 @@ +# Golden Test Manifest + +This is a test manifest for v1 golden fixtures. diff --git a/internal/core/testdata/v1-bundle/expected-output/manifest/secret.txt b/internal/core/testdata/v1-bundle/expected-output/manifest/secret.txt new file mode 100644 index 0000000..6dd5ee4 --- /dev/null +++ b/internal/core/testdata/v1-bundle/expected-output/manifest/secret.txt @@ -0,0 +1 @@ +The secret passphrase is: correct-horse-battery-staple diff --git a/internal/core/testdata/v1-golden.json b/internal/core/testdata/v1-golden.json new file mode 100644 index 0000000..da75e5d --- /dev/null +++ b/internal/core/testdata/v1-golden.json @@ -0,0 +1,55 @@ +{ + "version": 1, + "passphrase": "dGhpc19pc19hX3Rlc3RfcGFzc3BocmFzZV92MV9nbGRu", + "total": 5, + "threshold": 3, + "created": "2025-01-01T00:00:00Z", + "shares": [ + { + "index": 1, + "holder": "Alice", + "data_hex": "b80133495d062b21dd59bfbad555c5db6f3b0b541d5aecf971c62df9746e1f58c7c7533598ccf63b018502ae4c", + "checksum": "sha256:7f4db518a8c37308051647652edcf7aebc08b062807e06c540e97e872a62d0c9", + "pem": "-----BEGIN REMEMORY SHARE-----\nVersion: 1\nIndex: 1\nTotal: 5\nThreshold: 3\nHolder: Alice\nCreated: 2025-01-01T00:00:00Z\nChecksum: sha256:7f4db518a8c37308051647652edcf7aebc08b062807e06c540e97e872a62d0c9\n\nuAEzSV0GKyHdWb+61VXF2287C1QdWuz5ccYt+XRuH1jHx1M1mMz2OwGFAq5M\n-----END REMEMORY SHARE-----\n", + "compact": "RM1:1:5:3:uAEzSV0GKyHdWb-61VXF2287C1QdWuz5ccYt-XRuH1jHx1M1mMz2OwGFAq5M:7f4d" + }, + { + "index": 2, + "holder": "Bob", + "data_hex": "b923203bc79529094129a5d18ab5820c7c7a039e156147296d66b93bbff1578cb78427199ebd799aea1366e0f1", + "checksum": "sha256:6db66089e82bff2ed01164cd5fa70f30bde33715a3542ae1142d068d4926b38f", + "pem": "-----BEGIN REMEMORY SHARE-----\nVersion: 1\nIndex: 2\nTotal: 5\nThreshold: 3\nHolder: Bob\nCreated: 2025-01-01T00:00:00Z\nChecksum: sha256:6db66089e82bff2ed01164cd5fa70f30bde33715a3542ae1142d068d4926b38f\n\nuSMgO8eVKQlBKaXRirWCDHx6A54VYUcpbWa5O7/xV4y3hCcZnr15muoTZuDx\n-----END REMEMORY SHARE-----\n", + "compact": "RM1:2:5:3:uSMgO8eVKQlBKaXRirWCDHx6A54VYUcpbWa5O7_xV4y3hCcZnr15muoTZuDx:6db6" + }, + { + "index": 3, + "holder": "Carol", + "data_hex": "19c62330285c7a638f59c5bff181a43534e369335ba8fcbc4f5cee3e6f60412c3b7e94387636f04e74bcf255fa", + "checksum": "sha256:d262a7d3dd0c6968724cc0c7210844125d97f80f36572553d61a7bb356df2e78", + "pem": "-----BEGIN REMEMORY SHARE-----\nVersion: 1\nIndex: 3\nTotal: 5\nThreshold: 3\nHolder: Carol\nCreated: 2025-01-01T00:00:00Z\nChecksum: sha256:d262a7d3dd0c6968724cc0c7210844125d97f80f36572553d61a7bb356df2e78\n\nGcYjMChcemOPWcW/8YGkNTTjaTNbqPy8T1zuPm9gQSw7fpQ4djbwTnS88lX6\n-----END REMEMORY SHARE-----\n", + "compact": "RM1:3:5:3:GcYjMChcemOPWcW_8YGkNTTjaTNbqPy8T1zuPm9gQSw7fpQ4djbwTnS88lX6:d262" + }, + { + "index": 4, + "holder": "David", + "data_hex": "267048789b21321d75b7901c29d7bfa7023d8bc67a28ebede084644f4255b20caa5cc285afd6d1afc31de9228b", + "checksum": "sha256:d531c07ecd8176b5abcbcdcb0f6180f68b1e3f0f7be7a72f597d94d5f570581b", + "pem": "-----BEGIN REMEMORY SHARE-----\nVersion: 1\nIndex: 4\nTotal: 5\nThreshold: 3\nHolder: David\nCreated: 2025-01-01T00:00:00Z\nChecksum: sha256:d531c07ecd8176b5abcbcdcb0f6180f68b1e3f0f7be7a72f597d94d5f570581b\n\nJnBIeJshMh11t5AcKde/pwI9i8Z6KOvt4IRkT0JVsgyqXMKFr9bRr8Md6SKL\n-----END REMEMORY SHARE-----\n", + "compact": "RM1:4:5:3:JnBIeJshMh11t5AcKde_pwI9i8Z6KOvt4IRkT0JVsgyqXMKFr9bRr8Md6SKL:d531" + }, + { + "index": 5, + "holder": "Eve", + "data_hex": "9070b2d0a78c7eb025848c4edf09d5c26c33b588380e6231d384a2291d2543cff684f28d82b128392fb10d6a65", + "checksum": "sha256:ac94f56dd4a8d3d65f4256f7800ac99a3ca01bc92bfd8b0ed1e53cafa39c4ddf", + "pem": "-----BEGIN REMEMORY SHARE-----\nVersion: 1\nIndex: 5\nTotal: 5\nThreshold: 3\nHolder: Eve\nCreated: 2025-01-01T00:00:00Z\nChecksum: sha256:ac94f56dd4a8d3d65f4256f7800ac99a3ca01bc92bfd8b0ed1e53cafa39c4ddf\n\nkHCy0KeMfrAlhIxO3wnVwmwztYg4DmIx04SiKR0lQ8/2hPKNgrEoOS+xDWpl\n-----END REMEMORY SHARE-----\n", + "compact": "RM1:5:5:3:kHCy0KeMfrAlhIxO3wnVwmwztYg4DmIx04SiKR0lQ8_2hPKNgrEoOS-xDWpl:ac94" + } + ], + "manifest": { + "files": { + "manifest/README.md": "# Golden Test Manifest\n\nThis is a test manifest for v1 golden fixtures.\n", + "manifest/secret.txt": "The secret passphrase is: correct-horse-battery-staple\n" + } + } +} \ No newline at end of file diff --git a/internal/core/testdata/v2-bundle/MANIFEST.age b/internal/core/testdata/v2-bundle/MANIFEST.age new file mode 100644 index 0000000..57fd7d8 Binary files /dev/null and b/internal/core/testdata/v2-bundle/MANIFEST.age differ diff --git a/internal/core/testdata/v2-bundle/SHARE-alice.txt b/internal/core/testdata/v2-bundle/SHARE-alice.txt new file mode 100644 index 0000000..5956ffe --- /dev/null +++ b/internal/core/testdata/v2-bundle/SHARE-alice.txt @@ -0,0 +1,11 @@ +-----BEGIN REMEMORY SHARE----- +Version: 2 +Index: 1 +Total: 5 +Threshold: 3 +Holder: Alice +Created: 2025-01-01 00:00 +Checksum: sha256:f18bb126ca180c0e94f79456fbf549ceff04bc2efa3d21555b785caf3ef43162 + +u5B5hc+2vNeLJDOAKTP/DN7BG5eLun3A4TcIArENaOwx +-----END REMEMORY SHARE----- diff --git a/internal/core/testdata/v2-bundle/SHARE-bob.txt b/internal/core/testdata/v2-bundle/SHARE-bob.txt new file mode 100644 index 0000000..fa0ab2f --- /dev/null +++ b/internal/core/testdata/v2-bundle/SHARE-bob.txt @@ -0,0 +1,11 @@ +-----BEGIN REMEMORY SHARE----- +Version: 2 +Index: 2 +Total: 5 +Threshold: 3 +Holder: Bob +Created: 2025-01-01 00:00 +Checksum: sha256:10c890d4d401e3674bb5c37d89489befa8152e597b71dcbc3c6137cd91b2b4ee + +4FCmWfgQHjkaGrtwLPqV4WB81u+ZeGKZekj7yukG2+zY +-----END REMEMORY SHARE----- diff --git a/internal/core/testdata/v2-bundle/SHARE-carol.txt b/internal/core/testdata/v2-bundle/SHARE-carol.txt new file mode 100644 index 0000000..eae44aa --- /dev/null +++ b/internal/core/testdata/v2-bundle/SHARE-carol.txt @@ -0,0 +1,11 @@ +-----BEGIN REMEMORY SHARE----- +Version: 2 +Index: 3 +Total: 5 +Threshold: 3 +Holder: Carol +Created: 2025-01-01 00:00 +Checksum: sha256:6ec02c2131511cd738e47591dc4415cd6677cfac403c569621f2035c066b4597 + +aKoRQv1shz6UZSAXvTLEXnS1zSQkTS3jhqA3+06G2jnA +-----END REMEMORY SHARE----- diff --git a/internal/core/testdata/v2-bundle/SHARE-david.txt b/internal/core/testdata/v2-bundle/SHARE-david.txt new file mode 100644 index 0000000..1d1af4a --- /dev/null +++ b/internal/core/testdata/v2-bundle/SHARE-david.txt @@ -0,0 +1,11 @@ +-----BEGIN REMEMORY SHARE----- +Version: 2 +Index: 4 +Total: 5 +Threshold: 3 +Holder: David +Created: 2025-01-01 00:00 +Checksum: sha256:0827ab3357b65539c83105624e3e9db428caf492ecfffe99d05c11ec54b6a69d + +UMwXZ6OAyFfIMR2cpa1fFX+mf4VaAmRoqf19HkubW1hW +-----END REMEMORY SHARE----- diff --git a/internal/core/testdata/v2-bundle/SHARE-eve.txt b/internal/core/testdata/v2-bundle/SHARE-eve.txt new file mode 100644 index 0000000..3eb6434 --- /dev/null +++ b/internal/core/testdata/v2-bundle/SHARE-eve.txt @@ -0,0 +1,11 @@ +-----BEGIN REMEMORY SHARE----- +Version: 2 +Index: 5 +Total: 5 +Threshold: 3 +Holder: Eve +Created: 2025-01-01 00:00 +Checksum: sha256:9e774f3aaf85acf354e4e2880a9f48cb06a2adcc041e48cab499d6e4ad4fb637 + +IhI6NXvrSh/9P+fxdGqR/1S9zkjnEgZtvisCDnNIzZx5 +-----END REMEMORY SHARE----- diff --git a/internal/core/testdata/v2-bundle/expected-output/manifest/README.md b/internal/core/testdata/v2-bundle/expected-output/manifest/README.md new file mode 100644 index 0000000..cb851de --- /dev/null +++ b/internal/core/testdata/v2-bundle/expected-output/manifest/README.md @@ -0,0 +1,3 @@ +# Golden Test Manifest + +This is a test manifest for v1 golden fixtures. diff --git a/internal/core/testdata/v2-bundle/expected-output/manifest/secret.txt b/internal/core/testdata/v2-bundle/expected-output/manifest/secret.txt new file mode 100644 index 0000000..6dd5ee4 --- /dev/null +++ b/internal/core/testdata/v2-bundle/expected-output/manifest/secret.txt @@ -0,0 +1 @@ +The secret passphrase is: correct-horse-battery-staple diff --git a/internal/core/testdata/v2-golden.json b/internal/core/testdata/v2-golden.json new file mode 100644 index 0000000..724516f --- /dev/null +++ b/internal/core/testdata/v2-golden.json @@ -0,0 +1,60 @@ +{ + "version": 2, + "passphrase": "dGhpc19pc19hX3Rlc3RfcGFzc3BocmFzZV92Ml9nbGQ", + "total": 5, + "threshold": 3, + "created": "2025-01-01 00:00", + "shares": [ + { + "index": 1, + "holder": "Alice", + "data_hex": "bb907985cfb6bcd78b2433802933ff0cdec11b978bba7dc0e1370802b10d68ec31", + "checksum": "sha256:f18bb126ca180c0e94f79456fbf549ceff04bc2efa3d21555b785caf3ef43162", + "pem": "-----BEGIN REMEMORY SHARE-----\nVersion: 2\nIndex: 1\nTotal: 5\nThreshold: 3\nHolder: Alice\nCreated: 2025-01-01 00:00\nChecksum: sha256:f18bb126ca180c0e94f79456fbf549ceff04bc2efa3d21555b785caf3ef43162\n\nu5B5hc+2vNeLJDOAKTP/DN7BG5eLun3A4TcIArENaOwx\n-----END REMEMORY SHARE-----\n", + "compact": "RM2:1:5:3:u5B5hc-2vNeLJDOAKTP_DN7BG5eLun3A4TcIArENaOwx:f18b", + "words": "romance long gesture panda hint hint clutch major lens end zone boost ugly miss funny jar lava alpha evidence avoid climb mammal photo mail bullet" + }, + { + "index": 2, + "holder": "Bob", + "data_hex": "e050a659f8101e391a1abb702cfa95e1607cd6ef997862997a48fbcae906dbecd8", + "checksum": "sha256:10c890d4d401e3674bb5c37d89489befa8152e597b71dcbc3c6137cd91b2b4ee", + "pem": "-----BEGIN REMEMORY SHARE-----\nVersion: 2\nIndex: 2\nTotal: 5\nThreshold: 3\nHolder: Bob\nCreated: 2025-01-01 00:00\nChecksum: sha256:10c890d4d401e3674bb5c37d89489befa8152e597b71dcbc3c6137cd91b2b4ee\n\n4FCmWfgQHjkaGrtwLPqV4WB81u+ZeGKZekj7yukG2+zY\n-----END REMEMORY SHARE-----\n", + "compact": "RM2:2:5:3:4FCmWfgQHjkaGrtwLPqV4WB81u-ZeGKZekj7yukG2-zY:10c8", + "words": "theory lunch nose usual acid broken half first ice guitar pistol security amazing hidden salmon congress glad slim mutual wasp purse lock hurry only capital" + }, + { + "index": 3, + "holder": "Carol", + "data_hex": "68aa1142fd6c873e94652017bd32c45e74b5cd24244d2de386a037fb4e86da39c0", + "checksum": "sha256:6ec02c2131511cd738e47591dc4415cd6677cfac403c569621f2035c066b4597", + "pem": "-----BEGIN REMEMORY SHARE-----\nVersion: 2\nIndex: 3\nTotal: 5\nThreshold: 3\nHolder: Carol\nCreated: 2025-01-01 00:00\nChecksum: sha256:6ec02c2131511cd738e47591dc4415cd6677cfac403c569621f2035c066b4597\n\naKoRQv1shz6UZSAXvTLEXnS1zSQkTS3jhqA3+06G2jnA\n-----END REMEMORY SHARE-----\n", + "compact": "RM2:3:5:3:aKoRQv1shz6UZSAXvTLEXnS1zSQkTS3jhqA3-06G2jnA:6ec0", + "words": "hamster explain expose width silent palm face piano bless trumpet rain rude ensure track mountain meadow combine bring pool husband reject drop happy day differ" + }, + { + "index": 4, + "holder": "David", + "data_hex": "50cc1767a380c857c8311d9ca5ad5f157fa67f855a026468a9fd7d1e4b9b5b5856", + "checksum": "sha256:0827ab3357b65539c83105624e3e9db428caf492ecfffe99d05c11ec54b6a69d", + "pem": "-----BEGIN REMEMORY SHARE-----\nVersion: 2\nIndex: 4\nTotal: 5\nThreshold: 3\nHolder: David\nCreated: 2025-01-01 00:00\nChecksum: sha256:0827ab3357b65539c83105624e3e9db428caf492ecfffe99d05c11ec54b6a69d\n\nUMwXZ6OAyFfIMR2cpa1fFX+mf4VaAmRoqf19HkubW1hW\n-----END REMEMORY SHARE-----\n", + "compact": "RM2:4:5:3:UMwXZ6OAyFfIMR2cpa1fFX-mf4VaAmRoqf19HkubW1hW:0827", + "words": "express gaze supreme either arrive cloud camp casual original coin fit cliff whip divert betray doctor good earn leg when tool soap hope approve donate" + }, + { + "index": 5, + "holder": "Eve", + "data_hex": "22123a357beb4a1ffd3fe7f1746a91ff54bdce48e712066dbe2b020e7348cd9c79", + "checksum": "sha256:9e774f3aaf85acf354e4e2880a9f48cb06a2adcc041e48cab499d6e4ad4fb637", + "pem": "-----BEGIN REMEMORY SHARE-----\nVersion: 2\nIndex: 5\nTotal: 5\nThreshold: 3\nHolder: Eve\nCreated: 2025-01-01 00:00\nChecksum: sha256:9e774f3aaf85acf354e4e2880a9f48cb06a2adcc041e48cab499d6e4ad4fb637\n\nIhI6NXvrSh/9P+fxdGqR/1S9zkjnEgZtvisCDnNIzZx5\n-----END REMEMORY SHARE-----\n", + "compact": "RM2:5:5:3:IhI6NXvrSh_9P-fxdGqR_1S9zkjnEgZtvisCDnNIzZx5:9e77", + "words": "capital mushroom minute water regret avocado visual woman vapor person piece wrong envelope transfer castle time all hospital member advice transfer piece cushion monkey fatigue" + } + ], + "manifest": { + "files": { + "manifest/README.md": "# Golden Test Manifest\n\nThis is a test manifest for v1 golden fixtures.\n", + "manifest/secret.txt": "The secret passphrase is: correct-horse-battery-staple\n" + } + } +} \ No newline at end of file diff --git a/internal/core/wordlist.go b/internal/core/wordlist.go new file mode 100644 index 0000000..90f4b0e --- /dev/null +++ b/internal/core/wordlist.go @@ -0,0 +1,264 @@ +package core + +import ( + "crypto/sha256" + "fmt" + "strings" + "sync" +) + +// wordIndex maps BIP39 words to their index (0-2047). Initialized once via sync.Once. +var ( + wordIndex map[string]int + wordIndexOnce sync.Once +) + +func initWordIndex() { + wordIndexOnce.Do(func() { + wordIndex = make(map[string]int, len(bip39English)) + for i, w := range bip39English { + wordIndex[w] = i + } + }) +} + +// EncodeWords converts bytes to BIP39 words (11 bits per word). +// 33 bytes (264 bits) produces exactly 24 words. +func EncodeWords(data []byte) []string { + totalBits := len(data) * 8 + numWords := (totalBits + 10) / 11 // ceiling division + + words := make([]string, numWords) + for i := 0; i < numWords; i++ { + idx := extract11Bits(data, i*11) + words[i] = bip39English[idx] + } + return words +} + +// extract11Bits extracts an 11-bit value starting at the given bit offset. +// Out-of-range bits are treated as zero (for padding the final chunk). +func extract11Bits(data []byte, bitOffset int) int { + val := 0 + for b := 0; b < 11; b++ { + byteIdx := (bitOffset + b) / 8 + bitIdx := 7 - (bitOffset+b)%8 + if byteIdx < len(data) { + val = (val << 1) | ((int(data[byteIdx]) >> bitIdx) & 1) + } else { + val <<= 1 // pad with zero + } + } + return val +} + +// DecodeWords converts BIP39 words back to bytes. +// Returns an error with typo suggestions if a word is not recognized. +func DecodeWords(words []string) ([]byte, error) { + initWordIndex() + + if len(words) == 0 { + return nil, fmt.Errorf("no words provided") + } + + // Convert words to 11-bit indices + indices := make([]int, len(words)) + for i, w := range words { + w = strings.ToLower(strings.TrimSpace(w)) + idx, ok := wordIndex[w] + if !ok { + suggestion := SuggestWord(w) + if suggestion != "" { + return nil, fmt.Errorf("word %d %q not recognized — did you mean %q?", i+1, w, suggestion) + } + return nil, fmt.Errorf("word %d %q not recognized", i+1, w) + } + indices[i] = idx + } + + // Convert 11-bit indices to bytes + totalBits := len(words) * 11 + numBytes := totalBits / 8 + result := make([]byte, numBytes) + + for i, idx := range indices { + set11Bits(result, i*11, idx) + } + + return result, nil +} + +// set11Bits writes an 11-bit value at the given bit offset in data. +// Precondition: the target bits in data must be zero-initialized, as this +// function only sets (ORs) 1-bits and never clears existing bits. +func set11Bits(data []byte, bitOffset int, val int) { + for b := 0; b < 11; b++ { + byteIdx := (bitOffset + b) / 8 + bitIdx := 7 - (bitOffset+b)%8 + if byteIdx < len(data) { + if (val>>(10-b))&1 == 1 { + data[byteIdx] |= 1 << bitIdx + } + } + } +} + +// Word 25 layout (11 bits total): +// +// ┌──────────────┬────────────────────┐ +// │ index (4 hi) │ checksum (7 lo) │ +// │ bits 10-7 │ bits 6-0 │ +// └──────────────┴────────────────────┘ +// +// Index: share index (1-based) stored in upper 4 bits. +// - Shares 1–15: index stored directly. +// - Shares 16+: index set to 0 (sentinel for "unknown"). +// The system still works — the share data contains the Shamir +// x-coordinate needed for Combine(). The UI just can't identify +// which specific friend this share belongs to. +// +// Checksum: lower 7 bits of SHA-256(data_bytes)[0]. +// - Catches transpositions, word ordering mistakes, and typos that +// happen to be valid BIP39 words. +// - False positive rate: 1/128 (~0.8%). +const ( + word25IndexBits = 4 + word25CheckBits = 7 + word25MaxIndex = (1 << word25IndexBits) - 1 // 15 + word25CheckMask = (1 << word25CheckBits) - 1 // 0x7F +) + +// word25Checksum computes the 7-bit checksum for the 25th word. +// It hashes the raw share data bytes and returns the lower 7 bits of byte 0. +func word25Checksum(data []byte) int { + h := sha256.Sum256(data) + return int(h[0]) & word25CheckMask +} + +// word25Encode packs a share index and data checksum into an 11-bit BIP39 word index. +func word25Encode(shareIndex int, data []byte) int { + idx := shareIndex + if idx > word25MaxIndex { + idx = 0 // sentinel: index not representable in 4 bits + } + check := word25Checksum(data) + return (idx << word25CheckBits) | check +} + +// word25Decode unpacks the 25th word's 11-bit value into index and checksum. +func word25Decode(val int) (index int, checksum int) { + return val >> word25CheckBits, val & word25CheckMask +} + +// Words returns this share's data encoded as 25 BIP39 words. +// The first 24 words encode the share data (33 bytes = 264 bits, 11 bits per word). +// The 25th word packs 4 bits of share index + 7 bits of checksum (see word25 layout above). +// Returns an error for v1 shares or if the share index is negative. +func (s *Share) Words() ([]string, error) { + if s.Version < 2 { + return nil, fmt.Errorf("word encoding requires share version 2 or later (got v%d)", s.Version) + } + if s.Index < 0 { + return nil, fmt.Errorf("share index must be non-negative (got %d)", s.Index) + } + words := EncodeWords(s.Data) + bip39Idx := word25Encode(s.Index, s.Data) + words = append(words, bip39English[bip39Idx]) + return words, nil +} + +// DecodeShareWords decodes 25 BIP39 words into share data and index. +// The first 24 words are decoded to bytes; the 25th word carries index + checksum. +// Returns index=0 if the share index was > 15 (the sentinel value). +// Returns an error if the checksum doesn't match (wrong word order, typos, etc.). +func DecodeShareWords(words []string) (data []byte, index int, err error) { + if len(words) != 25 { + return nil, 0, fmt.Errorf("expected 25 words, got %d", len(words)) + } + + // Look up the 25th word in the BIP39 list + lastWord := strings.ToLower(strings.TrimSpace(words[len(words)-1])) + initWordIndex() + + bip39Idx, ok := wordIndex[lastWord] + if !ok { + suggestion := SuggestWord(lastWord) + if suggestion != "" { + return nil, 0, fmt.Errorf("word %d %q not recognized — did you mean %q?", len(words), lastWord, suggestion) + } + return nil, 0, fmt.Errorf("word %d %q not recognized", len(words), lastWord) + } + + // Decode the data words (all but the last) + data, err = DecodeWords(words[:len(words)-1]) + if err != nil { + return nil, 0, err + } + + // Unpack index and checksum from the 25th word + index, expectedCheck := word25Decode(bip39Idx) + + // Verify checksum against the decoded data + actualCheck := word25Checksum(data) + if actualCheck != expectedCheck { + return nil, 0, fmt.Errorf("word checksum failed — check word order and spelling") + } + + return data, index, nil +} + +// SuggestWord finds the closest BIP39 word by Levenshtein distance (max 2). +// Returns empty string if no close match is found. +func SuggestWord(input string) string { + input = strings.ToLower(strings.TrimSpace(input)) + if input == "" { + return "" + } + + bestWord := "" + bestDist := 3 // only suggest if distance <= 2 + + for _, w := range bip39English { + d := levenshtein(input, w) + if d < bestDist { + bestDist = d + bestWord = w + } + if d == 0 { + return w // exact match + } + } + + return bestWord +} + +// levenshtein computes the edit distance between two strings. +func levenshtein(a, b string) int { + if len(a) == 0 { + return len(b) + } + if len(b) == 0 { + return len(a) + } + + // Use single-row optimization + prev := make([]int, len(b)+1) + for j := range prev { + prev[j] = j + } + + for i := 1; i <= len(a); i++ { + curr := make([]int, len(b)+1) + curr[0] = i + for j := 1; j <= len(b); j++ { + cost := 1 + if a[i-1] == b[j-1] { + cost = 0 + } + curr[j] = min(curr[j-1]+1, min(prev[j]+1, prev[j-1]+cost)) + } + prev = curr + } + + return prev[len(b)] +} diff --git a/internal/core/wordlist_test.go b/internal/core/wordlist_test.go new file mode 100644 index 0000000..c1173bb --- /dev/null +++ b/internal/core/wordlist_test.go @@ -0,0 +1,433 @@ +package core + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "strings" + "testing" +) + +func TestEncodeDecodeRoundTrip(t *testing.T) { + tests := []struct { + name string + size int + numWords int + }{ + {"33 bytes (24 words)", 33, 24}, + {"32 bytes (24 words)", 32, 24}, // 256 bits → ceil(256/11) = 24 words + {"45 bytes (33 words)", 45, 33}, // 360 bits → ceil(360/11) = 33 words + {"1 byte (1 word)", 1, 1}, // 8 bits → ceil(8/11) = 1 word + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data := make([]byte, tt.size) + for i := range data { + data[i] = byte(i * 7) // deterministic pattern + } + + words := EncodeWords(data) + if len(words) != tt.numWords { + t.Fatalf("expected %d words, got %d", tt.numWords, len(words)) + } + + decoded, err := DecodeWords(words) + if err != nil { + t.Fatalf("DecodeWords error: %v", err) + } + + // Decoded length is totalBits/8, which may truncate trailing padding bits + expectedLen := (len(words) * 11) / 8 + if len(decoded) != expectedLen { + t.Fatalf("decoded length: got %d, want %d", len(decoded), expectedLen) + } + + // The original data should match the decoded data up to the original length + if !bytes.Equal(decoded[:tt.size], data) { + t.Errorf("round-trip mismatch:\n got: %x\n want: %x", decoded[:tt.size], data) + } + }) + } +} + +func TestEncodeWords24(t *testing.T) { + // 33 bytes = 264 bits = exactly 24 words (no padding needed) + data := make([]byte, 33) + for i := range data { + data[i] = byte(i + 1) + } + words := EncodeWords(data) + if len(words) != 24 { + t.Errorf("expected 24 words for 33 bytes, got %d", len(words)) + } +} + +func TestDecodeWordsInvalidWord(t *testing.T) { + words := []string{"abandon", "ability", "appler"} // "appler" is a typo for "apple" + _, err := DecodeWords(words) + if err == nil { + t.Fatal("expected error for invalid word") + } + if !strings.Contains(err.Error(), "appler") { + t.Errorf("error should mention the invalid word, got: %v", err) + } + if !strings.Contains(err.Error(), "did you mean") { + t.Errorf("error should include a suggestion, got: %v", err) + } +} + +func TestDecodeWordsEmpty(t *testing.T) { + _, err := DecodeWords([]string{}) + if err == nil { + t.Fatal("expected error for empty input") + } + if !strings.Contains(err.Error(), "no words") { + t.Errorf("expected 'no words' error, got: %v", err) + } +} + +func TestSuggestWord(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"appla", "apple"}, // one char off from "apple" + {"abandn", "abandon"}, // missing 'o' + {"zooo", "zoo"}, // one extra char + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := SuggestWord(tt.input) + if got != tt.expected { + t.Errorf("SuggestWord(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestBIP39ListIntegrity(t *testing.T) { + // Check count + if len(bip39English) != 2048 { + t.Fatalf("expected 2048 words, got %d", len(bip39English)) + } + + // Check no duplicates + seen := make(map[string]bool, 2048) + for i, w := range bip39English { + if w == "" { + t.Errorf("empty word at index %d", i) + } + if seen[w] { + t.Errorf("duplicate word %q at index %d", w, i) + } + seen[w] = true + } + + // SHA-256 integrity check: hash the newline-joined word list + joined := strings.Join(bip39English[:], "\n") + "\n" + hash := sha256.Sum256([]byte(joined)) + hexHash := hex.EncodeToString(hash[:]) + expectedHash := "2f5eed53a4727b4bf8880d8f3f199efc90e58503646d9ff8eff3a2ed3b24dbda" + if hexHash != expectedHash { + t.Errorf("BIP39 word list hash mismatch:\n got: %s\n want: %s", hexHash, expectedHash) + } +} + +func TestEncodeWordsDeterministic(t *testing.T) { + data := make([]byte, 33) + for i := range data { + data[i] = byte(i * 13) + } + + words1 := EncodeWords(data) + words2 := EncodeWords(data) + + if strings.Join(words1, " ") != strings.Join(words2, " ") { + t.Error("EncodeWords is not deterministic") + } +} + +func TestShareWords(t *testing.T) { + data := make([]byte, 33) + for i := range data { + data[i] = byte(i) + } + share := NewShare(2, 1, 5, 3, "Alice", data) + words, err := share.Words() + if err != nil { + t.Fatalf("Words() error: %v", err) + } + if len(words) != 25 { + t.Errorf("expected 25 words for 33-byte share (24 data + 1 meta), got %d", len(words)) + } + + // Round-trip through DecodeShareWords + decoded, index, err := DecodeShareWords(words) + if err != nil { + t.Fatalf("DecodeShareWords error: %v", err) + } + if !bytes.Equal(decoded, data) { + t.Errorf("Share.Words() round-trip data mismatch") + } + if index != 1 { + t.Errorf("Share.Words() round-trip index: got %d, want 1", index) + } +} + +func TestDecodeShareWordsRoundTrip(t *testing.T) { + tests := []struct { + name string + index int + expectedIndex int // what DecodeShareWords should return (0 for >15) + }{ + {"index 1", 1, 1}, + {"index 2", 2, 2}, + {"index 5", 5, 5}, + {"index 15 (max exact)", 15, 15}, + {"index 16 (sentinel)", 16, 0}, // above 15 → stored as 0 + {"index 100 (sentinel)", 100, 0}, // above 15 → stored as 0 + {"index 255 (sentinel)", 255, 0}, // above 15 → stored as 0 + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data := make([]byte, 33) + for i := range data { + data[i] = byte(i * 7) + } + share := NewShare(2, tt.index, 5, 3, "Test", data) + words, err := share.Words() + if err != nil { + t.Fatalf("Words() error: %v", err) + } + if len(words) != 25 { + t.Fatalf("expected 25 words, got %d", len(words)) + } + + decoded, index, err := DecodeShareWords(words) + if err != nil { + t.Fatalf("DecodeShareWords error: %v", err) + } + if !bytes.Equal(decoded, data) { + t.Errorf("data mismatch") + } + if index != tt.expectedIndex { + t.Errorf("index: got %d, want %d", index, tt.expectedIndex) + } + }) + } +} + +// TestWord25ChecksumDetectsTransposition verifies that swapping two adjacent +// data words causes the 25th-word checksum to fail. +func TestWord25ChecksumDetectsTransposition(t *testing.T) { + data := make([]byte, 33) + for i := range data { + data[i] = byte(i * 13) + } + share := NewShare(2, 3, 5, 3, "Test", data) + words, err := share.Words() + if err != nil { + t.Fatalf("Words() error: %v", err) + } + + // Swap words 0 and 1 (adjacent transposition in data words) + swapped := make([]string, len(words)) + copy(swapped, words) + swapped[0], swapped[1] = swapped[1], swapped[0] + + _, _, decErr := DecodeShareWords(swapped) + if decErr == nil { + t.Error("expected checksum error for transposed words, got nil") + } + if !strings.Contains(decErr.Error(), "checksum") { + t.Errorf("expected checksum error, got: %v", decErr) + } +} + +// TestWord25ChecksumDetectsSubstitution verifies that replacing a data word +// with a different valid BIP39 word causes the checksum to fail. +func TestWord25ChecksumDetectsSubstitution(t *testing.T) { + data := make([]byte, 33) + for i := range data { + data[i] = byte(i * 13) + } + share := NewShare(2, 3, 5, 3, "Test", data) + words, err := share.Words() + if err != nil { + t.Fatalf("Words() error: %v", err) + } + + // Replace word 5 with a different BIP39 word + modified := make([]string, len(words)) + copy(modified, words) + // Pick a word that's definitely different from the original + replacement := "zoo" + if modified[5] == replacement { + replacement = "abandon" + } + modified[5] = replacement + + _, _, err = DecodeShareWords(modified) + if err == nil { + t.Error("expected checksum error for substituted word, got nil") + } + if !strings.Contains(err.Error(), "checksum") { + t.Errorf("expected checksum error, got: %v", err) + } +} + +// TestWord25Layout verifies the bit-packing layout of the 25th word: +// upper 4 bits = index, lower 7 bits = SHA-256 checksum. +func TestWord25Layout(t *testing.T) { + data := make([]byte, 33) + for i := range data { + data[i] = byte(i + 42) + } + + // Compute expected values + expectedCheck := word25Checksum(data) + if expectedCheck < 0 || expectedCheck > 127 { + t.Fatalf("checksum out of 7-bit range: %d", expectedCheck) + } + + // Test encoding for index in range (1-15) + for _, idx := range []int{1, 7, 15} { + encoded := word25Encode(idx, data) + gotIdx, gotCheck := word25Decode(encoded) + if gotIdx != idx { + t.Errorf("index %d: decode got index %d", idx, gotIdx) + } + if gotCheck != expectedCheck { + t.Errorf("index %d: decode got check %d, want %d", idx, gotCheck, expectedCheck) + } + // Verify the 11-bit value is in BIP39 range + if encoded < 0 || encoded >= 2048 { + t.Errorf("index %d: encoded value %d out of BIP39 range", idx, encoded) + } + } + + // Test sentinel for index > 15 + for _, idx := range []int{16, 100, 255} { + encoded := word25Encode(idx, data) + gotIdx, gotCheck := word25Decode(encoded) + if gotIdx != 0 { + t.Errorf("index %d: expected sentinel 0, got %d", idx, gotIdx) + } + if gotCheck != expectedCheck { + t.Errorf("index %d: checksum should still be valid, got %d want %d", idx, gotCheck, expectedCheck) + } + } +} + +// TestWord25ChecksumDifferentData verifies that different data produces +// different checksums (not a guarantee, but should hold for distinct inputs). +func TestWord25ChecksumDifferentData(t *testing.T) { + data1 := make([]byte, 33) + data2 := make([]byte, 33) + for i := range data1 { + data1[i] = byte(i) + data2[i] = byte(i + 1) + } + + check1 := word25Checksum(data1) + check2 := word25Checksum(data2) + // With 7 bits, there's a 1/128 chance these collide. + // Use sufficiently different inputs to make collision astronomically unlikely. + if check1 == check2 { + t.Logf("warning: checksums collided (1/128 chance) — not a bug, but unexpected") + } +} + +func TestWordsV1ShareReturnsError(t *testing.T) { + data := make([]byte, 33) + share := NewShare(1, 1, 5, 3, "Alice", data) + _, err := share.Words() + if err == nil { + t.Fatal("expected error for v1 share") + } + if !strings.Contains(err.Error(), "version 2") { + t.Errorf("expected version error, got: %v", err) + } +} + +func TestDecodeShareWordsWrongCount(t *testing.T) { + tests := []struct { + name string + count int + }{ + {"0 words", 0}, + {"1 word", 1}, + {"10 words", 10}, + {"24 words", 24}, + {"26 words", 26}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + words := make([]string, tt.count) + for i := range words { + words[i] = "abandon" + } + _, _, err := DecodeShareWords(words) + if err == nil { + t.Fatalf("expected error for %d words", tt.count) + } + if !strings.Contains(err.Error(), "expected 25 words") { + t.Errorf("expected word count error, got: %v", err) + } + }) + } +} + +func TestDecodeWordsMixedCase(t *testing.T) { + data := make([]byte, 33) + for i := range data { + data[i] = byte(i * 7) + } + words := EncodeWords(data) + + // Uppercase some words + mixed := make([]string, len(words)) + for i, w := range words { + if i%2 == 0 { + mixed[i] = strings.ToUpper(w) + } else { + // Capitalize first letter + mixed[i] = strings.ToUpper(w[:1]) + w[1:] + } + } + + decoded, err := DecodeWords(mixed) + if err != nil { + t.Fatalf("DecodeWords should handle mixed case, got error: %v", err) + } + if !bytes.Equal(decoded, data) { + t.Errorf("mixed-case round-trip mismatch") + } +} + +func TestSuggestWordNoMatch(t *testing.T) { + // Strings far from any BIP39 word (distance > 2) + tests := []string{"zzzzzzz", "qqqqqq", "xylophone"} + for _, input := range tests { + got := SuggestWord(input) + if got != "" { + t.Errorf("SuggestWord(%q) = %q, want empty string (no close match)", input, got) + } + } +} + +func TestWordsNegativeIndexReturnsError(t *testing.T) { + data := make([]byte, 33) + share := NewShare(2, -1, 5, 3, "Alice", data) + _, err := share.Words() + if err == nil { + t.Fatal("expected error for negative index") + } + if !strings.Contains(err.Error(), "non-negative") { + t.Errorf("expected non-negative error, got: %v", err) + } +} diff --git a/internal/crypto/passphrase.go b/internal/crypto/passphrase.go index cc38e56..9d28d3b 100644 --- a/internal/crypto/passphrase.go +++ b/internal/crypto/passphrase.go @@ -15,15 +15,25 @@ const ( // GeneratePassphrase creates a cryptographically secure passphrase. // The passphrase is URL-safe base64 encoded (no padding) for easy handling. func GeneratePassphrase(numBytes int) (string, error) { + _, passphrase, err := GenerateRawPassphrase(numBytes) + return passphrase, err +} + +// GenerateRawPassphrase creates random bytes and returns both the raw bytes +// and the base64url-encoded passphrase string. Protocol v2 splits the raw bytes +// via Shamir (instead of the encoded string), then base64url-encodes after +// recombining. The passphrase string is used for age encryption. +func GenerateRawPassphrase(numBytes int) (raw []byte, passphrase string, err error) { if numBytes < 16 { - return "", fmt.Errorf("passphrase must be at least 16 bytes, got %d", numBytes) + return nil, "", fmt.Errorf("passphrase must be at least 16 bytes, got %d", numBytes) } - raw := make([]byte, numBytes) + raw = make([]byte, numBytes) if _, err := rand.Read(raw); err != nil { - return "", fmt.Errorf("generating random bytes: %w", err) + return nil, "", fmt.Errorf("generating random bytes: %w", err) } // URL-safe base64 without padding for easy copy-paste - return base64.RawURLEncoding.EncodeToString(raw), nil + passphrase = base64.RawURLEncoding.EncodeToString(raw) + return raw, passphrase, nil } diff --git a/internal/html/assets/recover.html b/internal/html/assets/recover.html index c59898e..4fedc42 100644 --- a/internal/html/assets/recover.html +++ b/internal/html/assets/recover.html @@ -55,14 +55,14 @@

1 Collect Sha
@@ -162,8 +162,8 @@

3 Recover Fil duplicate: "That piece is already here (index {0})", no_share: "No piece found in {0}", invalid_share: "That piece looks invalid in {0}: {1}", - paste_btn: "Paste a piece", - paste_placeholder: "Paste the piece text here...", + paste_btn: "Paste a piece or type recovery words", + paste_placeholder: "Paste share text or type your 25 recovery words here...", paste_submit: "Add piece", paste_no_share: "No piece found in the pasted text", your_share: "Your piece", @@ -176,6 +176,9 @@

3 Recover Fil scan_title: "Scan a QR code", scan_hint: "Point your camera at a QR code from a friend's PDF", scan_camera_error: "Could not access the camera", + error_invalid_words_title: "Invalid recovery words", + error_invalid_words_message: "The recovery words could not be decoded.", + error_invalid_words_guidance: "Check the words for typos. Each word should be from the BIP39 word list printed on the recovery sheet.", // Error titles error_title: "Something went wrong", error_wasm_title: "Recovery tool failed to load", @@ -204,7 +207,7 @@

3 Recover Fil error_wrong_manifest_guidance: "The encrypted archive should be named MANIFEST.age. You can find it inside any of the bundles that were distributed to friends.", error_paste_no_share_title: "No piece in pasted text", error_paste_no_share_message: "The pasted text doesn't contain a valid recovery piece.", - error_paste_no_share_guidance: "Copy the entire content from your friend's README.txt file, including the 'BEGIN REMEMORY SHARE' and 'END REMEMORY SHARE' markers.", + error_paste_no_share_guidance: "Copy the entire content from your friend's README.txt file, including the 'BEGIN REMEMORY SHARE' and 'END REMEMORY SHARE' markers. You can also type or paste the 25 recovery words.", error_decrypt_title: "Decryption failed", error_decrypt_message: "The archive couldn't be decrypted with the provided pieces.", error_decrypt_guidance: "This usually means the pieces don't match this archive, or not enough valid pieces were provided. Make sure all pieces are from the same recovery set.", @@ -254,8 +257,8 @@

3 Recover Fil duplicate: "Esa parte ya fue agregada (índice {0})", no_share: "No se encontró ninguna parte en {0}", invalid_share: "La parte en {0} no es válida: {1}", - paste_btn: "Pegar una parte", - paste_placeholder: "Pega aquí el texto de la parte...", + paste_btn: "Pegar una parte o escribir palabras de recuperación", + paste_placeholder: "Pega el texto de la parte o escribe tus 25 palabras de recuperación aquí...", paste_submit: "Agregar parte", paste_no_share: "No se encontró ninguna parte en el texto pegado", your_share: "Tu parte", @@ -268,6 +271,9 @@

3 Recover Fil scan_title: "Escanear un código QR", scan_hint: "Apunta tu cámara al código QR del PDF de tu amigo", scan_camera_error: "No se pudo acceder a la cámara", + error_invalid_words_title: "Palabras de recuperación inválidas", + error_invalid_words_message: "No se pudieron decodificar las palabras de recuperación.", + error_invalid_words_guidance: "Revisa las palabras por errores de escritura. Cada palabra debe ser de la lista BIP39 impresa en la hoja de recuperación.", error_title: "Algo salió mal", error_wasm_title: "Error al cargar la herramienta", error_wasm_message: "No se pudo cargar el módulo de recuperación en tu navegador.", @@ -295,7 +301,7 @@

3 Recover Fil error_wrong_manifest_guidance: "El archivo encriptado debe llamarse MANIFEST.age. Puedes encontrarlo dentro de cualquiera de los sobres distribuidos a los amigos.", error_paste_no_share_title: "No hay parte en el texto", error_paste_no_share_message: "El texto pegado no contiene una parte de recuperación válida.", - error_paste_no_share_guidance: "Copia todo el contenido del archivo README.txt de tu amigo, incluyendo los marcadores 'BEGIN REMEMORY SHARE' y 'END REMEMORY SHARE'.", + error_paste_no_share_guidance: "Copia todo el contenido del archivo README.txt de tu amigo, incluyendo los marcadores 'BEGIN REMEMORY SHARE' y 'END REMEMORY SHARE'. También puedes escribir o pegar las 25 palabras de recuperación.", error_decrypt_title: "Error de desencriptación", error_decrypt_message: "No se pudo desencriptar el archivo con las partes proporcionadas.", error_decrypt_guidance: "Esto generalmente significa que las partes no corresponden a este archivo, o no se proporcionaron suficientes partes válidas. Asegúrate de que todas las partes sean del mismo conjunto.", @@ -344,8 +350,8 @@

3 Recover Fil duplicate: "Dieser Teil ist bereits vorhanden (Index {0})", no_share: "Kein Teil in {0} gefunden", invalid_share: "Der Teil in {0} ist ungültig: {1}", - paste_btn: "Teil einfügen", - paste_placeholder: "Teil-Text hier einfügen...", + paste_btn: "Teil einfügen oder Wiederherstellungswörter eingeben", + paste_placeholder: "Teil-Text einfügen oder 25 Wiederherstellungswörter hier eingeben...", paste_submit: "Teil hinzufügen", paste_no_share: "Kein Teil im eingefügten Text gefunden", your_share: "Dein Teil", @@ -358,6 +364,9 @@

3 Recover Fil scan_title: "QR-Code scannen", scan_hint: "Richte deine Kamera auf den QR-Code aus dem PDF deines Freundes", scan_camera_error: "Kein Zugriff auf die Kamera", + error_invalid_words_title: "Ungültige Wiederherstellungswörter", + error_invalid_words_message: "Die Wiederherstellungswörter konnten nicht dekodiert werden.", + error_invalid_words_guidance: "Überprüfe die Wörter auf Tippfehler. Jedes Wort sollte aus der BIP39-Wortliste stammen, die auf dem Wiederherstellungsblatt gedruckt ist.", error_title: "Etwas ist schiefgelaufen", error_wasm_title: "Wiederherstellungstool konnte nicht geladen werden", error_wasm_message: "Das Wiederherstellungsmodul konnte in deinem Browser nicht geladen werden.", @@ -385,7 +394,7 @@

3 Recover Fil error_wrong_manifest_guidance: "Das verschlüsselte Archiv sollte MANIFEST.age heißen. Du findest es in jedem der an Freunde verteilten Bundles.", error_paste_no_share_title: "Kein Teil im Text", error_paste_no_share_message: "Der eingefügte Text enthält keinen gültigen Wiederherstellungsteil.", - error_paste_no_share_guidance: "Kopiere den gesamten Inhalt der README.txt-Datei deines Freundes, einschließlich der 'BEGIN REMEMORY SHARE' und 'END REMEMORY SHARE' Markierungen.", + error_paste_no_share_guidance: "Kopiere den gesamten Inhalt der README.txt-Datei deines Freundes, einschließlich der 'BEGIN REMEMORY SHARE' und 'END REMEMORY SHARE' Markierungen. Du kannst auch die 25 Wiederherstellungswörter eingeben oder einfügen.", error_decrypt_title: "Entschlüsselung fehlgeschlagen", error_decrypt_message: "Das Archiv konnte mit den bereitgestellten Teilen nicht entschlüsselt werden.", error_decrypt_guidance: "Das bedeutet meist, dass die Teile nicht zu diesem Archiv passen oder nicht genügend gültige Teile bereitgestellt wurden. Stelle sicher, dass alle Teile aus demselben Wiederherstellungsset stammen.", @@ -434,8 +443,8 @@

3 Recover Fil duplicate: "Cette part est déjà présente (index {0})", no_share: "Aucune part trouvée dans {0}", invalid_share: "La part dans {0} est invalide : {1}", - paste_btn: "Coller une part", - paste_placeholder: "Collez le texte de la part ici...", + paste_btn: "Coller une part ou saisir les mots de récupération", + paste_placeholder: "Collez le texte de la part ou saisissez vos 25 mots de récupération ici...", paste_submit: "Ajouter la part", paste_no_share: "Aucune part trouvée dans le texte collé", your_share: "Votre part", @@ -448,6 +457,9 @@

3 Recover Fil scan_title: "Scanner un code QR", scan_hint: "Dirigez votre caméra vers le QR code du PDF de votre ami", scan_camera_error: "Impossible d'accéder à la caméra", + error_invalid_words_title: "Mots de récupération invalides", + error_invalid_words_message: "Les mots de récupération n'ont pas pu être décodés.", + error_invalid_words_guidance: "Vérifiez les mots pour les fautes de frappe. Chaque mot doit figurer dans la liste BIP39 imprimée sur la feuille de récupération.", error_title: "Une erreur s'est produite", error_wasm_title: "Échec du chargement de l'outil", error_wasm_message: "Le module de récupération n'a pas pu être chargé dans votre navigateur.", @@ -475,7 +487,7 @@

3 Recover Fil error_wrong_manifest_guidance: "L'archive chiffrée doit s'appeler MANIFEST.age. Vous pouvez la trouver dans n'importe quelle enveloppe distribuée aux amis.", error_paste_no_share_title: "Aucune part dans le texte", error_paste_no_share_message: "Le texte collé ne contient pas de part de récupération valide.", - error_paste_no_share_guidance: "Copiez tout le contenu du fichier README.txt de votre ami, y compris les marqueurs 'BEGIN REMEMORY SHARE' et 'END REMEMORY SHARE'.", + error_paste_no_share_guidance: "Copiez tout le contenu du fichier README.txt de votre ami, y compris les marqueurs 'BEGIN REMEMORY SHARE' et 'END REMEMORY SHARE'. Vous pouvez aussi saisir ou coller les 25 mots de récupération.", error_decrypt_title: "Échec du déchiffrement", error_decrypt_message: "L'archive n'a pas pu être déchiffrée avec les parts fournies.", error_decrypt_guidance: "Cela signifie généralement que les parts ne correspondent pas à cette archive, ou que le nombre de parts valides fourni est insuffisant. Assurez-vous que toutes les parts proviennent du même ensemble.", @@ -523,8 +535,8 @@

3 Recover Fil duplicate: "Ta sveženj je že tukaj (indeks {0})", no_share: "Sveženj ni bil najden v {0}", invalid_share: "Ta sveženj je videti neveljaven v {0}: {1}", - paste_btn: "Prilepi sveženj", - paste_placeholder: "Prilepite besedilo svežnja tukaj ...", + paste_btn: "Prilepite sveženj ali vnesite besede za obnovitev", + paste_placeholder: "Prilepite besedilo svežnja ali vnesite 25 besed za obnovitev tukaj...", paste_submit: "Dodaj sveženj", paste_no_share: "V prilepljenem besedilu ni bilo najdenega svežnja", your_share: "Vaš sveženj", @@ -537,6 +549,9 @@

3 Recover Fil scan_title: "Skeniraj QR kodo", scan_hint: "Usmerite kamero na QR kodo s prijateljevega PDF-ja", scan_camera_error: "Ni mogoče dostopati do kamere", + error_invalid_words_title: "Neveljavne besede za obnovitev", + error_invalid_words_message: "Besed za obnovitev ni bilo mogoče dekodirati.", + error_invalid_words_guidance: "Preverite besede za tipkarske napake. Vsaka beseda mora biti s seznama BIP39, natisnjenega na listu za obnovitev.", // Error titles error_title: "Nekaj je šlo narobe", error_wasm_title: "Orodje za obnovitev ni uspelo naložiti", @@ -565,7 +580,7 @@

3 Recover Fil error_wrong_manifest_guidance: "Šifriran arhiv mora biti poimenovan MANIFEST.age. Najdete ga lahko v katerem koli od svežnjev, ki so bili razdeljeni prijateljem.", error_paste_no_share_title: "V prilepljenem besedilu ni bilo najdenega svežnja", error_paste_no_share_message: "Prilepljeno besedilo ne vsebuje veljavnega svežnja za obnovitev.", - error_paste_no_share_guidance: "Kopirajte celotno vsebino iz datoteke README.txt vašega prijatelja, vključno z oznakami 'BEGIN REMEMORY SHARE' in 'END REMEMORY SHARE'.", + error_paste_no_share_guidance: "Kopirajte celotno vsebino iz datoteke README.txt vašega prijatelja, vključno z oznakami 'BEGIN REMEMORY SHARE' in 'END REMEMORY SHARE'. Lahko tudi vnesete ali prilepite 25 besed za obnovitev.", error_decrypt_title: "Dešifriranje ni uspelo", error_decrypt_message: "Arhiva ni bilo mogoče dešifrirati z danimi svežnji.", error_decrypt_guidance: "To običajno pomeni, da svežnji ne ustrezajo temu arhivu ali pa da ni bilo zagotovljenih dovolj veljavnih svežnjev. Prepričajte se, da so vsi svežnji iz istega nabora za obnovitev.", diff --git a/internal/html/assets/src/app.ts b/internal/html/assets/src/app.ts index 17720ad..b18cf3e 100644 --- a/internal/html/assets/src/app.ts +++ b/internal/html/assets/src/app.ts @@ -88,7 +88,7 @@ declare const t: TranslationFunction; scanQrBtn: document.getElementById('scan-qr-btn') as HTMLButtonElement | null, qrScannerModal: document.getElementById('qr-scanner-modal'), qrVideo: document.getElementById('qr-video') as HTMLVideoElement | null, - qrScannerClose: document.getElementById('qr-scanner-close') as HTMLButtonElement | null + qrScannerClose: document.getElementById('qr-scanner-close') as HTMLButtonElement | null, }; // Personalization data (embedded in HTML) @@ -288,7 +288,7 @@ declare const t: TranslationFunction; if (state.shares.some(s => s.index === share.index)) return; - if (state.shares.length === 0) { + if (state.shares.length === 0 || (state.threshold === 0 && share.threshold > 0)) { state.threshold = share.threshold; state.total = share.total; } @@ -549,14 +549,35 @@ declare const t: TranslationFunction; } share = result.share; } else { - showError( - t('error_paste_no_share_message'), - { - title: t('error_paste_no_share_title'), - guidance: t('error_paste_no_share_guidance') + // Try to extract BIP39 words from the pasted text + const extractedWords = extractWordsFromText(content); + if (extractedWords.length >= 25) { + const wordResult = window.rememoryDecodeWords(extractedWords); + if (!wordResult.error && wordResult.index > 0) { + // Valid words found — add share directly (25th word provides the index) + share = buildShareFromWords(wordResult); + if (!share) return; // error already shown + } else if (wordResult.error) { + // Words were detected but decoding failed — show the specific error + toast.error( + t('error_invalid_words_title'), + wordResult.error, + t('error_invalid_words_guidance') + ); + return; } - ); - return; + } + + if (!share) { + showError( + t('error_paste_no_share_message'), + { + title: t('error_paste_no_share_title'), + guidance: t('error_paste_no_share_guidance') + } + ); + return; + } } if (state.shares.some(s => s.index === share.index)) { @@ -564,7 +585,7 @@ declare const t: TranslationFunction; return; } - if (state.shares.length === 0) { + if (state.shares.length === 0 || (state.threshold === 0 && share.threshold > 0)) { state.threshold = share.threshold; state.total = share.total; } @@ -574,6 +595,38 @@ declare const t: TranslationFunction; checkRecoverReady(); } + // ============================================ + // Build Share from Decoded Words + // ============================================ + + function buildShareFromWords(wordResult: { data: Uint8Array; index: number; checksum: string }): import('./types').ParsedShare | null { + const shareIndex = wordResult.index; + + // Get version/total/threshold from first loaded share or personalization + let version = 2; + let total = 0; + let threshold = 0; + + if (state.shares.length > 0) { + version = state.shares[0].version; + total = state.total; + threshold = state.threshold; + } else if (personalization) { + total = personalization.total; + threshold = personalization.threshold; + } + + // Construct ParsedShare from decoded data + const dataB64 = btoa(String.fromCharCode(...wordResult.data)); + return { + version, + index: shareIndex, + threshold, + total, + dataB64 + }; + } + // ============================================ // QR Code Scanner (BarcodeDetector API) // ============================================ @@ -744,7 +797,7 @@ declare const t: TranslationFunction; return; } - if (state.shares.length === 0) { + if (state.shares.length === 0 || (state.threshold === 0 && share.threshold > 0)) { state.threshold = share.threshold; state.total = share.total; } @@ -784,7 +837,7 @@ declare const t: TranslationFunction; return; } - if (state.shares.length === 0) { + if (state.shares.length === 0 || (state.threshold === 0 && share.threshold > 0)) { state.threshold = share.threshold; state.total = share.total; } @@ -943,9 +996,10 @@ declare const t: TranslationFunction; } function checkRecoverReady(): void { - const ready = state.shares.length >= state.threshold && - state.threshold > 0 && - state.manifest !== null; + const ready = state.manifest !== null && ( + (state.threshold > 0 && state.shares.length >= state.threshold) || + (state.threshold === 0 && state.shares.length >= 2) + ); if (elements.recoverBtn) { elements.recoverBtn.disabled = !ready; @@ -982,6 +1036,7 @@ declare const t: TranslationFunction; setStatus(t('combining')); const sharesForCombine: ShareInput[] = state.shares.map(s => ({ + version: s.version, index: s.index, dataB64: s.dataB64 })); @@ -1097,6 +1152,35 @@ declare const t: TranslationFunction; state.manifest = null; } + // ============================================ + // Word Extraction + // ============================================ + + // extractWordsFromText extracts BIP39 words from text, handling: + // - Numbered two-column grids: " 1. merit 14. beef" (sorted by number) + // - Plain word lists: "merit often shuffle wedding" + function extractWordsFromText(text: string): string[] { + // Try to parse numbered format first (e.g. "1. word", "13. word") + const numbered: { idx: number; word: string }[] = []; + const re = /(\d+)\.\s+([a-zA-Z]+)/g; + let m; + while ((m = re.exec(text)) !== null) { + numbered.push({ idx: parseInt(m[1], 10), word: m[2].toLowerCase() }); + } + + if (numbered.length >= 25) { + // Sort by number to handle two-column grids correctly + numbered.sort((a, b) => a.idx - b.idx); + return numbered.map(e => e.word); + } + + // Fallback: plain word list (no numbers) + return text + .toLowerCase() + .split(/\s+/) + .filter(w => w.length > 0 && /^[a-z]+$/.test(w)); + } + // ============================================ // Utility Functions // ============================================ diff --git a/internal/html/assets/src/types.ts b/internal/html/assets/src/types.ts index f5555aa..3999199 100644 --- a/internal/html/assets/src/types.ts +++ b/internal/html/assets/src/types.ts @@ -6,6 +6,7 @@ // ============================================ export interface ParsedShare { + version: number; index: number; threshold: number; total: number; @@ -16,6 +17,7 @@ export interface ParsedShare { } export interface ShareInput { + version: number; index: number; dataB64: string; } @@ -187,6 +189,7 @@ declare global { rememoryExtractTarGz(data: Uint8Array): ExtractResult; rememoryExtractBundle(zipData: Uint8Array): BundleExtractResult; rememoryParseCompactShare(compact: string): ShareParseResult; + rememoryDecodeWords(words: string[]): { data: Uint8Array; index: number; checksum: string; error?: string }; // Creation functions (create.wasm) rememoryCreateBundles(config: BundleConfig): BundleCreateResult; diff --git a/internal/integration_test.go b/internal/integration_test.go index 6e2c8c1..9b5d285 100644 --- a/internal/integration_test.go +++ b/internal/integration_test.go @@ -74,7 +74,7 @@ func TestFullWorkflow(t *testing.T) { // Create share objects with metadata shareObjects := make([]*core.Share, len(shares)) for i, data := range shares { - shareObjects[i] = core.NewShare(i+1, len(friends), threshold, friends[i].Name, data) + shareObjects[i] = core.NewShare(1, i+1, len(friends), threshold, friends[i].Name, data) } // Verify immediate reconstruction @@ -181,7 +181,7 @@ func TestInsufficientShares(t *testing.T) { // TestCorruptedShare verifies that corrupted shares are detected func TestCorruptedShare(t *testing.T) { - share := core.NewShare(1, 5, 3, "Alice", []byte("test-data")) + share := core.NewShare(1, 1, 5, 3, "Alice", []byte("test-data")) encoded := share.Encode() // Parse and verify - should work @@ -289,7 +289,7 @@ func TestAllThresholdCombinations(t *testing.T) { // Create share objects and encode/decode them shareObjs := make([]*core.Share, n) for i, data := range shares { - shareObjs[i] = core.NewShare(i+1, n, k, "", data) + shareObjs[i] = core.NewShare(1, i+1, n, k, "", data) encoded := shareObjs[i].Encode() parsed, err := core.ParseShare([]byte(encoded)) if err != nil { @@ -381,7 +381,7 @@ func TestBundleGeneration(t *testing.T) { shareInfos := make([]project.ShareInfo, len(friends)) for i, data := range shares { - share := core.NewShare(i+1, len(friends), threshold, friends[i].Name, data) + share := core.NewShare(1, i+1, len(friends), threshold, friends[i].Name, data) sharePath := filepath.Join(p.SharesPath(), share.Filename()) if err := os.WriteFile(sharePath, []byte(share.Encode()), 0644); err != nil { t.Fatalf("writing share: %v", err) @@ -598,7 +598,7 @@ func TestBundleRecovery(t *testing.T) { shares, _ := core.Split([]byte(passphrase), len(friends), threshold) shareInfos := make([]project.ShareInfo, len(friends)) for i, data := range shares { - share := core.NewShare(i+1, len(friends), threshold, friends[i].Name, data) + share := core.NewShare(1, i+1, len(friends), threshold, friends[i].Name, data) sharePath := filepath.Join(p.SharesPath(), share.Filename()) os.WriteFile(sharePath, []byte(share.Encode()), 0644) shareInfos[i] = project.ShareInfo{ @@ -763,7 +763,7 @@ func TestAnonymousBundleGeneration(t *testing.T) { shares, _ := core.Split([]byte(passphrase), len(p.Friends), p.Threshold) shareInfos := make([]project.ShareInfo, len(p.Friends)) for i, data := range shares { - share := core.NewShare(i+1, len(p.Friends), p.Threshold, p.Friends[i].Name, data) + share := core.NewShare(1, i+1, len(p.Friends), p.Threshold, p.Friends[i].Name, data) sharePath := filepath.Join(p.SharesPath(), share.Filename()) os.WriteFile(sharePath, []byte(share.Encode()), 0644) shareInfos[i] = project.ShareInfo{ @@ -909,7 +909,7 @@ func TestAnonymousBundleRecovery(t *testing.T) { shares, _ := core.Split([]byte(passphrase), len(p.Friends), p.Threshold) shareInfos := make([]project.ShareInfo, len(p.Friends)) for i, data := range shares { - share := core.NewShare(i+1, len(p.Friends), p.Threshold, p.Friends[i].Name, data) + share := core.NewShare(1, i+1, len(p.Friends), p.Threshold, p.Friends[i].Name, data) sharePath := filepath.Join(p.SharesPath(), share.Filename()) os.WriteFile(sharePath, []byte(share.Encode()), 0644) shareInfos[i] = project.ShareInfo{ diff --git a/internal/pdf/readme.go b/internal/pdf/readme.go index 8612593..87ea72b 100644 --- a/internal/pdf/readme.go +++ b/internal/pdf/readme.go @@ -153,6 +153,36 @@ func GenerateReadme(data ReadmeData) ([]byte, error) { pdf.CellFormat(0, 4, compact, "", 1, "C", true, 0, "") pdf.Ln(8) + // Word grid (25 recovery words in two columns: 24 data words + 1 index word) + words, _ := data.Share.Words() + if len(words) > 0 { + addSection(pdf, fmt.Sprintf("YOUR %d RECOVERY WORDS", len(words))) + pdf.SetFont(fontMono, "", bodySize) + + half := (len(words) + 1) / 2 + colWidth := contentWidth / 2 + startY := pdf.GetY() + + for i := 0; i < half; i++ { + y := startY + float64(i)*5.5 + + // Left column: words 1-12 + pdf.SetXY(leftMargin, y) + pdf.CellFormat(colWidth, 5, fmt.Sprintf("%2d. %s", i+1, words[i]), "", 0, "L", false, 0, "") + + // Right column: words 13-24 + if i+half < len(words) { + pdf.SetXY(leftMargin+colWidth, y) + pdf.CellFormat(colWidth, 5, fmt.Sprintf("%2d. %s", i+half+1, words[i+half]), "", 0, "L", false, 0, "") + } + } + + pdf.SetY(startY + float64(half)*5.5 + 2) + pdf.SetFont(fontSans, "I", bodySize) + pdf.MultiCell(0, 5, "Read these words to the person helping you recover, or type them into the recovery tool.", "", "L", false) + pdf.Ln(5) + } + // PEM block (machine-readable format) addSection(pdf, "MACHINE-READABLE FORMAT") pdf.SetFont(fontMono, "", smallMono) diff --git a/internal/pdf/readme_test.go b/internal/pdf/readme_test.go index 0a1d83e..50bbe32 100644 --- a/internal/pdf/readme_test.go +++ b/internal/pdf/readme_test.go @@ -12,7 +12,7 @@ import ( ) func testReadmeData() ReadmeData { - share := core.NewShare(1, 3, 2, "Alice", []byte("test-share-data-for-qr-code-12345")) + share := core.NewShare(1, 1, 3, 2, "Alice", []byte("test-share-data-for-qr-code-12345")) return ReadmeData{ ProjectName: "Test Project", Holder: "Alice", @@ -111,7 +111,7 @@ func TestQRCodeGeneratesValidPNG(t *testing.T) { func TestQRCodeContentMatchesCompact(t *testing.T) { // Verify the QR content is the default URL with compact share in fragment - share := core.NewShare(2, 5, 3, "Bob", []byte("another-share-data-for-testing")) + share := core.NewShare(1, 2, 5, 3, "Bob", []byte("another-share-data-for-testing")) data := ReadmeData{ Share: share, Holder: "Bob", diff --git a/internal/wasm/create.go b/internal/wasm/create.go index 1d62f3c..e6ba2c4 100644 --- a/internal/wasm/create.go +++ b/internal/wasm/create.go @@ -7,7 +7,6 @@ import ( "archive/zip" "bytes" "compress/gzip" - "encoding/base64" "fmt" "syscall/js" "time" @@ -158,8 +157,8 @@ func createBundles(config CreateBundlesConfig) ([]BundleOutput, error) { return nil, fmt.Errorf("creating archive: %w", err) } - // Generate random passphrase - passphrase, err := crypto.GeneratePassphrase(crypto.DefaultPassphraseBytes) + // Generate random passphrase (v2: split raw bytes, not the base64 string) + raw, passphrase, err := crypto.GenerateRawPassphrase(crypto.DefaultPassphraseBytes) if err != nil { return nil, fmt.Errorf("generating passphrase: %w", err) } @@ -175,7 +174,7 @@ func createBundles(config CreateBundlesConfig) ([]BundleOutput, error) { // Split passphrase using Shamir's Secret Sharing n := len(config.Friends) k := config.Threshold - rawShares, err := core.Split([]byte(passphrase), n, k) + rawShares, err := core.Split(raw, n, k) if err != nil { return nil, fmt.Errorf("splitting passphrase: %w", err) } @@ -194,7 +193,7 @@ func createBundles(config CreateBundlesConfig) ([]BundleOutput, error) { // Create all shares first for i, friend := range config.Friends { share := &core.Share{ - Version: 1, + Version: 2, Index: i + 1, Total: n, Threshold: k, @@ -456,184 +455,6 @@ func parseProjectYAML(yamlText string) (*ProjectYAML, error) { return &proj, nil } -// generatePassphraseJS generates a random passphrase. -// Args: numBytes (int, optional - defaults to 32) -// Returns: { passphrase: string, error: string|null } -func generatePassphraseJS(this js.Value, args []js.Value) any { - numBytes := crypto.DefaultPassphraseBytes - if len(args) > 0 && !args[0].IsUndefined() && !args[0].IsNull() { - numBytes = args[0].Int() - } - - passphrase, err := crypto.GeneratePassphrase(numBytes) - if err != nil { - return errorResult(err.Error()) - } - - return js.ValueOf(map[string]any{ - "passphrase": passphrase, - "error": nil, - }) -} - -// hashBytesJS computes SHA-256 hash of bytes. -// Args: data (Uint8Array) -// Returns: string (sha256:...) -func hashBytesJS(this js.Value, args []js.Value) any { - if len(args) < 1 { - return "" - } - - jsData := args[0] - dataLen := jsData.Get("length").Int() - data := make([]byte, dataLen) - js.CopyBytesToGo(data, jsData) - - return core.HashBytes(data) -} - -// encryptAgeJS encrypts data using age/scrypt. -// Args: data (Uint8Array), passphrase (string) -// Returns: { encrypted: Uint8Array, error: string|null } -func encryptAgeJS(this js.Value, args []js.Value) any { - if len(args) < 2 { - return errorResult("missing arguments (need data, passphrase)") - } - - jsData := args[0] - dataLen := jsData.Get("length").Int() - data := make([]byte, dataLen) - js.CopyBytesToGo(data, jsData) - - passphrase := args[1].String() - - var buf bytes.Buffer - if err := core.Encrypt(&buf, bytes.NewReader(data), passphrase); err != nil { - return errorResult(err.Error()) - } - - encrypted := buf.Bytes() - jsResult := js.Global().Get("Uint8Array").New(len(encrypted)) - js.CopyBytesToJS(jsResult, encrypted) - - return js.ValueOf(map[string]any{ - "encrypted": jsResult, - "error": nil, - }) -} - -// splitPassphraseJS splits a passphrase using Shamir's Secret Sharing. -// Args: passphrase (string), n (int), k (int) -// Returns: { shares: [{index, dataB64}], error: string|null } -func splitPassphraseJS(this js.Value, args []js.Value) any { - if len(args) < 3 { - return errorResult("missing arguments (need passphrase, n, k)") - } - - passphrase := args[0].String() - n := args[1].Int() - k := args[2].Int() - - shares, err := core.Split([]byte(passphrase), n, k) - if err != nil { - return errorResult(err.Error()) - } - - jsShares := make([]any, len(shares)) - for i, shareData := range shares { - jsShares[i] = map[string]any{ - "index": i + 1, - "dataB64": base64.StdEncoding.EncodeToString(shareData), - } - } - - return js.ValueOf(map[string]any{ - "shares": jsShares, - "error": nil, - }) -} - -// createShareJS creates an encoded share. -// Args: index, total, threshold (int), holder (string), dataB64 (string), created (string RFC3339) -// Returns: { encoded: string, error: string|null } -func createShareJS(this js.Value, args []js.Value) any { - if len(args) < 6 { - return errorResult("missing arguments") - } - - index := args[0].Int() - total := args[1].Int() - threshold := args[2].Int() - holder := args[3].String() - dataB64 := args[4].String() - createdStr := args[5].String() - - data, err := base64.StdEncoding.DecodeString(dataB64) - if err != nil { - return errorResult(fmt.Sprintf("invalid base64 data: %v", err)) - } - - created, err := time.Parse(time.RFC3339, createdStr) - if err != nil { - return errorResult(fmt.Sprintf("invalid created time: %v", err)) - } - - share := &core.Share{ - Version: 1, - Index: index, - Total: total, - Threshold: threshold, - Holder: holder, - Created: created, - Data: data, - Checksum: core.HashBytes(data), - } - - return js.ValueOf(map[string]any{ - "encoded": share.Encode(), - "error": nil, - }) -} - -// createTarGzJS creates a tar.gz archive from file entries. -// Args: files (array of {name: string, data: Uint8Array}) -// Returns: { data: Uint8Array, error: string|null } -func createTarGzJS(this js.Value, args []js.Value) any { - if len(args) < 1 { - return errorResult("missing files argument") - } - - filesJS := args[0] - filesLen := filesJS.Length() - files := make([]FileEntry, filesLen) - - for i := 0; i < filesLen; i++ { - f := filesJS.Index(i) - name := f.Get("name").String() - dataJS := f.Get("data") - dataLen := dataJS.Get("length").Int() - data := make([]byte, dataLen) - js.CopyBytesToGo(data, dataJS) - files[i] = FileEntry{ - Name: name, - Data: data, - } - } - - archiveData, err := createTarGz(files) - if err != nil { - return errorResult(err.Error()) - } - - jsResult := js.Global().Get("Uint8Array").New(len(archiveData)) - js.CopyBytesToJS(jsResult, archiveData) - - return js.ValueOf(map[string]any{ - "data": jsResult, - "error": nil, - }) -} - // Functions are registered in main.go func init() { // This file is compiled only for WASM, functions will be registered in main() diff --git a/internal/wasm/js_wrappers.go b/internal/wasm/js_wrappers.go index e311a1a..1f41a76 100644 --- a/internal/wasm/js_wrappers.go +++ b/internal/wasm/js_wrappers.go @@ -41,6 +41,7 @@ func combineSharesJS(this js.Value, args []js.Value) any { for i := 0; i < length; i++ { shareObj := sharesArray.Index(i) shares[i] = ShareData{ + Version: shareObj.Get("version").Int(), Index: shareObj.Get("index").Int(), DataB64: shareObj.Get("dataB64").String(), } @@ -180,6 +181,40 @@ func parseCompactShareJS(this js.Value, args []js.Value) any { }) } +// decodeWordsJS decodes 25 BIP39 words to raw share data bytes and share index. +// The first 24 words encode the data; the 25th word packs 4 bits of index + 7 bits of checksum. +// Returns index=0 if the share index was > 15 (sentinel for "unknown — UI should not highlight a specific contact"). +// Returns an error if the embedded checksum doesn't match (wrong word order, typos, etc.). +// Args: words (string array) +// Returns: { data: Uint8Array, index: number, checksum: string, error: string|null } +func decodeWordsJS(this js.Value, args []js.Value) any { + if len(args) < 1 { + return errorResult("missing words argument") + } + + wordsArray := args[0] + length := wordsArray.Length() + words := make([]string, length) + for i := 0; i < length; i++ { + words[i] = wordsArray.Index(i).String() + } + + data, index, checksum, err := decodeShareWords(words) + if err != nil { + return errorResult(err.Error()) + } + + jsData := js.Global().Get("Uint8Array").New(len(data)) + js.CopyBytesToJS(jsData, data) + + return js.ValueOf(map[string]any{ + "data": jsData, + "index": index, + "checksum": checksum, + "error": nil, + }) +} + // shareInfoToJS converts a ShareInfo to a JS-compatible map. func shareInfoToJS(s *ShareInfo) map[string]any { return map[string]any{ diff --git a/internal/wasm/main_create.go b/internal/wasm/main_create.go index 7cb55ce..120bfec 100644 --- a/internal/wasm/main_create.go +++ b/internal/wasm/main_create.go @@ -14,16 +14,11 @@ func main() { js.Global().Set("rememoryExtractTarGz", js.FuncOf(extractTarGzJS)) js.Global().Set("rememoryExtractBundle", js.FuncOf(extractBundleJS)) js.Global().Set("rememoryParseCompactShare", js.FuncOf(parseCompactShareJS)) + js.Global().Set("rememoryDecodeWords", js.FuncOf(decodeWordsJS)) // Register bundle creation functions js.Global().Set("rememoryCreateBundles", js.FuncOf(createBundlesJS)) js.Global().Set("rememoryParseProjectYAML", js.FuncOf(parseProjectYAMLJS)) - js.Global().Set("rememoryGeneratePassphrase", js.FuncOf(generatePassphraseJS)) - js.Global().Set("rememoryHashBytes", js.FuncOf(hashBytesJS)) - js.Global().Set("rememoryEncryptAge", js.FuncOf(encryptAgeJS)) - js.Global().Set("rememorySplitPassphrase", js.FuncOf(splitPassphraseJS)) - js.Global().Set("rememoryCreateShare", js.FuncOf(createShareJS)) - js.Global().Set("rememoryCreateTarGz", js.FuncOf(createTarGzJS)) // Signal that WASM is ready js.Global().Set("rememoryReady", true) diff --git a/internal/wasm/main_recover.go b/internal/wasm/main_recover.go index 2c44574..9d4cb81 100644 --- a/internal/wasm/main_recover.go +++ b/internal/wasm/main_recover.go @@ -14,6 +14,7 @@ func main() { js.Global().Set("rememoryExtractTarGz", js.FuncOf(extractTarGzJS)) js.Global().Set("rememoryExtractBundle", js.FuncOf(extractBundleJS)) js.Global().Set("rememoryParseCompactShare", js.FuncOf(parseCompactShareJS)) + js.Global().Set("rememoryDecodeWords", js.FuncOf(decodeWordsJS)) // Signal that WASM is ready js.Global().Set("rememoryReady", true) diff --git a/internal/wasm/recover.go b/internal/wasm/recover.go index fb20876..98d8e6d 100644 --- a/internal/wasm/recover.go +++ b/internal/wasm/recover.go @@ -28,6 +28,7 @@ type ShareInfo struct { // ShareData is minimal data needed for combining. type ShareData struct { + Version int Index int DataB64 string } @@ -81,6 +82,13 @@ func combineShares(shares []ShareData) (string, error) { return "", fmt.Errorf("need at least 2 shares, got %d", len(shares)) } + // Validate all shares have the same version + for i := 1; i < len(shares); i++ { + if shares[i].Version != shares[0].Version { + return "", fmt.Errorf("share %d has different version (v%d vs v%d) — all shares must be from the same bundle", i+1, shares[i].Version, shares[0].Version) + } + } + // Convert to raw bytes for core.Combine rawShares := make([][]byte, len(shares)) for i, s := range shares { @@ -97,7 +105,7 @@ func combineShares(shares []ShareData) (string, error) { return "", fmt.Errorf("combining shares: %w", err) } - return string(secret), nil + return core.RecoverPassphrase(secret, shares[0].Version), nil } // decryptManifest decrypts age-encrypted data using a passphrase. @@ -112,6 +120,18 @@ func extractTarGz(tarGzData []byte) ([]core.ExtractedFile, error) { return core.ExtractTarGz(tarGzData) } +// decodeShareWords converts 25 BIP39 words to raw share data bytes and share index. +// The first 24 words encode the data; the 25th word packs 4 bits of index + 7 bits of checksum. +// Returns the decoded bytes, share index (0 if share >15), checksum, and any error. +// Returns an error if the embedded checksum doesn't match (wrong word order, typos, etc.). +func decodeShareWords(words []string) ([]byte, int, string, error) { + data, index, err := core.DecodeShareWords(words) + if err != nil { + return nil, 0, "", err + } + return data, index, core.HashBytes(data), nil +} + // BundleContents represents extracted content from a bundle ZIP. type BundleContents struct { Share *ShareInfo // Parsed share from README.txt