From a93f2726669389a9e657e83aa02d87e2a797131d Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 16 Sep 2026 02:23:29 +0100 Subject: [PATCH] feat: add client-side crypto and vault file indexing Two independent client-side pieces that push/pull will build on: internal/client/vault scans a vault directory into a file index (relative path, size, mtime, SHA-256 content hash), and internal/client/crypto derives an AES-256 key from a passphrase via Argon2id and does AES-GCM encrypt/decrypt. vault.ScanVault: stats the root first so a bad/nonexistent path returns a clean error instead of panicking through WalkDir on a nil DirEntry, checks the walk callback's own error instead of ignoring it (an unreadable subdirectory now surfaces an error rather than silently vanishing from the index), and uses filepath.Rel so nested files with the same basename don't collide. Content hash streams the file through SHA-256 rather than buffering it whole. crypto.DeriveKey wraps argon2.IDKey (t=1, 2GiB memory, p=4, 32-byte key - OWASP's "uniformly safe" high-memory recommendation). Encrypt/Decrypt wrap AES-GCM, generating a random nonce per Encrypt call and prepending it to the ciphertext; Decrypt splits it back off before authenticating. A wrong key or a tampered/truncated ciphertext fails cleanly instead of returning garbage. --- internal/client/crypto/crypto.go | 59 ++++++++++ internal/client/crypto/doc.go | 2 - internal/client/vault/doc.go | 2 - internal/client/vault/vault.go | 83 ++++++++++++++ internal/client/vault/vault_test.go | 167 ++++++++++++++++++++++++++++ 5 files changed, 309 insertions(+), 4 deletions(-) create mode 100644 internal/client/crypto/crypto.go delete mode 100644 internal/client/crypto/doc.go delete mode 100644 internal/client/vault/doc.go create mode 100644 internal/client/vault/vault.go create mode 100644 internal/client/vault/vault_test.go diff --git a/internal/client/crypto/crypto.go b/internal/client/crypto/crypto.go new file mode 100644 index 0000000..de62d13 --- /dev/null +++ b/internal/client/crypto/crypto.go @@ -0,0 +1,59 @@ +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "fmt" + + "golang.org/x/crypto/argon2" +) + +func Encrypt(key, plaintext []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("crypto: new cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("crypto: new gcm: %w", err) + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, fmt.Errorf("crypto: generate nonce: %w", err) + } + + return gcm.Seal(nonce, nonce, plaintext, nil), nil +} + +func Decrypt(key, ciphertext []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("crypto: new cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("crypto: new gcm: %w", err) + } + + if len(ciphertext) < gcm.NonceSize() { + return nil, fmt.Errorf("crypto: ciphertext shorter than nonce") + } + + nonce, sealed := ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():] + + plaintext, err := gcm.Open(nil, nonce, sealed, nil) + if err != nil { + return nil, fmt.Errorf("crypto: decrypt: %w", err) + } + + return plaintext, nil +} + +func DeriveKey(passphrase, salt []byte) []byte { + key := argon2.IDKey(passphrase, salt, 1, 2*1024*1024, 4, 32) + return key +} diff --git a/internal/client/crypto/doc.go b/internal/client/crypto/doc.go deleted file mode 100644 index 5c234f6..0000000 --- a/internal/client/crypto/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package crypto implements key derivation and vault encryption. -package crypto diff --git a/internal/client/vault/doc.go b/internal/client/vault/doc.go deleted file mode 100644 index 3c05c6d..0000000 --- a/internal/client/vault/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package vault scans vault directories and maintains the local index. -package vault diff --git a/internal/client/vault/vault.go b/internal/client/vault/vault.go new file mode 100644 index 0000000..04239ab --- /dev/null +++ b/internal/client/vault/vault.go @@ -0,0 +1,83 @@ +package vault + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "time" +) + +type FileInfo struct { + Path string + Size int64 + ModTime time.Time + Hash string +} + +func ScanVault(dir string) ([]FileInfo, error) { + var files []FileInfo + + stat, err := os.Stat(dir) + if err != nil { + return nil, err + } + if !stat.IsDir() { + return nil, os.ErrNotExist + } + + err = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return fmt.Errorf("vault: walk %s: %w", path, err) + } + + if !d.IsDir() { + name, err := filepath.Rel(dir, path) + if err != nil { + return fmt.Errorf("vault: relative path for %s: %w", path, err) + } + + info, err := d.Info() + if err != nil { + return fmt.Errorf("vault: stat %s: %w", path, err) + } + + hash, err := hashFile(path) + if err != nil { + return fmt.Errorf("vault: hash %s: %w", path, err) + } + + files = append(files, FileInfo{ + Path: name, + Size: info.Size(), + ModTime: info.ModTime(), + Hash: hash, + }) + } + return nil + }) + if err != nil { + return nil, err + } + + return files, nil +} + +// SHA-256 of contents for change detection between scans. +func hashFile(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/internal/client/vault/vault_test.go b/internal/client/vault/vault_test.go new file mode 100644 index 0000000..638abf3 --- /dev/null +++ b/internal/client/vault/vault_test.go @@ -0,0 +1,167 @@ +package vault + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "testing" +) + +func writeFile(t *testing.T, dir, rel string, content []byte) { + t.Helper() + path := filepath.Join(dir, rel) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll(%s): %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, content, 0o644); err != nil { + t.Fatalf("WriteFile(%s): %v", path, err) + } +} + +func hashOf(content []byte) string { + sum := sha256.Sum256(content) + return hex.EncodeToString(sum[:]) +} + +func findByPath(t *testing.T, files []FileInfo, path string) FileInfo { + t.Helper() + for _, f := range files { + if f.Path == path { + return f + } + } + t.Fatalf("no FileInfo with Path %q in %+v", path, files) + return FileInfo{} +} + +func TestScanVault(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "note.md", []byte("hello")) + writeFile(t, root, "daily/2026-09-16.md", []byte("today's entry")) + writeFile(t, root, "attachments/photo.png", []byte("not actually a png")) + + files, err := ScanVault(root) + if err != nil { + t.Fatalf("ScanVault() error: %v", err) + } + if len(files) != 3 { + t.Fatalf("got %d files, want 3: %+v", len(files), files) + } + + note := findByPath(t, files, "note.md") + if note.Size != int64(len("hello")) { + t.Errorf("note.md Size = %d, want %d", note.Size, len("hello")) + } + if note.ModTime.IsZero() { + t.Error("note.md ModTime is zero") + } + if want := hashOf([]byte("hello")); note.Hash != want { + t.Errorf("note.md Hash = %q, want %q", note.Hash, want) + } + + // Nested path is preserved, not collapsed to the basename. + daily := findByPath(t, files, filepath.Join("daily", "2026-09-16.md")) + if want := hashOf([]byte("today's entry")); daily.Hash != want { + t.Errorf("daily entry Hash = %q, want %q", daily.Hash, want) + } + + // Non-markdown files are included too - the scan isn't note-only. + findByPath(t, files, filepath.Join("attachments", "photo.png")) +} + +func TestScanVaultSameBasenameDifferentDirs(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "folder1/note.md", []byte("first")) + writeFile(t, root, "folder2/note.md", []byte("second")) + + files, err := ScanVault(root) + if err != nil { + t.Fatalf("ScanVault() error: %v", err) + } + if len(files) != 2 { + t.Fatalf("got %d files, want 2: %+v", len(files), files) + } + + first := findByPath(t, files, filepath.Join("folder1", "note.md")) + second := findByPath(t, files, filepath.Join("folder2", "note.md")) + + if first.Hash == second.Hash { + t.Error("distinct files under the same basename got the same Hash - content wasn't actually distinguished") + } + if first.Path == second.Path { + t.Error("distinct files under the same basename collapsed to the same Path") + } +} + +func TestScanVaultEmptyDir(t *testing.T) { + root := t.TempDir() + + files, err := ScanVault(root) + if err != nil { + t.Fatalf("ScanVault() error: %v", err) + } + if len(files) != 0 { + t.Errorf("got %d files, want 0: %+v", len(files), files) + } +} + +func TestScanVaultNonexistentPath(t *testing.T) { + _, err := ScanVault(filepath.Join(t.TempDir(), "does-not-exist")) + if err == nil { + t.Fatal("expected an error for a nonexistent path, got nil") + } +} + +func TestScanVaultPathIsAFile(t *testing.T) { + root := t.TempDir() + file := filepath.Join(root, "not-a-directory") + if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + _, err := ScanVault(file) + if err == nil { + t.Fatal("expected an error when the vault path is a file, got nil") + } +} + +func TestScanVaultUnreadableSubdirSurfacesError(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("running as root - permission bits don't block access") + } + + root := t.TempDir() + writeFile(t, root, "visible.md", []byte("x")) + + blocked := filepath.Join(root, "blocked") + if err := os.Mkdir(blocked, 0o755); err != nil { + t.Fatal(err) + } + writeFile(t, root, "blocked/secret.md", []byte("x")) + if err := os.Chmod(blocked, 0o000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(blocked, 0o755) }) + + _, err := ScanVault(root) + if err == nil { + t.Fatal("expected ScanVault to surface an error for the unreadable subdirectory, got nil") + } +} + +func TestScanVaultDirectoriesAreNotEntries(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "notes/inbox.md", []byte("x")) + + files, err := ScanVault(root) + if err != nil { + t.Fatalf("ScanVault() error: %v", err) + } + + for _, f := range files { + if f.Path == "notes" { + t.Errorf("directory %q was included as a file entry", f.Path) + } + } +}