Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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")
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
Expand All @@ -111,6 +141,15 @@ export class RecoveryPage {
);
}

// Navigate to a standalone recover.html file (no personalization)
async openFile(htmlPath: string): Promise<void> {
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<void> {
const readmePaths = bundleDirs.map(dir => path.join(dir, 'README.txt'));
Expand Down
149 changes: 149 additions & 0 deletions e2e/recovery.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { test, expect } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import {
getRememoryBin,
createTestProject,
createAnonymousTestProject,
extractBundle,
extractBundles,
extractAnonymousBundles,
extractWordsFromReadme,
generateStandaloneHTML,
RecoveryPage
} from './helpers';

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
});
});
23 changes: 22 additions & 1 deletion internal/bundle/readme.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
11 changes: 8 additions & 3 deletions internal/cmd/recover.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}

Expand All @@ -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)
}

Expand Down
13 changes: 7 additions & 6 deletions internal/cmd/seal.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"bytes"
"encoding/base64"
"fmt"
"os"
"path/filepath"
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
Expand Down Expand Up @@ -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")
}
Expand Down
Loading