Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions crypto/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 <path>.
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",
Expand Down
52 changes: 52 additions & 0 deletions crypto/zz_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading