diff --git a/internal/client/spool/identity.go b/internal/client/spool/identity.go index eb0945d..3613887 100644 --- a/internal/client/spool/identity.go +++ b/internal/client/spool/identity.go @@ -50,7 +50,7 @@ func writeIdentityAtomically(path string, data []byte) (result error) { _ = os.Remove(temporaryPath) } }() - if err := temporary.Chmod(0o600); err != nil { + if err := securePrivateFile(temporaryPath); err != nil { _ = temporary.Close() return fmt.Errorf("secure client identity temporary file: %w", err) } diff --git a/internal/client/spool/private_unix.go b/internal/client/spool/private_unix.go new file mode 100644 index 0000000..84fca0d --- /dev/null +++ b/internal/client/spool/private_unix.go @@ -0,0 +1,52 @@ +//go:build !windows + +package spool + +import ( + "errors" + "fmt" + "os" +) + +func ensurePrivateFile(path string) error { + file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600) + if err == nil { + return file.Close() + } + if !errors.Is(err, os.ErrExist) { + return err + } + info, err := os.Lstat(path) + if err != nil { + return err + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("%w: %s is not a regular file", ErrUnsafeDataDirectory, path) + } + if info.Mode().Perm()&0o077 != 0 { + return fmt.Errorf("%w: %s permissions %04o expose private state", ErrUnsafeDataDirectory, path, info.Mode().Perm()) + } + return nil +} + +func ensurePrivateDirectory(path string) error { + info, err := os.Lstat(path) + if os.IsNotExist(err) { + if err := os.MkdirAll(path, 0o700); err != nil { + return err + } + info, err = os.Lstat(path) + } + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("%w: %s is not a real directory", ErrUnsafeDataDirectory, path) + } + if info.Mode().Perm()&0o077 != 0 { + return fmt.Errorf("%w: %s permissions %04o expose private state", ErrUnsafeDataDirectory, path, info.Mode().Perm()) + } + return nil +} + +func securePrivateFile(path string) error { return os.Chmod(path, 0o600) } diff --git a/internal/client/spool/private_windows.go b/internal/client/spool/private_windows.go new file mode 100644 index 0000000..cb65927 --- /dev/null +++ b/internal/client/spool/private_windows.go @@ -0,0 +1,76 @@ +//go:build windows + +package spool + +import ( + "errors" + "fmt" + "os" + + "golang.org/x/sys/windows" +) + +const privateStateSDDL = "D:P(A;;FA;;;SY)(A;;FA;;;BA)" + +// Windows service state is owned by LocalSystem. The DACL is protected against +// parent inheritance and grants full access only to SYSTEM and Administrators; +// unprivileged execution contexts access it solely through the service. +func ensurePrivateFile(path string) error { + file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600) + if err == nil { + err = file.Close() + } else if !errors.Is(err, os.ErrExist) { + return err + } + if err != nil { + return err + } + info, err := os.Lstat(path) + if err != nil { + return err + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || hasReparsePoint(path) { + return fmt.Errorf("%w: %s is not a regular private file", ErrUnsafeDataDirectory, path) + } + return applyPrivateStateACL(path) +} + +func ensurePrivateDirectory(path string) error { + info, err := os.Lstat(path) + if os.IsNotExist(err) { + if err := os.MkdirAll(path, 0o700); err != nil { + return err + } + info, err = os.Lstat(path) + } + if err != nil { + return err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || hasReparsePoint(path) { + return fmt.Errorf("%w: %s is not a real private directory", ErrUnsafeDataDirectory, path) + } + return applyPrivateStateACL(path) +} + +func securePrivateFile(path string) error { return applyPrivateStateACL(path) } + +func hasReparsePoint(path string) bool { + value, err := windows.UTF16PtrFromString(path) + if err != nil { + return true + } + attributes, err := windows.GetFileAttributes(value) + return err != nil || attributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +func applyPrivateStateACL(path string) error { + descriptor, err := windows.SecurityDescriptorFromString(privateStateSDDL) + if err != nil { + return err + } + dacl, _, err := descriptor.DACL() + if err != nil { + return err + } + return windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, nil, nil, dacl, nil) +} diff --git a/internal/client/spool/spool.go b/internal/client/spool/spool.go index 11db4a7..33b21b1 100644 --- a/internal/client/spool/spool.go +++ b/internal/client/spool/spool.go @@ -9,7 +9,6 @@ import ( "errors" "fmt" "net/url" - "os" "path/filepath" "strconv" "sync" @@ -145,45 +144,4 @@ func (store *Store) Close() error { return result } -func ensurePrivateFile(path string) error { - file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600) - if err == nil { - return file.Close() - } - if !errors.Is(err, os.ErrExist) { - return err - } - info, err := os.Lstat(path) - if err != nil { - return err - } - if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { - return fmt.Errorf("%w: %s is not a regular file", ErrUnsafeDataDirectory, path) - } - if info.Mode().Perm()&0o077 != 0 { - return fmt.Errorf("%w: %s permissions %04o expose private state", ErrUnsafeDataDirectory, path, info.Mode().Perm()) - } - return nil -} - -func ensurePrivateDirectory(path string) error { - info, err := os.Lstat(path) - if os.IsNotExist(err) { - if err := os.MkdirAll(path, 0o700); err != nil { - return err - } - info, err = os.Lstat(path) - } - if err != nil { - return err - } - if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { - return fmt.Errorf("%w: %s is not a real directory", ErrUnsafeDataDirectory, path) - } - if info.Mode().Perm()&0o077 != 0 { - return fmt.Errorf("%w: %s permissions %04o expose private state", ErrUnsafeDataDirectory, path, info.Mode().Perm()) - } - return nil -} - func immutableDigest(payload []byte) [32]byte { return sha256.Sum256(payload) }