fix(credential): Windows 키 파일 검증을 지원한다
This commit is contained in:
parent
32c0754f91
commit
17ba8fb3fa
8 changed files with 242 additions and 7 deletions
|
|
@ -21,7 +21,6 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -394,14 +393,9 @@ func readKeyFile(path string, size int, private bool) ([]byte, error) {
|
||||||
if err != nil || !info.Mode().IsRegular() {
|
if err != nil || !info.Mode().IsRegular() {
|
||||||
return nil, ErrKey
|
return nil, ErrKey
|
||||||
}
|
}
|
||||||
if private && info.Mode().Perm()&0o077 != 0 {
|
if private && !privateKeyFileSecure(file, info) {
|
||||||
return nil, ErrKey
|
return nil, ErrKey
|
||||||
}
|
}
|
||||||
if private {
|
|
||||||
if st, ok := info.Sys().(*syscall.Stat_t); !ok || (st.Uid != uint32(os.Getuid()) && st.Uid != 0) {
|
|
||||||
return nil, ErrKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
raw, err := io.ReadAll(io.LimitReader(file, int64(size*4+129)))
|
raw, err := io.ReadAll(io.LimitReader(file, int64(size*4+129)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, ErrKey
|
return nil, ErrKey
|
||||||
|
|
|
||||||
10
packages/go/credentiallease/keyfile_other_test.go
Normal file
10
packages/go/credentiallease/keyfile_other_test.go
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
//go:build !unix && !windows
|
||||||
|
|
||||||
|
package credentiallease
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func secureTestKeyFile(t *testing.T, _ string) {
|
||||||
|
t.Helper()
|
||||||
|
t.Skip("private key files are unsupported on this platform")
|
||||||
|
}
|
||||||
9
packages/go/credentiallease/keyfile_security_other.go
Normal file
9
packages/go/credentiallease/keyfile_security_other.go
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
//go:build !unix && !windows
|
||||||
|
|
||||||
|
package credentiallease
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
// Unknown permission models fail closed until the platform has an explicit
|
||||||
|
// ownership and access-control implementation.
|
||||||
|
func privateKeyFileSecure(_ *os.File, _ os.FileInfo) bool { return false }
|
||||||
16
packages/go/credentiallease/keyfile_security_unix.go
Normal file
16
packages/go/credentiallease/keyfile_security_unix.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
//go:build unix
|
||||||
|
|
||||||
|
package credentiallease
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func privateKeyFileSecure(_ *os.File, info os.FileInfo) bool {
|
||||||
|
if info.Mode().Perm()&0o077 != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
st, ok := info.Sys().(*syscall.Stat_t)
|
||||||
|
return ok && (st.Uid == uint32(os.Getuid()) || st.Uid == 0)
|
||||||
|
}
|
||||||
83
packages/go/credentiallease/keyfile_security_windows.go
Normal file
83
packages/go/credentiallease/keyfile_security_windows.go
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package credentiallease
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
accessAllowedObjectACEType = 5
|
||||||
|
accessAllowedCallbackACEType = 9
|
||||||
|
accessAllowedCallbackObjectACEType = 11
|
||||||
|
)
|
||||||
|
|
||||||
|
// privateKeyFileSecure validates Windows ownership and discretionary ACLs.
|
||||||
|
// FileMode permission bits do not represent Windows ACLs, so checking 0600
|
||||||
|
// would reject every normal Windows key file without proving confidentiality.
|
||||||
|
func privateKeyFileSecure(file *os.File, _ os.FileInfo) bool {
|
||||||
|
sd, err := windows.GetSecurityInfo(
|
||||||
|
windows.Handle(file.Fd()),
|
||||||
|
windows.SE_FILE_OBJECT,
|
||||||
|
windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION,
|
||||||
|
)
|
||||||
|
if err != nil || sd == nil || !sd.IsValid() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err := windows.GetCurrentProcessToken().GetTokenUser()
|
||||||
|
if err != nil || current == nil || current.User.Sid == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
owner, _, err := sd.Owner()
|
||||||
|
if err != nil || !trustedKeyFileSID(owner, current.User.Sid) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
dacl, _, err := sd.DACL()
|
||||||
|
if err != nil || dacl == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := uint32(0); i < uint32(dacl.AceCount); i++ {
|
||||||
|
var ace *windows.ACCESS_ALLOWED_ACE
|
||||||
|
if err := windows.GetAce(dacl, i, &ace); err != nil || ace == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if ace.Header.AceFlags&windows.INHERIT_ONLY_ACE != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch ace.Header.AceType {
|
||||||
|
case windows.ACCESS_DENIED_ACE_TYPE:
|
||||||
|
continue
|
||||||
|
case windows.ACCESS_ALLOWED_ACE_TYPE:
|
||||||
|
if !aceGrantsFileRead(ace.Mask) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart))
|
||||||
|
if !trustedKeyFileSID(sid, current.User.Sid) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
case accessAllowedObjectACEType, accessAllowedCallbackACEType, accessAllowedCallbackObjectACEType:
|
||||||
|
// These layouts carry additional fields before the SID. Reject them
|
||||||
|
// instead of guessing whether an untrusted principal can read the key.
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func aceGrantsFileRead(mask windows.ACCESS_MASK) bool {
|
||||||
|
const readMask = windows.FILE_READ_DATA | windows.GENERIC_READ | windows.GENERIC_ALL | windows.MAXIMUM_ALLOWED
|
||||||
|
return mask&readMask != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func trustedKeyFileSID(candidate, current *windows.SID) bool {
|
||||||
|
return candidate != nil && current != nil && (candidate.Equals(current) ||
|
||||||
|
candidate.IsWellKnown(windows.WinLocalSystemSid) ||
|
||||||
|
candidate.IsWellKnown(windows.WinBuiltinAdministratorsSid))
|
||||||
|
}
|
||||||
44
packages/go/credentiallease/keyfile_test.go
Normal file
44
packages/go/credentiallease/keyfile_test.go
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
package credentiallease
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadPrivateKeyFile(t *testing.T) {
|
||||||
|
want := bytes.Repeat([]byte{0x5a}, 32)
|
||||||
|
path := filepath.Join(t.TempDir(), "recipient.key")
|
||||||
|
if err := os.WriteFile(path, []byte(base64.StdEncoding.EncodeToString(want)), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
secureTestKeyFile(t, path)
|
||||||
|
|
||||||
|
got, err := LoadPrivateKeyFile(path, len(want))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadPrivateKeyFile() error = %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(got, want) {
|
||||||
|
t.Fatal("LoadPrivateKeyFile() returned unexpected key material")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadPrivateKeyFileRejectsInsecureUnixMode(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("Windows private-key access is validated from the file DACL")
|
||||||
|
}
|
||||||
|
path := filepath.Join(t.TempDir(), "recipient.key")
|
||||||
|
encoded := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x5a}, 32))
|
||||||
|
if err := os.WriteFile(path, []byte(encoded), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := LoadPrivateKeyFile(path, 32)
|
||||||
|
if !errors.Is(err, ErrKey) {
|
||||||
|
t.Fatalf("LoadPrivateKeyFile() error = %v, want ErrKey", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
15
packages/go/credentiallease/keyfile_unix_test.go
Normal file
15
packages/go/credentiallease/keyfile_unix_test.go
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
//go:build unix
|
||||||
|
|
||||||
|
package credentiallease
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func secureTestKeyFile(t *testing.T, path string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.Chmod(path, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
64
packages/go/credentiallease/keyfile_windows_test.go
Normal file
64
packages/go/credentiallease/keyfile_windows_test.go
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package credentiallease
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
func secureTestKeyFile(t *testing.T, path string) {
|
||||||
|
t.Helper()
|
||||||
|
current, err := windows.GetCurrentProcessToken().GetTokenUser()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
setTestKeyFileDACL(t, path, "D:P(A;;FA;;;"+current.User.Sid.String()+")(A;;FA;;;SY)(A;;FA;;;BA)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadPrivateKeyFileRejectsUntrustedWindowsReader(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "recipient.key")
|
||||||
|
encoded := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x5a}, 32))
|
||||||
|
if err := os.WriteFile(path, []byte(encoded), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
current, err := windows.GetCurrentProcessToken().GetTokenUser()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
setTestKeyFileDACL(t, path, "D:P(A;;FA;;;"+current.User.Sid.String()+")(A;;FR;;;WD)")
|
||||||
|
|
||||||
|
_, err = LoadPrivateKeyFile(path, 32)
|
||||||
|
if !errors.Is(err, ErrKey) {
|
||||||
|
t.Fatalf("LoadPrivateKeyFile() error = %v, want ErrKey", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setTestKeyFileDACL(t *testing.T, path, sddl string) {
|
||||||
|
t.Helper()
|
||||||
|
sd, err := windows.SecurityDescriptorFromString(sddl)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dacl, _, err := sd.DACL()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := windows.SetNamedSecurityInfo(
|
||||||
|
path,
|
||||||
|
windows.SE_FILE_OBJECT,
|
||||||
|
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
dacl,
|
||||||
|
nil,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue