mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-19 20:09:07 +02:00
491 lines
17 KiB
Go
491 lines
17 KiB
Go
//go:build !ios
|
|
|
|
package certproof
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto"
|
|
"crypto/x509"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"sync"
|
|
"unsafe"
|
|
|
|
"github.com/ebitengine/purego"
|
|
log "github.com/sirupsen/logrus"
|
|
)
|
|
|
|
const (
|
|
securityFramework = "/System/Library/Frameworks/Security.framework/Security"
|
|
coreFoundationFramework = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"
|
|
|
|
errSecItemNotFound = -25300
|
|
errSecInteractionNotAllowed = -25308
|
|
)
|
|
|
|
// errKeyNeedsApproval reports a key whose access list does not include netbird, so using
|
|
// it needs the user's approval, which a daemon has no UI to ask for.
|
|
var errKeyNeedsApproval = errors.New("the key's access control requires user approval for netbird; " +
|
|
"import the identity with netbird allowed (security import <identity.p12> -k <keychain> " +
|
|
"-T /Applications/NetBird.app/Contents/MacOS/netbird), " +
|
|
"or set AllowAllAppsAccess in the MDM certificate payload, which allows every application")
|
|
|
|
var (
|
|
keychainOnce sync.Once
|
|
keychainErr error
|
|
|
|
secItemCopyMatching func(query uintptr, result *uintptr) int32
|
|
secIdentityCopyCertificate func(identity uintptr, cert *uintptr) int32
|
|
secIdentityCopyPrivateKey func(identity uintptr, key *uintptr) int32
|
|
secCertificateCopyData func(cert uintptr) uintptr
|
|
secKeyCreateSignature func(key, algorithm, data uintptr, err *uintptr) uintptr
|
|
|
|
secKeychainOpen func(path *byte, keychain *uintptr) int32
|
|
secKeychainCopySearchList func(searchList *uintptr) int32
|
|
secKeychainGetPath func(keychain uintptr, pathLength *uint32, path *byte) int32
|
|
cfArrayCreate func(alloc uintptr, values *uintptr, count int, callBacks uintptr) uintptr
|
|
|
|
cfDictionaryCreate func(alloc uintptr, keys, values *uintptr, count int, keyCallBacks, valueCallBacks uintptr) uintptr
|
|
cfArrayGetCount func(array uintptr) int
|
|
cfArrayGetValueAtIndex func(array uintptr, index int) uintptr
|
|
cfDataCreate func(alloc uintptr, data *byte, length int) uintptr
|
|
cfDataGetLength func(data uintptr) int
|
|
cfDataGetBytePtr func(data uintptr) unsafe.Pointer
|
|
cfErrorGetCode func(err uintptr) int
|
|
cfRelease func(ref uintptr)
|
|
|
|
kSecClass, kSecClassIdentity, kSecClassCertificate, kSecMatchLimit, kSecMatchLimitAll, kSecReturnRef uintptr
|
|
kSecMatchSearchList uintptr
|
|
kSecKeyAlgorithmECDSASHA256, kSecKeyAlgorithmECDSASHA384, kSecKeyAlgorithmRSAPSSSHA256 uintptr
|
|
kCFBooleanTrue, kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks, kCFTypeArrayCallBacks uintptr
|
|
)
|
|
|
|
// systemKeychain is where MDM installs device identities.
|
|
const systemKeychain = "/Library/Keychains/System.keychain"
|
|
|
|
// DefaultStore is the System keychain for the root daemon, so it never touches a user's
|
|
// keychain, whose keys would ask for approval in a session the daemon has no UI in. Run
|
|
// by an ordinary user, it is that user's keychain search list.
|
|
func DefaultStore() Store {
|
|
if os.Geteuid() == 0 {
|
|
return newKeychainStore(systemKeychain)
|
|
}
|
|
return newKeychainStore()
|
|
}
|
|
|
|
// keychainStore yields the identities of a set of keychains, or of the process's keychain
|
|
// search list when none is named, reached through purego so the client keeps building
|
|
// with CGO_ENABLED=0.
|
|
type keychainStore struct {
|
|
keychains []string
|
|
}
|
|
|
|
// newKeychainStore returns a store that searches the keychain files at paths, or the
|
|
// process's keychain search list when no path is given.
|
|
func newKeychainStore(paths ...string) *keychainStore {
|
|
return &keychainStore{keychains: paths}
|
|
}
|
|
|
|
func (s *keychainStore) Candidates(_ context.Context) ([]Candidate, error) {
|
|
if err := loadKeychain(); err != nil {
|
|
return nil, err
|
|
}
|
|
searchList, done, err := searchListOf(s.keychains)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer done()
|
|
|
|
var leaves []*x509.Certificate
|
|
err = eachIdentity(searchList, func(_ uintptr, der []byte) (bool, error) {
|
|
cert, err := x509.ParseCertificate(der)
|
|
if err != nil {
|
|
log.Warnf("skipping keychain identity: %v", err)
|
|
return false, nil
|
|
}
|
|
log.Debugf("keychain identity: subject=%q issuer=%q serial=%s expires=%s", cert.Subject, cert.Issuer, cert.SerialNumber, cert.NotAfter)
|
|
leaves = append(leaves, cert)
|
|
return false, nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// The certificate query runs even without identities: it separates a keychain that is
|
|
// readable but holds no identity from one the process cannot read at all.
|
|
pool, err := keychainCertificates(searchList)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(leaves) == 0 {
|
|
log.Debugf("keychain search list holds no identities usable for certificate posture, but %d readable certificates: an identity needs its private key in the same keychain", len(pool))
|
|
return nil, nil
|
|
}
|
|
log.Debugf("keychain search list holds %d identities and %d certificates for chain building", len(leaves), len(pool))
|
|
|
|
candidates := make([]Candidate, 0, len(leaves))
|
|
for _, leaf := range leaves {
|
|
chain := buildChain(leaf, pool)
|
|
log.Debugf("keychain candidate %q issued by %q built a chain of %d certificates", leaf.Subject, leaf.Issuer, len(chain))
|
|
if len(chain) == 1 && leaf.CheckSignatureFrom(leaf) != nil {
|
|
log.Debugf("keychain candidate %q has no issuer in the keychain, its proof carries the leaf alone and only verifies if the challenge supplies %q", leaf.Subject, leaf.Issuer)
|
|
}
|
|
candidates = append(candidates, Candidate{Chain: chain, Signer: &keychainSigner{leaf: leaf, keychains: s.keychains}, Intermediates: pool})
|
|
}
|
|
return candidates, nil
|
|
}
|
|
|
|
// keychainSigner holds only the certificate; the identity is looked up again at signing
|
|
// time so no keychain references outlive a call.
|
|
type keychainSigner struct {
|
|
leaf *x509.Certificate
|
|
keychains []string
|
|
}
|
|
|
|
func (s *keychainSigner) Public() crypto.PublicKey {
|
|
return s.leaf.PublicKey
|
|
}
|
|
|
|
func (s *keychainSigner) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
|
|
scheme, err := schemeFor(s.leaf.PublicKey, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
log.Debugf("signing certificate posture challenge with keychain key of %q", s.leaf.Subject)
|
|
|
|
searchList, done, err := searchListOf(s.keychains)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer done()
|
|
|
|
algorithm := keychainAlgorithm(scheme)
|
|
var signature []byte
|
|
var attempts signAttempts
|
|
err = eachIdentity(searchList, func(identity uintptr, der []byte) (bool, error) {
|
|
if !bytes.Equal(der, s.leaf.Raw) {
|
|
return false, nil
|
|
}
|
|
// The same certificate can sit in the keychain with more than one key, of which
|
|
// only one may be usable, so a failure moves on to the next match.
|
|
var ok bool
|
|
signature, ok = attempts.try(func() ([]byte, error) { return signWithIdentity(identity, algorithm, digest) })
|
|
return ok, nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if signature == nil {
|
|
if err := attempts.err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return nil, errors.New("certificate is no longer in the keychain")
|
|
}
|
|
log.Debugf("keychain signed certificate posture challenge for %q, %d bytes", s.leaf.Subject, len(signature))
|
|
return signature, nil
|
|
}
|
|
|
|
func keychainAlgorithm(scheme sigScheme) uintptr {
|
|
switch scheme {
|
|
case schemeECDSASHA384:
|
|
return kSecKeyAlgorithmECDSASHA384
|
|
case schemeRSAPSSSHA256:
|
|
return kSecKeyAlgorithmRSAPSSSHA256
|
|
default:
|
|
return kSecKeyAlgorithmECDSASHA256
|
|
}
|
|
}
|
|
|
|
func signWithIdentity(identity, algorithm uintptr, digest []byte) ([]byte, error) {
|
|
var key uintptr
|
|
if status := secIdentityCopyPrivateKey(identity, &key); status != 0 {
|
|
return nil, fmt.Errorf("SecIdentityCopyPrivateKey: %d", status)
|
|
}
|
|
defer release(key)
|
|
|
|
data := cfDataCreate(0, &digest[0], len(digest))
|
|
if data == 0 {
|
|
return nil, errors.New("CFDataCreate returned NULL")
|
|
}
|
|
defer release(data)
|
|
|
|
var cfErr uintptr
|
|
signature := secKeyCreateSignature(key, algorithm, data, &cfErr)
|
|
if signature == 0 {
|
|
if cfErr == 0 {
|
|
return nil, errors.New("SecKeyCreateSignature failed without a CFError")
|
|
}
|
|
defer release(cfErr)
|
|
code := cfErrorGetCode(cfErr)
|
|
if code == errSecInteractionNotAllowed {
|
|
return nil, fmt.Errorf("SecKeyCreateSignature: CFError %d: %w", code, errKeyNeedsApproval)
|
|
}
|
|
return nil, fmt.Errorf("SecKeyCreateSignature: CFError %d", code)
|
|
}
|
|
defer release(signature)
|
|
return dataBytes(signature), nil
|
|
}
|
|
|
|
// searchListOf opens the keychain files at paths as a CFArray for kSecMatchSearchList,
|
|
// or yields 0, the process's own search list, when paths is empty. The caller calls done
|
|
// once it no longer uses the list.
|
|
func searchListOf(paths []string) (searchList uintptr, done func(), err error) {
|
|
if len(paths) == 0 {
|
|
return 0, func() {}, nil
|
|
}
|
|
list, err := openSearchList(paths)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
return list, func() { release(list) }, nil
|
|
}
|
|
|
|
// openSearchList opens the keychain files at paths, at least one, as a CFArray. The
|
|
// caller releases the array.
|
|
func openSearchList(paths []string) (uintptr, error) {
|
|
refs := make([]uintptr, 0, len(paths))
|
|
defer func() {
|
|
for _, ref := range refs {
|
|
release(ref)
|
|
}
|
|
}()
|
|
for _, path := range paths {
|
|
cpath := append([]byte(path), 0)
|
|
var keychain uintptr
|
|
if status := secKeychainOpen(&cpath[0], &keychain); status != 0 {
|
|
return 0, fmt.Errorf("SecKeychainOpen %s: %d", path, status)
|
|
}
|
|
refs = append(refs, keychain)
|
|
}
|
|
// The array retains the keychains, so the references opened here are released.
|
|
list := cfArrayCreate(0, &refs[0], len(refs), kCFTypeArrayCallBacks)
|
|
if list == 0 {
|
|
return 0, errors.New("CFArrayCreate returned NULL")
|
|
}
|
|
return list, nil
|
|
}
|
|
|
|
// eachIdentity calls fn with every identity in searchList, or in the process's search
|
|
// list when it is 0, and its certificate. An identity whose certificate cannot be read is
|
|
// skipped rather than ending the walk.
|
|
func eachIdentity(searchList uintptr, fn func(identity uintptr, der []byte) (bool, error)) error {
|
|
return eachMatching(searchList, kSecClassIdentity, "identity", func(identity uintptr) (bool, error) {
|
|
var cert uintptr
|
|
if status := secIdentityCopyCertificate(identity, &cert); status != 0 {
|
|
log.Debugf("skipping keychain identity: SecIdentityCopyCertificate: %d", status)
|
|
return false, nil
|
|
}
|
|
der := certificateDER(cert)
|
|
release(cert)
|
|
if der == nil {
|
|
log.Debug("skipping keychain identity whose certificate has no DER data")
|
|
return false, nil
|
|
}
|
|
return fn(identity, der)
|
|
})
|
|
}
|
|
|
|
func keychainCertificates(searchList uintptr) ([]*x509.Certificate, error) {
|
|
var certs []*x509.Certificate
|
|
var unparsable int
|
|
err := eachMatching(searchList, kSecClassCertificate, "certificate", func(item uintptr) (bool, error) {
|
|
if cert, err := x509.ParseCertificate(certificateDER(item)); err == nil {
|
|
certs = append(certs, cert)
|
|
return false, nil
|
|
}
|
|
unparsable++
|
|
return false, nil
|
|
})
|
|
log.Debugf("keychain holds %d parsable certificates, %d unparsable", len(certs), unparsable)
|
|
return certs, err
|
|
}
|
|
|
|
func eachMatching(searchList, class uintptr, name string, fn func(item uintptr) (bool, error)) error {
|
|
keys := []uintptr{kSecClass, kSecMatchLimit, kSecReturnRef}
|
|
values := []uintptr{class, kSecMatchLimitAll, kCFBooleanTrue}
|
|
if searchList != 0 {
|
|
keys = append(keys, kSecMatchSearchList)
|
|
values = append(values, searchList)
|
|
}
|
|
query := cfDictionaryCreate(0, &keys[0], &values[0], len(keys), kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks)
|
|
defer release(query)
|
|
|
|
var items uintptr
|
|
switch status := secItemCopyMatching(query, &items); status {
|
|
case 0:
|
|
case errSecItemNotFound:
|
|
log.Debugf("keychain %s query returned errSecItemNotFound (%d): the search list holds no item of this class", name, errSecItemNotFound)
|
|
return nil
|
|
default:
|
|
log.Debugf("keychain %s query returned OSStatus %d", name, status)
|
|
return fmt.Errorf("SecItemCopyMatching: %d", status)
|
|
}
|
|
defer release(items)
|
|
|
|
n := cfArrayGetCount(items)
|
|
log.Debugf("keychain %s query returned %d items", name, n)
|
|
for i := 0; i < n; i++ {
|
|
if stop, err := fn(cfArrayGetValueAtIndex(items, i)); stop || err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// certificateDER returns the DER form of cert, or nil when SecCertificateCopyData
|
|
// returns NULL, which it does for an object that is not a valid certificate.
|
|
func certificateDER(cert uintptr) []byte {
|
|
data := secCertificateCopyData(cert)
|
|
if data == 0 {
|
|
return nil
|
|
}
|
|
defer release(data)
|
|
return dataBytes(data)
|
|
}
|
|
|
|
func dataBytes(data uintptr) []byte {
|
|
n := cfDataGetLength(data)
|
|
if n <= 0 {
|
|
return nil
|
|
}
|
|
return bytes.Clone(unsafe.Slice((*byte)(cfDataGetBytePtr(data)), n))
|
|
}
|
|
|
|
// release drops a CoreFoundation reference. CFRelease crashes the process on NULL, and
|
|
// several Security calls return NULL on failure, so every release goes through here.
|
|
func release(ref uintptr) {
|
|
if ref != 0 {
|
|
cfRelease(ref)
|
|
}
|
|
}
|
|
|
|
func loadKeychain() error {
|
|
keychainOnce.Do(func() {
|
|
if keychainErr = resolveKeychain(); keychainErr != nil {
|
|
log.Debugf("macOS keychain unavailable for certificate posture: %v", keychainErr)
|
|
return
|
|
}
|
|
log.Debugf("macOS Security framework loaded for certificate posture, running as uid=%d euid=%d", os.Getuid(), os.Geteuid())
|
|
logSearchList()
|
|
})
|
|
return keychainErr
|
|
}
|
|
|
|
// logSearchList reports the keychains the process searches. The root daemon sees the
|
|
// System keychain and System Roots, never a user's login keychain.
|
|
func logSearchList() {
|
|
if secKeychainCopySearchList == nil || secKeychainGetPath == nil {
|
|
log.Debug("keychain search list diagnostics unavailable on this macOS version")
|
|
return
|
|
}
|
|
|
|
var list uintptr
|
|
if status := secKeychainCopySearchList(&list); status != 0 {
|
|
log.Debugf("SecKeychainCopySearchList returned OSStatus %d", status)
|
|
return
|
|
}
|
|
defer release(list)
|
|
|
|
n := cfArrayGetCount(list)
|
|
log.Debugf("keychain search list contains %d keychains", n)
|
|
for i := 0; i < n; i++ {
|
|
log.Debugf("keychain search list[%d]: %s", i, keychainPath(cfArrayGetValueAtIndex(list, i)))
|
|
}
|
|
}
|
|
|
|
func keychainPath(keychain uintptr) string {
|
|
path := make([]byte, 1024)
|
|
length := uint32(len(path))
|
|
if status := secKeychainGetPath(keychain, &length, &path[0]); status != 0 {
|
|
return fmt.Sprintf("<SecKeychainGetPath: %d>", status)
|
|
}
|
|
return string(path[:length])
|
|
}
|
|
|
|
func resolveKeychain() error {
|
|
security, err := purego.Dlopen(securityFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
|
|
if err != nil {
|
|
return fmt.Errorf("open %s: %w", securityFramework, err)
|
|
}
|
|
coreFoundation, err := purego.Dlopen(coreFoundationFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
|
|
if err != nil {
|
|
return fmt.Errorf("open %s: %w", coreFoundationFramework, err)
|
|
}
|
|
|
|
for _, fn := range []struct {
|
|
ptr any
|
|
lib uintptr
|
|
name string
|
|
}{
|
|
{&secItemCopyMatching, security, "SecItemCopyMatching"},
|
|
{&secIdentityCopyCertificate, security, "SecIdentityCopyCertificate"},
|
|
{&secIdentityCopyPrivateKey, security, "SecIdentityCopyPrivateKey"},
|
|
{&secCertificateCopyData, security, "SecCertificateCopyData"},
|
|
{&secKeyCreateSignature, security, "SecKeyCreateSignature"},
|
|
{&secKeychainOpen, security, "SecKeychainOpen"},
|
|
{&cfArrayCreate, coreFoundation, "CFArrayCreate"},
|
|
{&cfDictionaryCreate, coreFoundation, "CFDictionaryCreate"},
|
|
{&cfArrayGetCount, coreFoundation, "CFArrayGetCount"},
|
|
{&cfArrayGetValueAtIndex, coreFoundation, "CFArrayGetValueAtIndex"},
|
|
{&cfDataCreate, coreFoundation, "CFDataCreate"},
|
|
{&cfDataGetLength, coreFoundation, "CFDataGetLength"},
|
|
{&cfDataGetBytePtr, coreFoundation, "CFDataGetBytePtr"},
|
|
{&cfErrorGetCode, coreFoundation, "CFErrorGetCode"},
|
|
{&cfRelease, coreFoundation, "CFRelease"},
|
|
} {
|
|
symbol, err := purego.Dlsym(fn.lib, fn.name)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve %s: %w", fn.name, err)
|
|
}
|
|
purego.RegisterFunc(fn.ptr, symbol)
|
|
}
|
|
|
|
for _, global := range []struct {
|
|
ptr *uintptr
|
|
lib uintptr
|
|
name string
|
|
deref bool
|
|
}{
|
|
{&kSecClass, security, "kSecClass", true},
|
|
{&kSecClassIdentity, security, "kSecClassIdentity", true},
|
|
{&kSecClassCertificate, security, "kSecClassCertificate", true},
|
|
{&kSecMatchLimit, security, "kSecMatchLimit", true},
|
|
{&kSecMatchLimitAll, security, "kSecMatchLimitAll", true},
|
|
{&kSecReturnRef, security, "kSecReturnRef", true},
|
|
{&kSecMatchSearchList, security, "kSecMatchSearchList", true},
|
|
{&kSecKeyAlgorithmECDSASHA256, security, "kSecKeyAlgorithmECDSASignatureDigestX962SHA256", true},
|
|
{&kSecKeyAlgorithmECDSASHA384, security, "kSecKeyAlgorithmECDSASignatureDigestX962SHA384", true},
|
|
{&kSecKeyAlgorithmRSAPSSSHA256, security, "kSecKeyAlgorithmRSASignatureDigestPSSSHA256", true},
|
|
{&kCFBooleanTrue, coreFoundation, "kCFBooleanTrue", true},
|
|
{&kCFTypeDictionaryKeyCallBacks, coreFoundation, "kCFTypeDictionaryKeyCallBacks", false},
|
|
{&kCFTypeDictionaryValueCallBacks, coreFoundation, "kCFTypeDictionaryValueCallBacks", false},
|
|
{&kCFTypeArrayCallBacks, coreFoundation, "kCFTypeArrayCallBacks", false},
|
|
} {
|
|
addr, err := purego.Dlsym(global.lib, global.name)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve %s: %w", global.name, err)
|
|
}
|
|
if global.deref {
|
|
addr = **(**uintptr)(unsafe.Pointer(&addr))
|
|
}
|
|
*global.ptr = addr
|
|
}
|
|
|
|
resolveOptional(security, "SecKeychainCopySearchList", &secKeychainCopySearchList)
|
|
resolveOptional(security, "SecKeychainGetPath", &secKeychainGetPath)
|
|
return nil
|
|
}
|
|
|
|
// resolveOptional binds a diagnostic-only symbol, leaving it nil when the framework no
|
|
// longer exports it so keychain lookups keep working without it.
|
|
func resolveOptional(lib uintptr, name string, ptr any) {
|
|
symbol, err := purego.Dlsym(lib, name)
|
|
if err != nil {
|
|
log.Debugf("keychain diagnostics: %s unavailable: %v", name, err)
|
|
return
|
|
}
|
|
purego.RegisterFunc(ptr, symbol)
|
|
}
|