diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index fc37b2a..b871a08 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -53,6 +53,7 @@ jobs: ./rememory html index > _site/index.html ./rememory html create > _site/maker.html ./rememory html docs > _site/docs.html + ./rememory html recover > _site/recover.html cp docs/screenshots/* _site/screenshots/ 2>/dev/null || true - name: Setup Pages diff --git a/Makefile b/Makefile index d11d3a6..f49d0af 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 +.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 BINARY := rememory VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null || echo "dev") @@ -81,6 +81,12 @@ serve: html @echo "Serving at http://localhost:8000" @cd dist && python3 -m http.server 8000 +# Run demo: clean, build, and create a demo project +demo: build + rm -rf demo-recovery + ./$(BINARY) demo + open demo-recovery/output/bundles/bundle-alice.zip + # Cross-compile for all platforms (used by CI) build-all: wasm @mkdir -p dist diff --git a/docs/screenshots/manifest-file-picker.png b/docs/screenshots/manifest-file-picker.png new file mode 100644 index 0000000..7923272 Binary files /dev/null and b/docs/screenshots/manifest-file-picker.png differ diff --git a/docs/screenshots/qr-camera-permission.png b/docs/screenshots/qr-camera-permission.png new file mode 100644 index 0000000..966cdf4 Binary files /dev/null and b/docs/screenshots/qr-camera-permission.png differ diff --git a/docs/screenshots/qr-scanning.png b/docs/screenshots/qr-scanning.png new file mode 100644 index 0000000..0bf4cff Binary files /dev/null and b/docs/screenshots/qr-scanning.png differ diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 8450c5c..39718c9 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -136,7 +136,8 @@ export class RecoveryPage { } async expectShareHolder(name: string): Promise { - await expect(this.page.locator('.share-item').filter({ hasText: name })).toBeVisible(); + // Use toBeAttached() since shares may be hidden when threshold is met + await expect(this.page.locator('.share-item').filter({ hasText: name })).toBeAttached(); } async expectReadyToRecover(): Promise { @@ -144,7 +145,8 @@ export class RecoveryPage { } async expectNeedMoreShares(count: number): Promise { - await expect(this.page.locator('#threshold-info')).toContainText(`Waiting for ${count} more piece`); + const expected = count === 1 ? 'Waiting for the last piece' : `Waiting for ${count} more pieces`; + await expect(this.page.locator('#threshold-info')).toContainText(expected); } async expectManifestLoaded(): Promise { diff --git a/e2e/qr-scanner.spec.ts b/e2e/qr-scanner.spec.ts new file mode 100644 index 0000000..05a1add --- /dev/null +++ b/e2e/qr-scanner.spec.ts @@ -0,0 +1,328 @@ +import { test, expect } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + getRememoryBin, + createTestProject, + extractBundle, + extractBundles, + RecoveryPage +} from './helpers'; + +test.describe('QR Scanner', () => { + let projectDir: string; + let bundlesDir: string; + + test.beforeAll(async () => { + const bin = getRememoryBin(); + if (!fs.existsSync(bin)) { + test.skip(); + return; + } + + projectDir = createTestProject(); + bundlesDir = path.join(projectDir, 'output', 'bundles'); + }); + + test.afterAll(async () => { + if (projectDir && fs.existsSync(projectDir)) { + fs.rmSync(projectDir, { recursive: true, force: true }); + } + }); + + test('scan button is visible when BarcodeDetector is available', async ({ page }) => { + const bundleDir = extractBundle(bundlesDir, 'Alice'); + + // Mock BarcodeDetector before page loads + await page.addInitScript(() => { + (window as any).BarcodeDetector = class { + constructor() {} + async detect() { return []; } + static async getSupportedFormats() { return ['qr_code']; } + }; + }); + + const recovery = new RecoveryPage(page, bundleDir); + await recovery.open(); + + await expect(page.locator('#scan-qr-btn')).toBeVisible(); + }); + + test('scan button is hidden when BarcodeDetector is not available', async ({ page }) => { + const bundleDir = extractBundle(bundlesDir, 'Alice'); + + // Ensure BarcodeDetector is NOT defined (default for most test environments) + await page.addInitScript(() => { + delete (window as any).BarcodeDetector; + }); + + const recovery = new RecoveryPage(page, bundleDir); + await recovery.open(); + + await expect(page.locator('#scan-qr-btn')).not.toBeVisible(); + }); + + test('clicking scan opens modal and close button dismisses it', async ({ page }) => { + const bundleDir = extractBundle(bundlesDir, 'Alice'); + + await page.addInitScript(() => { + (window as any).BarcodeDetector = class { + constructor() {} + async detect() { return []; } + static async getSupportedFormats() { return ['qr_code']; } + }; + + navigator.mediaDevices.getUserMedia = async () => { + const canvas = document.createElement('canvas'); + canvas.width = 640; + canvas.height = 480; + const ctx = canvas.getContext('2d')!; + ctx.fillStyle = '#000'; + ctx.fillRect(0, 0, 640, 480); + return canvas.captureStream(1); + }; + }); + + const recovery = new RecoveryPage(page, bundleDir); + await recovery.open(); + + // Modal should be hidden initially + await expect(page.locator('#qr-scanner-modal')).not.toBeVisible(); + + // Click scan button + await page.locator('#scan-qr-btn').click(); + + // Modal should be visible + await expect(page.locator('#qr-scanner-modal')).toBeVisible(); + + // Close button should dismiss modal + await page.locator('#qr-scanner-close').click(); + await expect(page.locator('#qr-scanner-modal')).not.toBeVisible(); + }); + + test('scanning a compact share adds it to the shares list', async ({ page }) => { + const [aliceDir, bobDir] = extractBundles(bundlesDir, ['Alice', 'Bob']); + + const recovery = new RecoveryPage(page, aliceDir); + + // Read Bob's PEM share + const bobReadme = fs.readFileSync(path.join(bobDir, 'README.txt'), 'utf8'); + const pemMatch = bobReadme.match( + /-----BEGIN REMEMORY SHARE-----([\s\S]*?)-----END REMEMORY SHARE-----/ + ); + if (!pemMatch) throw new Error('No PEM share found'); + const bobPemShare = pemMatch[0]; + + // Mock BarcodeDetector + getUserMedia with a real canvas-based video stream + await page.addInitScript(() => { + let detectCallCount = 0; + let compactShare = ''; + + (window as any).__qrTestSetCompact = (compact: string) => { + compactShare = compact; + }; + + (window as any).BarcodeDetector = class { + constructor() {} + async detect() { + detectCallCount++; + if (compactShare && detectCallCount > 3) { + return [{ rawValue: compactShare, format: 'qr_code', boundingBox: {}, cornerPoints: [] }]; + } + return []; + } + static async getSupportedFormats() { return ['qr_code']; } + }; + + // Use a real canvas capture stream so the video element gets readyState >= 2 + navigator.mediaDevices.getUserMedia = async () => { + const canvas = document.createElement('canvas'); + canvas.width = 640; + canvas.height = 480; + const ctx = canvas.getContext('2d')!; + ctx.fillStyle = '#000'; + ctx.fillRect(0, 0, 640, 480); + return canvas.captureStream(1); + }; + }); + + await recovery.open(); + await recovery.expectShareCount(1); + + // Convert Bob's PEM share to compact format via WASM + const compactShare = await page.evaluate((pem: string) => { + const result = (window as any).rememoryParseShare(pem); + if (result.error || !result.share) return ''; + return result.share.compact; + }, bobPemShare); + + expect(compactShare).toMatch(/^RM\d+:\d+:\d+:\d+:[A-Za-z0-9_-]+:[0-9a-f]{4}$/); + + // Verify the compact share parses correctly + const parseResult = await page.evaluate((compact: string) => { + return (window as any).rememoryParseCompactShare(compact); + }, compactShare); + expect(parseResult.error).toBeFalsy(); + + // Set the compact share for the mock BarcodeDetector to "find" + await page.evaluate((compact: string) => { + (window as any).__qrTestSetCompact(compact); + }, compactShare); + + // Open scanner + await page.locator('#scan-qr-btn').click(); + await expect(page.locator('#qr-scanner-modal')).toBeVisible(); + + // Wait for the share to be detected and added + await recovery.expectShareCount(2); + + // Modal should close after successful scan + await expect(page.locator('#qr-scanner-modal')).not.toBeVisible(); + }); + + test('scanning a URL with fragment adds the share', async ({ page }) => { + const [aliceDir, bobDir] = extractBundles(bundlesDir, ['Alice', 'Bob']); + + const bobReadme = fs.readFileSync(path.join(bobDir, 'README.txt'), 'utf8'); + const pemMatch = bobReadme.match( + /-----BEGIN REMEMORY SHARE-----([\s\S]*?)-----END REMEMORY SHARE-----/ + ); + if (!pemMatch) throw new Error('No PEM share found'); + + await page.addInitScript(() => { + let compactShare = ''; + + (window as any).__qrTestSetCompact = (compact: string) => { + compactShare = compact; + }; + + let detectCallCount = 0; + (window as any).BarcodeDetector = class { + constructor() {} + async detect() { + detectCallCount++; + if (compactShare && detectCallCount > 3) { + // Return as a URL with fragment, like the QR code from a PDF would contain + const url = `https://eljojo.github.io/rememory/recover.html#share=${encodeURIComponent(compactShare)}`; + return [{ rawValue: url, format: 'qr_code', boundingBox: {}, cornerPoints: [] }]; + } + return []; + } + static async getSupportedFormats() { return ['qr_code']; } + }; + + // Use a real canvas capture stream so the video element gets readyState >= 2 + navigator.mediaDevices.getUserMedia = async () => { + const canvas = document.createElement('canvas'); + canvas.width = 640; + canvas.height = 480; + const ctx = canvas.getContext('2d')!; + ctx.fillStyle = '#000'; + ctx.fillRect(0, 0, 640, 480); + return canvas.captureStream(1); + }; + }); + + const recovery = new RecoveryPage(page, aliceDir); + await recovery.open(); + + // Convert PEM share to compact format via WASM + const compactShare = await page.evaluate((pem: string) => { + const result = (window as any).rememoryParseShare(pem); + if (result.error || !result.share) return ''; + return result.share.compact; + }, pemMatch[0]); + + await page.evaluate((compact: string) => { + (window as any).__qrTestSetCompact(compact); + }, compactShare); + + await page.locator('#scan-qr-btn').click(); + + // Should detect the URL, extract the fragment, and add the share + await recovery.expectShareCount(2); + await expect(page.locator('#qr-scanner-modal')).not.toBeVisible(); + }); + + test('camera permission denied shows error and closes modal', async ({ page }) => { + const bundleDir = extractBundle(bundlesDir, 'Alice'); + + await page.addInitScript(() => { + (window as any).BarcodeDetector = class { + constructor() {} + async detect() { return []; } + static async getSupportedFormats() { return ['qr_code']; } + }; + + // Mock getUserMedia to reject (permission denied) + navigator.mediaDevices.getUserMedia = async () => { + throw new DOMException('Permission denied', 'NotAllowedError'); + }; + }); + + const recovery = new RecoveryPage(page, bundleDir); + await recovery.open(); + + await page.locator('#scan-qr-btn').click(); + + // Modal should close after error + await expect(page.locator('#qr-scanner-modal')).not.toBeVisible(); + + // A toast warning should appear + await expect(page.locator('.toast')).toBeVisible(); + }); + + test('camera tracks are stopped when modal is closed', async ({ page }) => { + const bundleDir = extractBundle(bundlesDir, 'Alice'); + + await page.addInitScript(() => { + (window as any).__qrTestTrackStopped = false; + + (window as any).BarcodeDetector = class { + constructor() {} + async detect() { return []; } + static async getSupportedFormats() { return ['qr_code']; } + }; + + // Use canvas capture stream but wrap tracks to detect stop() + const origGetUserMedia = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices); + navigator.mediaDevices.getUserMedia = async () => { + const canvas = document.createElement('canvas'); + canvas.width = 640; + canvas.height = 480; + const ctx = canvas.getContext('2d')!; + ctx.fillStyle = '#000'; + ctx.fillRect(0, 0, 640, 480); + const stream = canvas.captureStream(1); + + // Wrap track.stop() to detect when it's called + for (const track of stream.getTracks()) { + const origStop = track.stop.bind(track); + track.stop = () => { + (window as any).__qrTestTrackStopped = true; + origStop(); + }; + } + return stream; + }; + }); + + const recovery = new RecoveryPage(page, bundleDir); + await recovery.open(); + + // Open scanner + await page.locator('#scan-qr-btn').click(); + await expect(page.locator('#qr-scanner-modal')).toBeVisible(); + + // Verify track not yet stopped + let stopped = await page.evaluate(() => (window as any).__qrTestTrackStopped); + expect(stopped).toBe(false); + + // Close scanner + await page.locator('#qr-scanner-close').click(); + + // Verify track was stopped + stopped = await page.evaluate(() => (window as any).__qrTestTrackStopped); + expect(stopped).toBe(true); + }); +}); diff --git a/go.mod b/go.mod index d0eca73..3ac180d 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( filippo.io/age v1.3.1 github.com/go-pdf/fpdf v0.9.0 github.com/hashicorp/vault v1.21.2 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/spf13/cobra v1.10.2 golang.org/x/text v0.33.0 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 113730c..ddd0f6e 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= diff --git a/internal/bundle/bundle.go b/internal/bundle/bundle.go index ca9f797..2a12428 100644 --- a/internal/bundle/bundle.go +++ b/internal/bundle/bundle.go @@ -21,6 +21,7 @@ type Config struct { Version string // Tool version (e.g., "v1.0.0") GitHubReleaseURL string // URL to GitHub release for CLI download WASMBytes []byte // Compiled recover.wasm binary + RecoveryURL string // Optional: base URL for QR code (e.g. "https://example.com/recover.html") } // GenerateAll creates bundles for all friends in the project. @@ -57,18 +58,15 @@ func GenerateAll(p *project.Project, cfg Config) error { var otherFriendsInfo []html.FriendInfo if !p.Anonymous { otherFriends = make([]project.Friend, 0, len(p.Friends)-1) + otherFriendsInfo = make([]html.FriendInfo, 0, len(p.Friends)-1) for j, f := range p.Friends { if j != i { otherFriends = append(otherFriends, f) - } - } - - // Convert to FriendInfo for HTML personalization - otherFriendsInfo = make([]html.FriendInfo, len(otherFriends)) - for j, f := range otherFriends { - otherFriendsInfo[j] = html.FriendInfo{ - Name: f.Name, - Contact: f.Contact, + otherFriendsInfo = append(otherFriendsInfo, html.FriendInfo{ + Name: f.Name, + Contact: f.Contact, + ShareIndex: j + 1, // 1-based share index + }) } } } @@ -102,6 +100,7 @@ func GenerateAll(p *project.Project, cfg Config) error { GitHubReleaseURL: cfg.GitHubReleaseURL, SealedAt: p.Sealed.At, Anonymous: p.Anonymous, + RecoveryURL: cfg.RecoveryURL, }) if err != nil { return fmt.Errorf("generating bundle for %s: %w", friend.Name, err) @@ -133,6 +132,7 @@ type BundleParams struct { GitHubReleaseURL string SealedAt time.Time Anonymous bool + RecoveryURL string } // GenerateBundle creates a single bundle ZIP file for one friend. @@ -170,6 +170,7 @@ func GenerateBundle(params BundleParams) error { RecoverChecksum: readmeData.RecoverChecksum, Created: readmeData.Created, Anonymous: readmeData.Anonymous, + RecoveryURL: params.RecoveryURL, }) if err != nil { return fmt.Errorf("generating PDF: %w", err) diff --git a/internal/cmd/bundle.go b/internal/cmd/bundle.go index 0b35bac..475301e 100644 --- a/internal/cmd/bundle.go +++ b/internal/cmd/bundle.go @@ -6,6 +6,7 @@ import ( "path/filepath" "github.com/eljojo/rememory/internal/bundle" + "github.com/eljojo/rememory/internal/core" "github.com/eljojo/rememory/internal/html" "github.com/eljojo/rememory/internal/project" "github.com/spf13/cobra" @@ -30,6 +31,7 @@ Each bundle contains: } func init() { + bundleCmd.Flags().String("recovery-url", core.DefaultRecoveryURL, "Base URL for QR code in PDF") rootCmd.AddCommand(bundleCmd) } @@ -65,10 +67,13 @@ func runBundle(cmd *cobra.Command, args []string) error { // Generate bundles fmt.Printf("Generating bundles for %d friends...\n\n", len(p.Friends)) + recoveryURL, _ := cmd.Flags().GetString("recovery-url") + cfg := bundle.Config{ Version: version, GitHubReleaseURL: fmt.Sprintf("https://github.com/eljojo/rememory/releases/tag/%s", version), WASMBytes: wasmBytes, + RecoveryURL: recoveryURL, } if err := bundle.GenerateAll(p, cfg); err != nil { diff --git a/internal/cmd/demo.go b/internal/cmd/demo.go index 9287192..ed810d2 100644 --- a/internal/cmd/demo.go +++ b/internal/cmd/demo.go @@ -119,7 +119,7 @@ Note: In a real project, these would be your actual sensitive credentials. fmt.Printf(" %s manifest/passwords.txt\n", green("✓")) fmt.Println() - if err := sealProject(p); err != nil { + if err := sealProject(p, ""); err != nil { return err } diff --git a/internal/cmd/seal.go b/internal/cmd/seal.go index 487863c..dcd6985 100644 --- a/internal/cmd/seal.go +++ b/internal/cmd/seal.go @@ -35,6 +35,7 @@ Run this command inside a project directory (created with 'rememory init').`, } func init() { + sealCmd.Flags().String("recovery-url", core.DefaultRecoveryURL, "Base URL for QR code in PDF") rootCmd.AddCommand(sealCmd) } @@ -59,7 +60,9 @@ func runSeal(cmd *cobra.Command, args []string) error { return fmt.Errorf("invalid project: %w", err) } - if err := sealProject(p); err != nil { + recoveryURL, _ := cmd.Flags().GetString("recovery-url") + + if err := sealProject(p, recoveryURL); err != nil { return err } @@ -71,7 +74,8 @@ func runSeal(cmd *cobra.Command, args []string) error { // sealProject archives, encrypts, splits, verifies, saves, and generates bundles // for an already-loaded project. Both runSeal and runDemo share this logic. -func sealProject(p *project.Project) error { +// recoveryURL is the base URL for QR codes in the PDF. If empty, the PDF defaults to the production URL. +func sealProject(p *project.Project, recoveryURL string) error { // Check manifest directory exists and has content manifestDir := p.ManifestPath() fileCount, err := manifest.CountFiles(manifestDir) @@ -217,6 +221,7 @@ func sealProject(p *project.Project) error { Version: version, GitHubReleaseURL: fmt.Sprintf("https://github.com/eljojo/rememory/releases/tag/%s", version), WASMBytes: wasmBytes, + RecoveryURL: recoveryURL, } if err := bundle.GenerateAll(p, cfg); err != nil { diff --git a/internal/core/core_test.go b/internal/core/core_test.go index b20a1d0..c588110 100644 --- a/internal/core/core_test.go +++ b/internal/core/core_test.go @@ -259,6 +259,142 @@ func TestShareFilename(t *testing.T) { } } +func TestCompactEncodeRoundTrip(t *testing.T) { + original := NewShare(1, 5, 3, "Alice", []byte("test-share-data-1234567890")) + + compact := original.CompactEncode() + + decoded, err := ParseCompact(compact) + if err != nil { + t.Fatalf("ParseCompact: %v", err) + } + + if decoded.Version != original.Version { + t.Errorf("version: got %d, want %d", decoded.Version, original.Version) + } + if decoded.Index != original.Index { + t.Errorf("index: got %d, want %d", decoded.Index, original.Index) + } + if decoded.Total != original.Total { + t.Errorf("total: got %d, want %d", decoded.Total, original.Total) + } + if decoded.Threshold != original.Threshold { + t.Errorf("threshold: got %d, want %d", decoded.Threshold, original.Threshold) + } + if !bytes.Equal(decoded.Data, original.Data) { + t.Errorf("data mismatch") + } + if decoded.Checksum != original.Checksum { + t.Errorf("checksum: got %q, want %q", decoded.Checksum, original.Checksum) + } +} + +func TestCompactEncodeWithRealShares(t *testing.T) { + secret := []byte("a]Zp9kR-mN2xB7qL_YwF4vC8hD6sE0jT") + shares, err := Split(secret, 5, 3) + if err != nil { + t.Fatalf("Split: %v", err) + } + + for i, shareData := range shares { + share := NewShare(i+1, 5, 3, "", shareData) + compact := share.CompactEncode() + + decoded, err := ParseCompact(compact) + if err != nil { + t.Fatalf("share %d: ParseCompact(%q): %v", i+1, compact, err) + } + if !bytes.Equal(decoded.Data, shareData) { + t.Errorf("share %d: data mismatch after round-trip", i+1) + } + } +} + +func TestCompactEncodeFormat(t *testing.T) { + share := NewShare(2, 5, 3, "Bob", []byte{0xDE, 0xAD, 0xBE, 0xEF}) + compact := share.CompactEncode() + + if !strings.HasPrefix(compact, "RM1:") { + t.Errorf("should start with RM1:, got %q", compact) + } + + parts := strings.Split(compact, ":") + if len(parts) != 6 { + t.Fatalf("expected 6 parts, got %d: %q", len(parts), compact) + } + if parts[0] != "RM1" { + t.Errorf("version prefix: got %q, want RM1", parts[0]) + } + if parts[1] != "2" { + t.Errorf("index: got %q, want 2", parts[1]) + } + if parts[2] != "5" { + t.Errorf("total: got %q, want 5", parts[2]) + } + if parts[3] != "3" { + t.Errorf("threshold: got %q, want 3", parts[3]) + } + // base64url of 0xDEADBEEF with no padding + if parts[4] != "3q2-7w" { + t.Errorf("data: got %q, want 3q2-7w", parts[4]) + } + // checksum should be 4 hex chars + if len(parts[5]) != 4 { + t.Errorf("checksum length: got %d, want 4", len(parts[5])) + } +} + +func TestParseCompactRejectsBadInput(t *testing.T) { + // Build a valid compact string to use as a base + share := NewShare(1, 5, 3, "Alice", []byte("valid-data")) + valid := share.CompactEncode() + + tests := []struct { + name string + input string + }{ + {"empty string", ""}, + {"too few fields", "RM1:1:5:3:data"}, + {"too many fields", "RM1:1:5:3:data:check:extra"}, + {"wrong prefix", "XX1:1:5:3:AAAA:0000"}, + {"bad version", "RMx:1:5:3:AAAA:0000"}, + {"zero version", "RM0:1:5:3:AAAA:0000"}, + {"negative index", "RM1:-1:5:3:AAAA:0000"}, + {"zero index", "RM1:0:5:3:AAAA:0000"}, + {"zero total", "RM1:1:0:3:AAAA:0000"}, + {"zero threshold", "RM1:1:5:0:AAAA:0000"}, + {"bad base64", "RM1:1:5:3:!!!invalid!!!:0000"}, + {"wrong checksum", valid[:len(valid)-4] + "ffff"}, + {"truncated", valid[:len(valid)/2]}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ParseCompact(tt.input) + if err == nil { + t.Errorf("expected error for %q, got nil", tt.input) + } + }) + } +} + +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")) + compact := share.CompactEncode() + decoded, err := ParseCompact(compact) + if err != nil { + t.Fatalf("ParseCompact: %v", err) + } + if decoded.Holder != "" { + t.Errorf("holder should be empty in compact format, got %q", decoded.Holder) + } + if !decoded.Created.IsZero() { + t.Errorf("created should be zero in compact format, got %v", decoded.Created) + } +} + // createTarGz builds a tar.gz archive in memory with arbitrary entry names. // This allows crafting malicious archives for security testing. func createTarGz(t *testing.T, entries map[string]string) []byte { diff --git a/internal/core/share.go b/internal/core/share.go index db07f0b..832fa30 100644 --- a/internal/core/share.go +++ b/internal/core/share.go @@ -1,7 +1,9 @@ package core import ( + "crypto/sha256" "encoding/base64" + "encoding/hex" "fmt" "strconv" "strings" @@ -14,6 +16,10 @@ import ( const ( ShareBegin = "-----BEGIN REMEMORY SHARE-----" ShareEnd = "-----END REMEMORY SHARE-----" + + // DefaultRecoveryURL is the default base URL for QR codes in PDFs. + // Points to the recover.html hosted on GitHub Pages. + DefaultRecoveryURL = "https://eljojo.github.io/rememory/recover.html" ) // Share represents a single Shamir share with metadata. @@ -186,6 +192,78 @@ func (s *Share) Verify() error { return nil } +// CompactEncode returns a short string encoding of the share suitable for +// QR codes and URL fragments. Format: RM{version}:{index}:{total}:{threshold}:{base64url_data}:{short_check} +// The short_check is the first 4 hex characters of the SHA-256 of the raw share data. +func (s *Share) CompactEncode() string { + data := base64.RawURLEncoding.EncodeToString(s.Data) + check := shortChecksum(s.Data) + return fmt.Sprintf("RM%d:%d:%d:%d:%s:%s", s.Version, s.Index, s.Total, s.Threshold, data, check) +} + +// ParseCompact parses a compact-encoded share string back into a Share. +// It validates the format, decodes the data, and verifies the short checksum. +func ParseCompact(s string) (*Share, error) { + parts := strings.Split(s, ":") + if len(parts) != 6 { + return nil, fmt.Errorf("invalid compact share: expected 6 colon-separated fields, got %d", len(parts)) + } + + prefix := parts[0] + if !strings.HasPrefix(prefix, "RM") { + return nil, fmt.Errorf("invalid compact share: must start with 'RM', got %q", prefix) + } + + version, err := strconv.Atoi(prefix[2:]) + if err != nil || version < 1 { + return nil, fmt.Errorf("invalid compact share: bad version %q", prefix[2:]) + } + + index, err := strconv.Atoi(parts[1]) + if err != nil || index < 1 { + return nil, fmt.Errorf("invalid compact share: bad index %q", parts[1]) + } + + total, err := strconv.Atoi(parts[2]) + if err != nil || total < 1 { + return nil, fmt.Errorf("invalid compact share: bad total %q", parts[2]) + } + + threshold, err := strconv.Atoi(parts[3]) + if err != nil || threshold < 1 { + return nil, fmt.Errorf("invalid compact share: bad threshold %q", parts[3]) + } + + data, err := base64.RawURLEncoding.DecodeString(parts[4]) + if err != nil { + return nil, fmt.Errorf("invalid compact share: bad base64 data: %w", err) + } + if len(data) == 0 { + return nil, fmt.Errorf("invalid compact share: empty data") + } + + // Verify short checksum + expectedCheck := shortChecksum(data) + if parts[5] != expectedCheck { + return nil, fmt.Errorf("invalid compact share: checksum mismatch (got %s, want %s)", parts[5], expectedCheck) + } + + return &Share{ + Version: version, + Index: index, + Total: total, + Threshold: threshold, + Data: data, + Checksum: HashBytes(data), + }, nil +} + +// shortChecksum returns the first 4 hex characters of the SHA-256 of data. +func shortChecksum(data []byte) string { + h := sha256.Sum256(data) + return hex.EncodeToString(h[:2]) +} + // Filename returns a suggested filename for this share. func (s *Share) Filename() string { name := s.Holder diff --git a/internal/html/assets/docs.html b/internal/html/assets/docs.html index 73f0cf0..64fdfb5 100644 --- a/internal/html/assets/docs.html +++ b/internal/html/assets/docs.html @@ -17,18 +17,18 @@ -
- - -
-

🧠 ReMemory User Guide

-

Learn how to create encrypted recovery bundles and recover your secrets

-

- Prefer the command line? See the CLI guide. -

-
- - + + +
+ + +
+
+

🧠 ReMemory User Guide

+

Learn how to create encrypted recovery bundles and recover your secrets

+

+ Prefer the command line? See the CLI guide. +

+

Overview

@@ -426,6 +531,7 @@

Distributing to Friends

  • Keep the bundle somewhere safe (cloud backup, USB drive, etc.)
  • They cannot use it alone—they'll need to coordinate with others
  • A single share reveals nothing, but they should still keep it private
  • +
  • Consider printing the README.pdf and keeping it in a safe—paper survives digital disasters. The MANIFEST.age file can be stored separately (cloud, email, USB) since it's just encrypted ciphertext.
  • @@ -443,7 +549,8 @@

    What Friends Receive

    README.pdf - Same content, formatted for printing + Same content, formatted for printing. Also includes a QR code + that can be scanned to import the share directly.
    MANIFEST.age @@ -460,16 +567,17 @@

    What Friends Receive

    and shows a contact list of other friends who hold shares.

    -

    Recovery Process

    +

    Path A: I Have the Bundle ZIP

    - When your friends need to recover your secrets, here's what they do: + This is the easiest path. If you have the bundle ZIP file (or the individual files from it), + here's what to do:

    1
    -

    Open recover.html

    -

    One friend opens the recovery tool from their bundle in any modern browser. Their share is automatically pre-loaded.

    +

    Extract the ZIP and open recover.html

    +

    Open the recovery tool in any modern browser. Your share is automatically pre-loaded.

    @@ -485,7 +593,7 @@

    Load the encrypted manifest

    3

    Coordinate with other friends

    -

    The tool shows a contact list with other friends' names and contact information. Reach out and ask them to send their README.txt file.

    +

    The tool shows a contact list with other friends' names and contact information. Reach out and ask them to send their README.txt file or share text.

    @@ -493,7 +601,7 @@

    Coordinate with other friends

    4

    Add shares from other friends

    -

    Drag and drop their README.txt files onto the page, or paste share text directly. A checkmark appears next to each friend's name as their share is added.

    +

    For each friend's share, you can: drag and drop their README.txt file, paste share text directly, or scan a QR code from their printed PDF. A checkmark appears next to each friend's name as their share is added.

    @@ -505,6 +613,11 @@

    Recovery happens automatically

    +
    + Pro tip: If a friend sends you their entire .zip bundle file, + you can drag it directly onto the page—the manifest and their share are both imported automatically. +
    +
    Recovery interface - collecting shares
    The recovery tool showing collected shares and contact list
    @@ -515,6 +628,62 @@

    Recovery happens automatically

    Once threshold is met, files are decrypted and ready to download
    +

    Path B: I Have a Printed PDF

    +

    + Some people prefer to keep just the PDF printout in a safe or filing cabinet and store the + MANIFEST.age file separately (e.g. cloud storage, email, USB drive). Since + the manifest is encrypted, it's safe to keep copies anywhere—nobody can open it without + combining enough shares. +

    +

    If you're starting from a printed PDF:

    + +
    +
    1
    +
    +

    Open the recovery tool

    +

    Scan the QR code on the PDF with your phone camera—it opens the recovery website with your share pre-filled. Alternatively, visit the URL printed on the PDF and type the short code (RM1:...) shown below the QR code into the paste area.

    +
    +
    + +
    + Browser asking for camera permission +
    Your browser will ask for permission to use the camera
    +
    + +
    + Scanning a QR code from a printed PDF +
    Point your camera at the QR code on the printed PDF to import the share
    +
    + +
    +
    2
    +
    +

    Load the encrypted manifest

    +

    You need the MANIFEST.age file—drag it onto the page or click to browse. If you don't have it, ask one of the other friends to send it from their bundle. Every bundle contains the same copy.

    +
    +
    + +
    + Selecting MANIFEST.age from a folder +
    Select the MANIFEST.age file from where you stored it
    +
    + +
    +
    3
    +
    +

    Collect shares from other friends

    +

    Contact other friends and ask them to send their shares. They can send their README.txt, share text, or you can scan the QR code from their printed PDF.

    +
    +
    + +
    +
    4
    +
    +

    Recovery happens automatically

    +

    Once the threshold is met, decryption starts immediately.

    +
    +
    +
    Key points about recovery:
    - - + + + + + diff --git a/internal/html/assets/recover.html b/internal/html/assets/recover.html index 9cb8850..c59898e 100644 --- a/internal/html/assets/recover.html +++ b/internal/html/assets/recover.html @@ -10,6 +10,21 @@ + + +
    @@ -42,6 +57,9 @@

    1 Collect Sha +