Skip to content

Commit b17f251

Browse files
fix(crypto): canonicalize identity path via EvalSymlinks/symlink resolution (PILOT-295) (#12)
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
1 parent 1027869 commit b17f251

2 files changed

Lines changed: 91 additions & 0 deletions

File tree

crypto/identity.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,46 @@ type identityFile struct {
7979
PublicKey string `json:"public_key"`
8080
}
8181

82+
// resolvePath resolves any symlinks in the given path. Intermediate
83+
// directories are resolved via filepath.EvalSymlinks; if the final
84+
// component is a symlink it is followed via os.Readlink.
85+
func resolvePath(path string) string {
86+
// Fast path: EvalSymlinks succeeds when all components exist.
87+
if resolved, err := filepath.EvalSymlinks(path); err == nil {
88+
return resolved
89+
}
90+
91+
// EvalSymlinks failed (likely final component doesn't exist).
92+
// Resolve the parent directory and re-join with the base name.
93+
parent := filepath.Dir(path)
94+
base := filepath.Base(path)
95+
96+
resolvedParent, err := filepath.EvalSymlinks(parent)
97+
if err != nil {
98+
return path // can't resolve even the parent
99+
}
100+
101+
resolved := filepath.Join(resolvedParent, base)
102+
103+
// If the final component is itself a symlink (pointing to a
104+
// non-existent target), follow it via Readlink.
105+
if fi, err := os.Lstat(resolved); err == nil && fi.Mode()&os.ModeSymlink != 0 {
106+
if target, err := os.Readlink(resolved); err == nil {
107+
if filepath.IsAbs(target) {
108+
return target
109+
}
110+
return filepath.Join(resolvedParent, target)
111+
}
112+
}
113+
114+
return resolved
115+
}
116+
82117
// SaveIdentity writes the identity keypair to a JSON file.
83118
// Creates parent directories if needed. File is written with mode 0600.
84119
func SaveIdentity(path string, id *Identity) error {
120+
path = resolvePath(path)
121+
85122
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
86123
return fmt.Errorf("create identity dir: %w", err)
87124
}
@@ -113,6 +150,8 @@ func SaveIdentity(path string, id *Identity) error {
113150
// or restored from a permissive backup can end up with 0o644.
114151
// Remediation: chmod 600 <path>.
115152
func LoadIdentity(path string) (*Identity, error) {
153+
path = resolvePath(path)
154+
116155
if fi, statErr := os.Stat(path); statErr == nil {
117156
if fi.Mode().Perm()&0o077 != 0 {
118157
return nil, fmt.Errorf("identity file has loose permissions (mode %o); chmod 600 %s and retry",

crypto/zz_coverage_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,58 @@ func TestSaveLoad_ConcurrentReaders(t *testing.T) {
179179
}
180180
}
181181

182+
// TestSaveLoad_SymlinkResolution ensures SaveIdentity and LoadIdentity
183+
// canonicalize the path via filepath.EvalSymlinks, preventing writes
184+
// through a symlink to an unintended directory.
185+
func TestSaveLoad_SymlinkResolution(t *testing.T) {
186+
t.Parallel()
187+
if runtime.GOOS == "windows" {
188+
t.Skip("symlinks require unix")
189+
}
190+
191+
// Create two directories: "real" (where the file should land) and
192+
// "linkdir" (the symlink target directory).
193+
realDir := filepath.Join(t.TempDir(), "real")
194+
if err := os.MkdirAll(realDir, 0700); err != nil {
195+
t.Fatalf("MkdirAll realDir: %v", err)
196+
}
197+
linkDir := filepath.Join(t.TempDir(), "linkdir")
198+
if err := os.MkdirAll(linkDir, 0700); err != nil {
199+
t.Fatalf("MkdirAll linkDir: %v", err)
200+
}
201+
202+
// Create a symlink: linkdir/id.json → realDir/id.json.
203+
targetPath := filepath.Join(realDir, "id.json")
204+
linkPath := filepath.Join(linkDir, "id.json")
205+
if err := os.Symlink(targetPath, linkPath); err != nil {
206+
t.Fatalf("Symlink: %v", err)
207+
}
208+
209+
// Save via the symlink path — file should land at the resolved target.
210+
id, err := GenerateIdentity()
211+
if err != nil {
212+
t.Fatalf("GenerateIdentity: %v", err)
213+
}
214+
if err := SaveIdentity(linkPath, id); err != nil {
215+
t.Fatalf("SaveIdentity via symlink: %v", err)
216+
}
217+
218+
// The file must be at the resolved target path, not a new inode at the
219+
// symlink location.
220+
if _, err := os.Stat(targetPath); err != nil {
221+
t.Fatalf("identity not at resolved target path: %v", err)
222+
}
223+
224+
// Load via the symlink path must succeed and return the saved identity.
225+
loaded, err := LoadIdentity(linkPath)
226+
if err != nil {
227+
t.Fatalf("LoadIdentity via symlink: %v", err)
228+
}
229+
if !loaded.PublicKey.Equal(id.PublicKey) {
230+
t.Error("public key mismatch after symlink roundtrip")
231+
}
232+
}
233+
182234
// TestSaveLoad_OverwriteExisting ensures SaveIdentity over an existing
183235
// file replaces it with the new keypair (no append, no merge).
184236
func TestSaveLoad_OverwriteExisting(t *testing.T) {

0 commit comments

Comments
 (0)