From 5805c4b136999502aaaf55015514c47a96dbca8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Albornoz?= Date: Tue, 10 Feb 2026 01:04:55 -0500 Subject: [PATCH 1/7] Add Golden Test for v1 --- Makefile | 6 +- internal/core/golden_test.go | 514 ++++++++++++++++++ internal/core/testdata/v1-bundle/MANIFEST.age | Bin 0 -> 422 bytes .../core/testdata/v1-bundle/SHARE-alice.txt | 11 + .../core/testdata/v1-bundle/SHARE-bob.txt | 11 + .../core/testdata/v1-bundle/SHARE-carol.txt | 11 + .../core/testdata/v1-bundle/SHARE-david.txt | 11 + .../core/testdata/v1-bundle/SHARE-eve.txt | 11 + .../expected-output/manifest/README.md | 3 + .../expected-output/manifest/secret.txt | 1 + internal/core/testdata/v1-golden.json | 55 ++ 11 files changed, 633 insertions(+), 1 deletion(-) create mode 100644 internal/core/golden_test.go create mode 100644 internal/core/testdata/v1-bundle/MANIFEST.age create mode 100644 internal/core/testdata/v1-bundle/SHARE-alice.txt create mode 100644 internal/core/testdata/v1-bundle/SHARE-bob.txt create mode 100644 internal/core/testdata/v1-bundle/SHARE-carol.txt create mode 100644 internal/core/testdata/v1-bundle/SHARE-david.txt create mode 100644 internal/core/testdata/v1-bundle/SHARE-eve.txt create mode 100644 internal/core/testdata/v1-bundle/expected-output/manifest/README.md create mode 100644 internal/core/testdata/v1-bundle/expected-output/manifest/secret.txt create mode 100644 internal/core/testdata/v1-golden.json diff --git a/Makefile b/Makefile index f49d0af..177a625 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") @@ -87,6 +87,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/internal/core/golden_test.go b/internal/core/golden_test.go new file mode 100644 index 0000000..cf94e3f --- /dev/null +++ b/internal/core/golden_test.go @@ -0,0 +1,514 @@ +package core + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "sort" + "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"` +} + +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. + goldenPassphrase = "dGhpc19pc19hX3Rlc3RfcGFzc3BocmFzZV92MV9nbGRu" + + // goldenCreated is the fixed timestamp for all golden shares. + goldenCreated = "2025-01-01T00:00:00Z" +) + +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) goldenFixture { + t.Helper() + data, err := os.ReadFile(filepath.Join("testdata", "v1-golden.json")) + if err != nil { + t.Fatalf("reading v1-golden.json: %v", err) + } + var golden goldenFixture + if err := json.Unmarshal(data, &golden); err != nil { + t.Fatalf("unmarshaling v1-golden.json: %v", 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 +} + +// 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 +} + +// buildSortedTarGz creates a tar.gz with entries in sorted key order for determinism. +func buildSortedTarGz(files map[string]string) ([]byte, error) { + keys := make([]string, 0, len(files)) + for k := range files { + keys = append(keys, k) + } + sort.Strings(keys) + + var buf bytes.Buffer + gzw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gzw) + + for _, name := range keys { + content := files[name] + if err := tw.WriteHeader(&tar.Header{ + Name: name, + Size: int64(len(content)), + Mode: 0644, + ModTime: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + Typeflag: tar.TypeReg, + }); err != nil { + return nil, fmt.Errorf("writing tar header for %q: %w", name, err) + } + if _, err := tw.Write([]byte(content)); err != nil { + return nil, fmt.Errorf("writing tar content for %q: %w", name, err) + } + } + + if err := tw.Close(); err != nil { + return nil, fmt.Errorf("closing tar writer: %w", err) + } + if err := gzw.Close(); err != nil { + return nil, fmt.Errorf("closing gzip writer: %w", err) + } + return buf.Bytes(), nil +} + +// --- Generator --- + +// TestGenerateGoldenFixtures generates all golden test fixtures. +// 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(time.RFC3339, goldenCreated) + if err != nil { + t.Fatalf("parsing created time: %v", err) + } + + // Split the passphrase using Shamir's Secret Sharing + rawShares, err := Split([]byte(goldenPassphrase), 5, 3) + if err != nil { + t.Fatalf("splitting passphrase: %v", err) + } + + // Verify reconstruction before committing fixtures + recovered, err := Combine(rawShares[:3]) + if err != nil { + t.Fatalf("combining shares: %v", err) + } + if string(recovered) != goldenPassphrase { + t.Fatal("share reconstruction failed — shares are broken") + } + + // Build shares with fixed metadata + shares := make([]*Share, 5) + goldenShares := make([]goldenShare, 5) + for i := 0; i < 5; i++ { + share := &Share{ + Version: 1, + Index: i + 1, + Total: 5, + Threshold: 3, + Holder: goldenHolders[i], + Created: createdTime, + Data: rawShares[i], + Checksum: HashBytes(rawShares[i]), + } + shares[i] = share + goldenShares[i] = goldenShare{ + Index: share.Index, + Holder: share.Holder, + DataHex: hex.EncodeToString(share.Data), + Checksum: share.Checksum, + PEM: share.Encode(), + Compact: share.CompactEncode(), + } + } + + // Build manifest archive + archiveData, err := buildSortedTarGz(goldenManifestFiles) + if err != nil { + t.Fatalf("building tar.gz: %v", err) + } + + // Encrypt manifest + var encryptedBuf bytes.Buffer + if err := Encrypt(&encryptedBuf, bytes.NewReader(archiveData), goldenPassphrase); err != nil { + t.Fatalf("encrypting manifest: %v", err) + } + + // Build fixture JSON + fixture := goldenFixture{ + Version: 1, + Passphrase: goldenPassphrase, + Total: 5, + Threshold: 3, + Created: goldenCreated, + Shares: goldenShares, + Manifest: goldenManifest{ + Files: goldenManifestFiles, + }, + } + + fixtureJSON, err := json.MarshalIndent(fixture, "", " ") + if err != nil { + t.Fatalf("marshaling fixture JSON: %v", err) + } + + // Create directories + bundleDir := filepath.Join("testdata", "v1-bundle") + expectedDir := filepath.Join(bundleDir, "expected-output") + if err := os.MkdirAll(expectedDir, 0755); err != nil { + t.Fatalf("creating directories: %v", err) + } + + // Write v1-golden.json + jsonPath := filepath.Join("testdata", "v1-golden.json") + if err := os.WriteFile(jsonPath, fixtureJSON, 0644); err != nil { + t.Fatalf("writing %s: %v", jsonPath, err) + } + t.Logf("wrote %s", jsonPath) + + // Write share PEM files + for _, share := range shares { + filename := fmt.Sprintf("SHARE-%s.txt", strings.ToLower(share.Holder)) + sharePath := filepath.Join(bundleDir, 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 MANIFEST.age + manifestPath := filepath.Join(bundleDir, "MANIFEST.age") + if err := os.WriteFile(manifestPath, encryptedBuf.Bytes(), 0644); err != nil { + t.Fatalf("writing %s: %v", manifestPath, err) + } + t.Logf("wrote %s (%d bytes)", manifestPath, encryptedBuf.Len()) + + // Write expected output files + for name, content := range goldenManifestFiles { + outPath := filepath.Join(expectedDir, 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("Golden fixtures generated successfully.") + t.Log("Commit the testdata/ directory. These fixtures must never be modified.") +} + +// --- Golden tests --- + +// TestGoldenV1ShareParsing parses each fixture share and verifies all fields match. +func TestGoldenV1ShareParsing(t *testing.T) { + golden := loadGoldenJSON(t) + + for _, gs := range golden.Shares { + t.Run(gs.Holder, func(t *testing.T) { + // Read PEM file from v1-bundle/ + filename := fmt.Sprintf("SHARE-%s.txt", strings.ToLower(gs.Holder)) + pemData, err := os.ReadFile(filepath.Join("testdata", "v1-bundle", filename)) + if err != nil { + t.Fatalf("reading %s: %v", filename, err) + } + + // Parse + share, err := ParseShare(pemData) + if err != nil { + t.Fatalf("ParseShare: %v", err) + } + + // Verify all fields + 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, _ := time.Parse(time.RFC3339, 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) + } + + // Verify checksum integrity + if err := share.Verify(); err != nil { + t.Errorf("Verify: %v", err) + } + + // Re-encode and compare PEM + reEncoded := share.Encode() + if reEncoded != gs.PEM { + t.Errorf("PEM re-encode mismatch:\ngot:\n%s\nwant:\n%s", reEncoded, gs.PEM) + } + + // Compact encode and compare + compact := share.CompactEncode() + if compact != gs.Compact { + t.Errorf("compact: got %q, want %q", compact, gs.Compact) + } + + // Compact round-trip (only fields that survive: Version, Index, Total, Threshold, Data, Checksum) + 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) + } + }) + } +} + +// TestGoldenV1Combine combines threshold shares and verifies the passphrase. +func TestGoldenV1Combine(t *testing.T) { + golden := loadGoldenJSON(t) + + 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) + } + + if string(recovered) != golden.Passphrase { + t.Errorf("passphrase: got %q, want %q", string(recovered), golden.Passphrase) + } +} + +// TestGoldenV1CombineAllSubsets tries all valid k-of-n subsets. +func TestGoldenV1CombineAllSubsets(t *testing.T) { + golden := loadGoldenJSON(t) + + 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 { + // Build a human-readable name like "1,2,3" + 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) + } + + if string(recovered) != golden.Passphrase { + t.Errorf("passphrase: got %q, want %q", string(recovered), golden.Passphrase) + } + }) + } +} + +// TestGoldenV1Decrypt combines shares, decrypts the manifest, and verifies output. +func TestGoldenV1Decrypt(t *testing.T) { + golden := loadGoldenJSON(t) + + // Parse 3 share files from the bundle + 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", "v1-bundle", 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 + } + + // Combine shares to recover passphrase + recovered, err := Combine(shareData) + if err != nil { + t.Fatalf("Combine: %v", err) + } + passphrase := string(recovered) + + if passphrase != golden.Passphrase { + t.Fatalf("passphrase mismatch: got %q, want %q", passphrase, golden.Passphrase) + } + + // Read and decrypt MANIFEST.age + manifestAge, err := os.ReadFile(filepath.Join("testdata", "v1-bundle", "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) + } + + // Extract tar.gz + files, err := ExtractTarGz(decrypted.Bytes()) + if err != nil { + t.Fatalf("ExtractTarGz: %v", err) + } + + if len(files) == 0 { + t.Fatal("no files extracted from manifest") + } + + // Build a map of extracted files for easy lookup + extracted := make(map[string]string) + for _, f := range files { + extracted[f.Name] = string(f.Data) + } + + // Verify against the JSON fixture + 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) + } + } + + // Also verify against files on disk in expected-output/ + for _, f := range files { + diskPath := filepath.Join("testdata", "v1-bundle", "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) + } + } +} diff --git a/internal/core/testdata/v1-bundle/MANIFEST.age b/internal/core/testdata/v1-bundle/MANIFEST.age new file mode 100644 index 0000000000000000000000000000000000000000..b64619cc75115684da0e5684738eba98a999e62a GIT binary patch literal 422 zcmV;X0a^ZGXJsvAZewzJaCB*JZZ2oHU9W{-ee<`B`0OLos3-42bB8Gk2VZvLUzs~wz^TDqHzs-p9 zRS=bIS=F)0(L8k(`X~EQ!om-PSH2`%osoL7tHPnQX!3JF+rd|iEL#^D_49QyYnpCm Q+z*f6vTnLukZ6Cx?34Pv;s5{u literal 0 HcmV?d00001 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 From 5de7f773f7db264a00d65b3a6326fda7bd5a41ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Albornoz?= Date: Tue, 10 Feb 2026 01:04:55 -0500 Subject: [PATCH 2/7] ReMemory Protocol V2: Shamir uses Raw Bytes instead of base64 --- Makefile | 2 + internal/cmd/recover.go | 11 +- internal/cmd/seal.go | 13 +- internal/core/core_test.go | 16 +- internal/core/golden_test.go | 632 ++++++++++-------- internal/core/share.go | 29 +- internal/core/testdata/v2-bundle/MANIFEST.age | 6 + .../core/testdata/v2-bundle/SHARE-alice.txt | 11 + .../core/testdata/v2-bundle/SHARE-bob.txt | 11 + .../core/testdata/v2-bundle/SHARE-carol.txt | 11 + .../core/testdata/v2-bundle/SHARE-david.txt | 11 + .../core/testdata/v2-bundle/SHARE-eve.txt | 11 + .../expected-output/manifest/README.md | 3 + .../expected-output/manifest/secret.txt | 1 + internal/core/testdata/v2-golden.json | 55 ++ internal/crypto/passphrase.go | 18 +- internal/html/assets/src/app.ts | 1 + internal/html/assets/src/types.ts | 2 + internal/integration_test.go | 14 +- internal/pdf/readme_test.go | 4 +- internal/wasm/create.go | 30 +- internal/wasm/js_wrappers.go | 1 + internal/wasm/recover.go | 10 +- 23 files changed, 563 insertions(+), 340 deletions(-) create mode 100644 internal/core/testdata/v2-bundle/MANIFEST.age create mode 100644 internal/core/testdata/v2-bundle/SHARE-alice.txt create mode 100644 internal/core/testdata/v2-bundle/SHARE-bob.txt create mode 100644 internal/core/testdata/v2-bundle/SHARE-carol.txt create mode 100644 internal/core/testdata/v2-bundle/SHARE-david.txt create mode 100644 internal/core/testdata/v2-bundle/SHARE-eve.txt create mode 100644 internal/core/testdata/v2-bundle/expected-output/manifest/README.md create mode 100644 internal/core/testdata/v2-bundle/expected-output/manifest/secret.txt create mode 100644 internal/core/testdata/v2-golden.json diff --git a/Makefile b/Makefile index 177a625..3bb674b 100644 --- a/Makefile +++ b/Makefile @@ -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 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/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 index cf94e3f..4b9a81f 100644 --- a/internal/core/golden_test.go +++ b/internal/core/golden_test.go @@ -1,16 +1,14 @@ package core import ( - "archive/tar" "bytes" - "compress/gzip" + "encoding/base64" "encoding/hex" "encoding/json" "flag" "fmt" "os" "path/filepath" - "sort" "strings" "testing" "time" @@ -48,10 +46,16 @@ type goldenManifest struct { const ( // goldenPassphrase is a fixed base64url string (43 chars, represents 32 bytes). // This mimics the output of crypto.GeneratePassphrase(32) but is deterministic. - goldenPassphrase = "dGhpc19pc19hX3Rlc3RfcGFzc3BocmFzZV92MV9nbGRu" + // 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-01T00:00:00Z" + 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"} @@ -64,15 +68,15 @@ var goldenManifestFiles = map[string]string{ // --- Helpers --- -func loadGoldenJSON(t *testing.T) goldenFixture { +func loadGoldenJSON(t *testing.T, filename string) goldenFixture { t.Helper() - data, err := os.ReadFile(filepath.Join("testdata", "v1-golden.json")) + data, err := os.ReadFile(filepath.Join("testdata", filename)) if err != nil { - t.Fatalf("reading v1-golden.json: %v", err) + t.Fatalf("reading %s: %v", filename, err) } var golden goldenFixture if err := json.Unmarshal(data, &golden); err != nil { - t.Fatalf("unmarshaling v1-golden.json: %v", err) + t.Fatalf("unmarshaling %s: %v", filename, err) } return golden } @@ -86,6 +90,19 @@ func mustDecodeHex(t *testing.T, s string) []byte { 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 @@ -107,88 +124,62 @@ func combinations(n, k int) [][]int { return result } -// buildSortedTarGz creates a tar.gz with entries in sorted key order for determinism. -func buildSortedTarGz(files map[string]string) ([]byte, error) { - keys := make([]string, 0, len(files)) - for k := range files { - keys = append(keys, k) - } - sort.Strings(keys) - - var buf bytes.Buffer - gzw := gzip.NewWriter(&buf) - tw := tar.NewWriter(gzw) - - for _, name := range keys { - content := files[name] - if err := tw.WriteHeader(&tar.Header{ - Name: name, - Size: int64(len(content)), - Mode: 0644, - ModTime: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), - Typeflag: tar.TypeReg, - }); err != nil { - return nil, fmt.Errorf("writing tar header for %q: %w", name, err) - } - if _, err := tw.Write([]byte(content)); err != nil { - return nil, fmt.Errorf("writing tar content for %q: %w", name, err) - } - } - - if err := tw.Close(); err != nil { - return nil, fmt.Errorf("closing tar writer: %w", err) - } - if err := gzw.Close(); err != nil { - return nil, fmt.Errorf("closing gzip writer: %w", err) - } - return buf.Bytes(), nil -} // --- Generator --- -// TestGenerateGoldenFixtures generates all golden test fixtures. +// 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(time.RFC3339, goldenCreated) + createdTime, err := time.Parse(goldenCreatedFormat, goldenCreated) if err != nil { t.Fatalf("parsing created time: %v", err) } - // Split the passphrase using Shamir's Secret Sharing - rawShares, err := Split([]byte(goldenPassphrase), 5, 3) + // 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 passphrase: %v", err) + t.Fatalf("splitting raw passphrase: %v", err) } - // Verify reconstruction before committing fixtures - recovered, err := Combine(rawShares[:3]) + // Verify reconstruction: combine → base64url-encode → should match passphrase + recoveredV2, err := Combine(rawSharesV2[:3]) if err != nil { - t.Fatalf("combining shares: %v", err) + t.Fatalf("combining v2 shares: %v", err) } - if string(recovered) != goldenPassphrase { - t.Fatal("share reconstruction failed — shares are broken") + if RecoverPassphrase(recoveredV2, 2) != goldenPassphrase { + t.Fatal("v2 share reconstruction failed — shares are broken") } - // Build shares with fixed metadata - shares := make([]*Share, 5) - goldenShares := make([]goldenShare, 5) + // Build v2 shares with fixed metadata + sharesV2 := make([]*Share, 5) + goldenSharesV2 := make([]goldenShare, 5) for i := 0; i < 5; i++ { share := &Share{ - Version: 1, + Version: 2, Index: i + 1, Total: 5, Threshold: 3, Holder: goldenHolders[i], Created: createdTime, - Data: rawShares[i], - Checksum: HashBytes(rawShares[i]), + Data: rawSharesV2[i], + Checksum: HashBytes(rawSharesV2[i]), } - shares[i] = share - goldenShares[i] = goldenShare{ + sharesV2[i] = share + goldenSharesV2[i] = goldenShare{ Index: share.Index, Holder: share.Holder, DataHex: hex.EncodeToString(share.Data), @@ -198,70 +189,64 @@ func TestGenerateGoldenFixtures(t *testing.T) { } } - // Build manifest archive - archiveData, err := buildSortedTarGz(goldenManifestFiles) - if err != nil { - t.Fatalf("building tar.gz: %v", err) - } - - // Encrypt manifest - var encryptedBuf bytes.Buffer - if err := Encrypt(&encryptedBuf, bytes.NewReader(archiveData), goldenPassphrase); err != nil { - t.Fatalf("encrypting manifest: %v", err) + // 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 fixture JSON - fixture := goldenFixture{ - Version: 1, + // Build v2 fixture JSON + fixtureV2 := goldenFixture{ + Version: 2, Passphrase: goldenPassphrase, Total: 5, Threshold: 3, Created: goldenCreated, - Shares: goldenShares, + Shares: goldenSharesV2, Manifest: goldenManifest{ Files: goldenManifestFiles, }, } - fixtureJSON, err := json.MarshalIndent(fixture, "", " ") + fixtureJSONV2, err := json.MarshalIndent(fixtureV2, "", " ") if err != nil { - t.Fatalf("marshaling fixture JSON: %v", err) + t.Fatalf("marshaling v2 fixture JSON: %v", err) } - // Create directories - bundleDir := filepath.Join("testdata", "v1-bundle") - expectedDir := filepath.Join(bundleDir, "expected-output") - if err := os.MkdirAll(expectedDir, 0755); err != nil { - t.Fatalf("creating directories: %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 v1-golden.json - jsonPath := filepath.Join("testdata", "v1-golden.json") - if err := os.WriteFile(jsonPath, fixtureJSON, 0644); err != nil { - t.Fatalf("writing %s: %v", jsonPath, 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", jsonPath) + t.Logf("wrote %s", jsonPathV2) - // Write share PEM files - for _, share := range shares { + // Write v2 share PEM files + for _, share := range sharesV2 { filename := fmt.Sprintf("SHARE-%s.txt", strings.ToLower(share.Holder)) - sharePath := filepath.Join(bundleDir, filename) + 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 MANIFEST.age - manifestPath := filepath.Join(bundleDir, "MANIFEST.age") - if err := os.WriteFile(manifestPath, encryptedBuf.Bytes(), 0644); err != nil { - t.Fatalf("writing %s: %v", manifestPath, err) + // 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)", manifestPath, encryptedBuf.Len()) + t.Logf("wrote %s (%d bytes)", manifestPathV2, encryptedBufV2.Len()) - // Write expected output files + // Write v2 expected output files for name, content := range goldenManifestFiles { - outPath := filepath.Join(expectedDir, name) + outPath := filepath.Join(expectedDirV2, name) if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil { t.Fatalf("creating dir for %s: %v", outPath, err) } @@ -271,144 +256,245 @@ func TestGenerateGoldenFixtures(t *testing.T) { t.Logf("wrote %s", outPath) } - t.Log("Golden fixtures generated successfully.") - t.Log("Commit the testdata/ directory. These fixtures must never be modified.") + 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 --- +// --- Golden tests (table-driven across v1 and v2) --- -// TestGoldenV1ShareParsing parses each fixture share and verifies all fields match. -func TestGoldenV1ShareParsing(t *testing.T) { - golden := loadGoldenJSON(t) +// 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 +} - for _, gs := range golden.Shares { - t.Run(gs.Holder, func(t *testing.T) { - // Read PEM file from v1-bundle/ - filename := fmt.Sprintf("SHARE-%s.txt", strings.ToLower(gs.Holder)) - pemData, err := os.ReadFile(filepath.Join("testdata", "v1-bundle", filename)) - if err != nil { - t.Fatalf("reading %s: %v", filename, err) - } +var goldenVersions = []goldenVersion{ + {"v1", "v1-golden.json", "v1-bundle"}, + {"v2", "v2-golden.json", "v2-bundle"}, +} - // Parse - share, err := ParseShare(pemData) - if err != nil { - t.Fatalf("ParseShare: %v", err) +// 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) + } + }) } + }) + } +} - // Verify all fields - 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) - } +// 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) - expectedCreated, _ := time.Parse(time.RFC3339, golden.Created) - if !share.Created.Equal(expectedCreated) { - t.Errorf("created: got %v, want %v", share.Created, expectedCreated) + if len(golden.Shares) < golden.Threshold { + t.Fatalf("not enough shares in fixture: have %d, need %d", len(golden.Shares), golden.Threshold) } - expectedData := mustDecodeHex(t, gs.DataHex) - if !bytes.Equal(share.Data, expectedData) { - t.Errorf("data mismatch: got %x, want %s", share.Data, gs.DataHex) + shareData := make([][]byte, golden.Threshold) + for i := 0; i < golden.Threshold; i++ { + shareData[i] = mustDecodeHex(t, golden.Shares[i].DataHex) } - if share.Checksum != gs.Checksum { - t.Errorf("checksum: got %q, want %q", share.Checksum, gs.Checksum) + recovered, err := Combine(shareData) + if err != nil { + t.Fatalf("Combine: %v", err) } - // Verify checksum integrity - if err := share.Verify(); err != nil { - t.Errorf("Verify: %v", err) + passphrase := RecoverPassphrase(recovered, golden.Version) + if passphrase != golden.Passphrase { + t.Errorf("passphrase: got %q, want %q", passphrase, golden.Passphrase) } + }) + } +} - // Re-encode and compare PEM - reEncoded := share.Encode() - if reEncoded != gs.PEM { - t.Errorf("PEM re-encode mismatch:\ngot:\n%s\nwant:\n%s", reEncoded, gs.PEM) - } +// 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) - // Compact encode and compare - compact := share.CompactEncode() - if compact != gs.Compact { - t.Errorf("compact: got %q, want %q", compact, gs.Compact) + allData := make([][]byte, len(golden.Shares)) + for i, gs := range golden.Shares { + allData[i] = mustDecodeHex(t, gs.DataHex) } - // Compact round-trip (only fields that survive: Version, Index, Total, Threshold, Data, Checksum) - 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") + 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)) } - if decoded.Version != share.Version { - t.Errorf("compact round-trip version: got %d, want %d", decoded.Version, share.Version) + + 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) + } + }) } }) } } -// TestGoldenV1Combine combines threshold shares and verifies the passphrase. -func TestGoldenV1Combine(t *testing.T) { - golden := loadGoldenJSON(t) - - 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) - } +// 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) - recovered, err := Combine(shareData) - if err != nil { - t.Fatalf("Combine: %v", err) - } + allData := make([][]byte, len(golden.Shares)) + for i, gs := range golden.Shares { + allData[i] = mustDecodeHex(t, gs.DataHex) + } - if string(recovered) != golden.Passphrase { - t.Errorf("passphrase: got %q, want %q", string(recovered), golden.Passphrase) + 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") + } + }) + } + } + }) } } -// TestGoldenV1CombineAllSubsets tries all valid k-of-n subsets. -func TestGoldenV1CombineAllSubsets(t *testing.T) { - golden := loadGoldenJSON(t) - - 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 { - // Build a human-readable name like "1,2,3" - 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] +// 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) @@ -416,99 +502,61 @@ func TestGoldenV1CombineAllSubsets(t *testing.T) { t.Fatalf("Combine: %v", err) } - if string(recovered) != golden.Passphrase { - t.Errorf("passphrase: got %q, want %q", string(recovered), golden.Passphrase) + passphrase := RecoverPassphrase(recovered, golden.Version) + if passphrase != golden.Passphrase { + t.Fatalf("passphrase mismatch: got %q, want %q", passphrase, golden.Passphrase) } - }) - } -} - -// TestGoldenV1Decrypt combines shares, decrypts the manifest, and verifies output. -func TestGoldenV1Decrypt(t *testing.T) { - golden := loadGoldenJSON(t) - - // Parse 3 share files from the bundle - 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", "v1-bundle", 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 - } - - // Combine shares to recover passphrase - recovered, err := Combine(shareData) - if err != nil { - t.Fatalf("Combine: %v", err) - } - passphrase := string(recovered) - 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) + } - // Read and decrypt MANIFEST.age - manifestAge, err := os.ReadFile(filepath.Join("testdata", "v1-bundle", "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) + } - 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) + } - // Extract tar.gz - files, err := ExtractTarGz(decrypted.Bytes()) - if err != nil { - t.Fatalf("ExtractTarGz: %v", err) - } + if len(files) == 0 { + t.Fatal("no files extracted from manifest") + } - 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) + } - // Build a map of extracted files for easy lookup - 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)) + } - // Verify against the JSON fixture - 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 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) + } + } - // Also verify against files on disk in expected-output/ - for _, f := range files { - diskPath := filepath.Join("testdata", "v1-bundle", "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) - } + 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) + } + } + }) } } 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/v2-bundle/MANIFEST.age b/internal/core/testdata/v2-bundle/MANIFEST.age new file mode 100644 index 0000000..d611656 --- /dev/null +++ b/internal/core/testdata/v2-bundle/MANIFEST.age @@ -0,0 +1,6 @@ +age-encryption.org/v1 +-> scrypt b1GIBsr5h4HFG8ENmHi91A 18 +NejrQlk/0+SD6narVOhMzw9b/JDjkdswA1i3vbY9QHE +--- WW4notsCMsI9IMZjXIqT0gBuCqw5AdVxFbeXuzfbFjQ +�Z1�i"}q���RUn�:�f�l�aW�b�h#k\��, �GZ~���P�Y�b��8��T�j�sE ���Yr Y�be������' ��{��>�o�G$p��t� ���7��N�?���'��~�I,W16����Gn�N��f㠰g+�*��-k`�,^)�a��Z�|�{�T�o���ّ͑ܟ}��p�� �/��ֺc �A�]0���R\��0Y�,��o��H}���j�Dq +�jZޮ �A��&��y��͘D��r<Jd|v�Q�D \ No newline at end of file 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..3b4048e --- /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:b94ca626f5a0ebb59c08e623d5d71819cab3650957db9325de4d525908e94a14 + +kF4KdLOv/Ki7GSPaS9DiZ3GxxJPZKU/icCdrsvb7+xXb +-----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..c0dbdaf --- /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:f22b15c3b3837ae7a77cd98288755d37464f67d5212c016ab63a4cae926fef1b + +sZyeDkY5vQlP6MYMmlUI9REeUMl/KTBlrR+XV1VyfY9l +-----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..7ab797b --- /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:5d97ad7e3abfd4fa91183e9da756c6fedb2b1329e6c7b385e4b7d1b56a8ecce5 + +Ag4MAtSGtvbhGRqelxyY9XU2dPP7Byadrh2rJL5nMvR2 +-----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..4e53c4a --- /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:1b11c0065b3d56168f4333bcb1594dbb4e70357b8cabeebed5b91d1fd9fb64c6 + +okdMnSgXwtueAJEp4C/HQ2MqbgF9b9q0m5Q8IB4nZySL +-----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..d4b4a1e --- /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:242b22ded01926df7dba5f8caed34976793fc8acd26d3a1f08ceff7739f11d43 + +XDPeAVtD20h8r8/bteGitPvhPSJ+HcQDV3qBlACGx6wn +-----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..062e4ac --- /dev/null +++ b/internal/core/testdata/v2-golden.json @@ -0,0 +1,55 @@ +{ + "version": 2, + "passphrase": "dGhpc19pc19hX3Rlc3RfcGFzc3BocmFzZV92Ml9nbGQ", + "total": 5, + "threshold": 3, + "created": "2025-01-01 00:00", + "shares": [ + { + "index": 1, + "holder": "Alice", + "data_hex": "905e0a74b3affca8bb1923da4bd0e26771b1c493d9294fe270276bb2f6fbfb15db", + "checksum": "sha256:b94ca626f5a0ebb59c08e623d5d71819cab3650957db9325de4d525908e94a14", + "pem": "-----BEGIN REMEMORY SHARE-----\nVersion: 2\nIndex: 1\nTotal: 5\nThreshold: 3\nHolder: Alice\nCreated: 2025-01-01 00:00\nChecksum: sha256:b94ca626f5a0ebb59c08e623d5d71819cab3650957db9325de4d525908e94a14\n\nkF4KdLOv/Ki7GSPaS9DiZ3GxxJPZKU/icCdrsvb7+xXb\n-----END REMEMORY SHARE-----\n", + "compact": "RM2:1:5:3:kF4KdLOv_Ki7GSPaS9DiZ3GxxJPZKU_icCdrsvb7-xXb:b94c" + }, + { + "index": 2, + "holder": "Bob", + "data_hex": "b19c9e0e4639bd094fe8c60c9a5508f5111e50c97f293065ad1f975755727d8f65", + "checksum": "sha256:f22b15c3b3837ae7a77cd98288755d37464f67d5212c016ab63a4cae926fef1b", + "pem": "-----BEGIN REMEMORY SHARE-----\nVersion: 2\nIndex: 2\nTotal: 5\nThreshold: 3\nHolder: Bob\nCreated: 2025-01-01 00:00\nChecksum: sha256:f22b15c3b3837ae7a77cd98288755d37464f67d5212c016ab63a4cae926fef1b\n\nsZyeDkY5vQlP6MYMmlUI9REeUMl/KTBlrR+XV1VyfY9l\n-----END REMEMORY SHARE-----\n", + "compact": "RM2:2:5:3:sZyeDkY5vQlP6MYMmlUI9REeUMl_KTBlrR-XV1VyfY9l:f22b" + }, + { + "index": 3, + "holder": "Carol", + "data_hex": "020e0c02d486b6f6e1191a9e971c98f5753674f3fb07269dae1dab24be6732f476", + "checksum": "sha256:5d97ad7e3abfd4fa91183e9da756c6fedb2b1329e6c7b385e4b7d1b56a8ecce5", + "pem": "-----BEGIN REMEMORY SHARE-----\nVersion: 2\nIndex: 3\nTotal: 5\nThreshold: 3\nHolder: Carol\nCreated: 2025-01-01 00:00\nChecksum: sha256:5d97ad7e3abfd4fa91183e9da756c6fedb2b1329e6c7b385e4b7d1b56a8ecce5\n\nAg4MAtSGtvbhGRqelxyY9XU2dPP7Byadrh2rJL5nMvR2\n-----END REMEMORY SHARE-----\n", + "compact": "RM2:3:5:3:Ag4MAtSGtvbhGRqelxyY9XU2dPP7Byadrh2rJL5nMvR2:5d97" + }, + { + "index": 4, + "holder": "David", + "data_hex": "a2474c9d2817c2db9e009129e02fc743632a6e017d6fdab49b943c201e2767248b", + "checksum": "sha256:1b11c0065b3d56168f4333bcb1594dbb4e70357b8cabeebed5b91d1fd9fb64c6", + "pem": "-----BEGIN REMEMORY SHARE-----\nVersion: 2\nIndex: 4\nTotal: 5\nThreshold: 3\nHolder: David\nCreated: 2025-01-01 00:00\nChecksum: sha256:1b11c0065b3d56168f4333bcb1594dbb4e70357b8cabeebed5b91d1fd9fb64c6\n\nokdMnSgXwtueAJEp4C/HQ2MqbgF9b9q0m5Q8IB4nZySL\n-----END REMEMORY SHARE-----\n", + "compact": "RM2:4:5:3:okdMnSgXwtueAJEp4C_HQ2MqbgF9b9q0m5Q8IB4nZySL:1b11" + }, + { + "index": 5, + "holder": "Eve", + "data_hex": "5c33de015b43db487cafcfdbb5e1a2b4fbe13d227e1dc403577a81940086c7ac27", + "checksum": "sha256:242b22ded01926df7dba5f8caed34976793fc8acd26d3a1f08ceff7739f11d43", + "pem": "-----BEGIN REMEMORY SHARE-----\nVersion: 2\nIndex: 5\nTotal: 5\nThreshold: 3\nHolder: Eve\nCreated: 2025-01-01 00:00\nChecksum: sha256:242b22ded01926df7dba5f8caed34976793fc8acd26d3a1f08ceff7739f11d43\n\nXDPeAVtD20h8r8/bteGitPvhPSJ+HcQDV3qBlACGx6wn\n-----END REMEMORY SHARE-----\n", + "compact": "RM2:5:5:3:XDPeAVtD20h8r8_bteGitPvhPSJ-HcQDV3qBlACGx6wn:242b" + } + ], + "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/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/src/app.ts b/internal/html/assets/src/app.ts index 17720ad..677887e 100644 --- a/internal/html/assets/src/app.ts +++ b/internal/html/assets/src/app.ts @@ -982,6 +982,7 @@ declare const t: TranslationFunction; setStatus(t('combining')); const sharesForCombine: ShareInput[] = state.shares.map(s => ({ + version: s.version, index: s.index, dataB64: s.dataB64 })); diff --git a/internal/html/assets/src/types.ts b/internal/html/assets/src/types.ts index f5555aa..f4d1f82 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; } 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_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..eb44863 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, @@ -522,19 +521,23 @@ func encryptAgeJS(this js.Value, args []js.Value) any { }) } -// splitPassphraseJS splits a passphrase using Shamir's Secret Sharing. -// Args: passphrase (string), n (int), k (int) +// splitPassphraseJS splits raw passphrase bytes using Shamir's Secret Sharing. +// Args: rawBytes (Uint8Array), 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)") + return errorResult("missing arguments (need rawBytes, n, k)") } - passphrase := args[0].String() + jsData := args[0] + dataLen := jsData.Get("length").Int() + rawBytes := make([]byte, dataLen) + js.CopyBytesToGo(rawBytes, jsData) + n := args[1].Int() k := args[2].Int() - shares, err := core.Split([]byte(passphrase), n, k) + shares, err := core.Split(rawBytes, n, k) if err != nil { return errorResult(err.Error()) } @@ -573,13 +576,16 @@ func createShareJS(this js.Value, args []js.Value) any { return errorResult(fmt.Sprintf("invalid base64 data: %v", err)) } - created, err := time.Parse(time.RFC3339, createdStr) + created, err := time.Parse("2006-01-02 15:04", createdStr) + if err != nil { + created, err = time.Parse(time.RFC3339, createdStr) + } if err != nil { return errorResult(fmt.Sprintf("invalid created time: %v", err)) } share := &core.Share{ - Version: 1, + Version: 2, Index: index, Total: total, Threshold: threshold, diff --git a/internal/wasm/js_wrappers.go b/internal/wasm/js_wrappers.go index e311a1a..685c62f 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(), } diff --git a/internal/wasm/recover.go b/internal/wasm/recover.go index fb20876..631d5ee 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. From 3f447352aa5a97affcc38e87a7de12fe26a72407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Albornoz?= Date: Tue, 10 Feb 2026 13:07:26 -0500 Subject: [PATCH 3/7] remove dead code --- internal/wasm/create.go | 185 ----------------------------------- internal/wasm/main_create.go | 6 -- 2 files changed, 191 deletions(-) diff --git a/internal/wasm/create.go b/internal/wasm/create.go index eb44863..e6ba2c4 100644 --- a/internal/wasm/create.go +++ b/internal/wasm/create.go @@ -455,191 +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 raw passphrase bytes using Shamir's Secret Sharing. -// Args: rawBytes (Uint8Array), 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 rawBytes, n, k)") - } - - jsData := args[0] - dataLen := jsData.Get("length").Int() - rawBytes := make([]byte, dataLen) - js.CopyBytesToGo(rawBytes, jsData) - - n := args[1].Int() - k := args[2].Int() - - shares, err := core.Split(rawBytes, 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("2006-01-02 15:04", createdStr) - if err != nil { - created, err = time.Parse(time.RFC3339, createdStr) - } - if err != nil { - return errorResult(fmt.Sprintf("invalid created time: %v", err)) - } - - share := &core.Share{ - Version: 2, - 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/main_create.go b/internal/wasm/main_create.go index 7cb55ce..91709cf 100644 --- a/internal/wasm/main_create.go +++ b/internal/wasm/main_create.go @@ -18,12 +18,6 @@ func main() { // 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) From 690c1ebcd0c74215e429afb60705009045fadc6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Albornoz?= Date: Tue, 10 Feb 2026 09:55:55 -0500 Subject: [PATCH 4/7] Add Word Lists using BIP39: 25 words for 33 bytes of shamir share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Encode each share as 25 human-readable words using the BIP39 English word list. The first 24 words encode the share data (33 bytes), and the 25th word encodes the share index — so recovering via words requires no extra input from the user. Words appear in README.txt, PDF, and can be typed or pasted directly into the recovery tool's paste area. The paste area auto-detects all formats: PEM blocks, compact strings, plain word lists, and numbered two-column grids. --- e2e/recovery.spec.ts | 76 + internal/bundle/readme.go | 23 +- internal/core/bip39_english.go | 2057 +++++++++++++++++ internal/core/golden_test.go | 48 + internal/core/testdata/v2-bundle/MANIFEST.age | Bin 422 -> 422 bytes .../core/testdata/v2-bundle/SHARE-alice.txt | 4 +- .../core/testdata/v2-bundle/SHARE-bob.txt | 4 +- .../core/testdata/v2-bundle/SHARE-carol.txt | 4 +- .../core/testdata/v2-bundle/SHARE-david.txt | 4 +- .../core/testdata/v2-bundle/SHARE-eve.txt | 4 +- internal/core/testdata/v2-golden.json | 45 +- internal/core/wordlist.go | 196 ++ internal/core/wordlist_test.go | 211 ++ internal/html/assets/recover.html | 51 +- internal/html/assets/src/app.ts | 98 +- internal/html/assets/src/types.ts | 1 + internal/pdf/readme.go | 30 + internal/wasm/js_wrappers.go | 32 + internal/wasm/main_create.go | 1 + internal/wasm/main_recover.go | 1 + internal/wasm/recover.go | 11 + 21 files changed, 2844 insertions(+), 57 deletions(-) create mode 100644 internal/core/bip39_english.go create mode 100644 internal/core/wordlist.go create mode 100644 internal/core/wordlist_test.go diff --git a/e2e/recovery.spec.ts b/e2e/recovery.spec.ts index 0f7d0b6..fa34a71 100644 --- a/e2e/recovery.spec.ts +++ b/e2e/recovery.spec.ts @@ -198,6 +198,82 @@ 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); + + // Read Bob's README.txt to extract the share words + const bobReadme = fs.readFileSync(path.join(bobDir, 'README.txt'), 'utf8'); + + // Extract words from the README.txt "YOUR 25 RECOVERY WORDS:" section + const wordsMatch = bobReadme.match(/YOUR 25 RECOVERY WORDS:\n\n([\s\S]*?)\n\nRead these words/); + expect(wordsMatch).not.toBeNull(); + + // Parse the two-column word grid into ordered word list + 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) { + // Each line has format: " 1. word 14. word" + 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); + } + } + } + } + } + // Combine: left column (1-13) then right column (14-25) + const words = [...leftWords, ...rightWords].join(' '); + 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); diff --git a/internal/bundle/readme.go b/internal/bundle/readme.go index cdb9209..f2448a2 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("YOUR 25 RECOVERY WORDS:\n\n") + 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/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/golden_test.go b/internal/core/golden_test.go index 4b9a81f..76025a1 100644 --- a/internal/core/golden_test.go +++ b/internal/core/golden_test.go @@ -35,6 +35,7 @@ type goldenShare struct { 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 { @@ -186,6 +187,7 @@ func TestGenerateGoldenFixtures(t *testing.T) { Checksum: share.Checksum, PEM: share.Encode(), Compact: share.CompactEncode(), + Words: strings.Join(share.Words(), " "), } } @@ -560,3 +562,49 @@ func TestGoldenDecrypt(t *testing.T) { }) } } + +// 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 := share.Words() + 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/testdata/v2-bundle/MANIFEST.age b/internal/core/testdata/v2-bundle/MANIFEST.age index d611656ca6101e2c471a581bff0c7bcb670e84f5..94a5ab5f35788864fe6c4167402a4ac7122b8f4a 100644 GIT binary patch delta 400 zcmV;B0dM}M1EvFzAb(C;H8x3aIZiT3VPiu_T6$4-D^)^gATc-!a(P2&a8p?~a(H<{ zac44WMO9KvNN{vQL8H-%l>!xrof?H zFKuV*Diwu4*C4n~Gr7S2f!bU0^=g!mJZY_AwcPrQvG4zVqSDwMQmrBww3`7v;GSVs zf#RO5uZm~XW#f!^p1dyIua5Rr)Mw5NJW+78c0;`DaZ$bPClK)}rZLxIm+REIV$J5ZHbCd5Db2+%{8J8`x9af7;{rlywyy zj{hXVIg_dcnfwGLiHTbBVo{SUKoSJYs-n@jN#~p)F-GcNu9cO;>(NljX6 zSV?hIFlRz_LveRCL1b2VMq*`Hb$VuEMru(C7=T(asc9kveSdMH!MsvcZp1no