From 3d4c4364a2d8d9025d06e3630f0612ddf3278c6b Mon Sep 17 00:00:00 2001 From: matthew-pilot Date: Sat, 30 May 2026 14:45:19 +0000 Subject: [PATCH] fix(crypto): canonicalize identity path via EvalSymlinks/symlink resolution (PILOT-295) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SaveIdentity and LoadIdentity now resolve any symlinks in the supplied path before operating. This prevents writes to unexpected directories when an operator configures IdentityPath as a symlink to a location they don't fully control (e.g. group-readable dir). A resolvePath helper handles three cases: 1. All components exist → EvalSymlinks succeeds (fast path). 2. Final component doesn't exist → resolve parent, re-join base. 3. Final component is a dangling symlink → follow via Readlink. Closes PILOT-295 --- crypto/identity.go | 39 ++++++++++++++++++++++++++++ crypto/zz_coverage_test.go | 52 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/crypto/identity.go b/crypto/identity.go index f7d1dc5..6e969f2 100644 --- a/crypto/identity.go +++ b/crypto/identity.go @@ -79,9 +79,46 @@ type identityFile struct { PublicKey string `json:"public_key"` } +// resolvePath resolves any symlinks in the given path. Intermediate +// directories are resolved via filepath.EvalSymlinks; if the final +// component is a symlink it is followed via os.Readlink. +func resolvePath(path string) string { + // Fast path: EvalSymlinks succeeds when all components exist. + if resolved, err := filepath.EvalSymlinks(path); err == nil { + return resolved + } + + // EvalSymlinks failed (likely final component doesn't exist). + // Resolve the parent directory and re-join with the base name. + parent := filepath.Dir(path) + base := filepath.Base(path) + + resolvedParent, err := filepath.EvalSymlinks(parent) + if err != nil { + return path // can't resolve even the parent + } + + resolved := filepath.Join(resolvedParent, base) + + // If the final component is itself a symlink (pointing to a + // non-existent target), follow it via Readlink. + if fi, err := os.Lstat(resolved); err == nil && fi.Mode()&os.ModeSymlink != 0 { + if target, err := os.Readlink(resolved); err == nil { + if filepath.IsAbs(target) { + return target + } + return filepath.Join(resolvedParent, target) + } + } + + return resolved +} + // SaveIdentity writes the identity keypair to a JSON file. // Creates parent directories if needed. File is written with mode 0600. func SaveIdentity(path string, id *Identity) error { + path = resolvePath(path) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { return fmt.Errorf("create identity dir: %w", err) } @@ -113,6 +150,8 @@ func SaveIdentity(path string, id *Identity) error { // or restored from a permissive backup can end up with 0o644. // Remediation: chmod 600 . func LoadIdentity(path string) (*Identity, error) { + path = resolvePath(path) + if fi, statErr := os.Stat(path); statErr == nil { if fi.Mode().Perm()&0o077 != 0 { return nil, fmt.Errorf("identity file has loose permissions (mode %o); chmod 600 %s and retry", diff --git a/crypto/zz_coverage_test.go b/crypto/zz_coverage_test.go index da57396..07315df 100644 --- a/crypto/zz_coverage_test.go +++ b/crypto/zz_coverage_test.go @@ -179,6 +179,58 @@ func TestSaveLoad_ConcurrentReaders(t *testing.T) { } } +// TestSaveLoad_SymlinkResolution ensures SaveIdentity and LoadIdentity +// canonicalize the path via filepath.EvalSymlinks, preventing writes +// through a symlink to an unintended directory. +func TestSaveLoad_SymlinkResolution(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("symlinks require unix") + } + + // Create two directories: "real" (where the file should land) and + // "linkdir" (the symlink target directory). + realDir := filepath.Join(t.TempDir(), "real") + if err := os.MkdirAll(realDir, 0700); err != nil { + t.Fatalf("MkdirAll realDir: %v", err) + } + linkDir := filepath.Join(t.TempDir(), "linkdir") + if err := os.MkdirAll(linkDir, 0700); err != nil { + t.Fatalf("MkdirAll linkDir: %v", err) + } + + // Create a symlink: linkdir/id.json → realDir/id.json. + targetPath := filepath.Join(realDir, "id.json") + linkPath := filepath.Join(linkDir, "id.json") + if err := os.Symlink(targetPath, linkPath); err != nil { + t.Fatalf("Symlink: %v", err) + } + + // Save via the symlink path — file should land at the resolved target. + id, err := GenerateIdentity() + if err != nil { + t.Fatalf("GenerateIdentity: %v", err) + } + if err := SaveIdentity(linkPath, id); err != nil { + t.Fatalf("SaveIdentity via symlink: %v", err) + } + + // The file must be at the resolved target path, not a new inode at the + // symlink location. + if _, err := os.Stat(targetPath); err != nil { + t.Fatalf("identity not at resolved target path: %v", err) + } + + // Load via the symlink path must succeed and return the saved identity. + loaded, err := LoadIdentity(linkPath) + if err != nil { + t.Fatalf("LoadIdentity via symlink: %v", err) + } + if !loaded.PublicKey.Equal(id.PublicKey) { + t.Error("public key mismatch after symlink roundtrip") + } +} + // TestSaveLoad_OverwriteExisting ensures SaveIdentity over an existing // file replaces it with the new keypair (no append, no merge). func TestSaveLoad_OverwriteExisting(t *testing.T) {