diff --git a/internal/darwin/corefoundation/core_foundation_darwin.go b/internal/darwin/corefoundation/core_foundation_darwin.go index 1008ddfb..552269df 100644 --- a/internal/darwin/corefoundation/core_foundation_darwin.go +++ b/internal/darwin/corefoundation/core_foundation_darwin.go @@ -33,6 +33,7 @@ const ( nilCFData C.CFDataRef = 0 nilCFString C.CFStringRef = 0 nilCFDictionary C.CFDictionaryRef = 0 + nilCFArray C.CFArrayRef = 0 nilCFError C.CFErrorRef = 0 nilCFType C.CFTypeRef = 0 ) @@ -45,10 +46,15 @@ func Release(ref TypeReferer) { C.CFRelease(ref.TypeRef()) } +func Retain(ref TypeReferer) { + C.CFRetain(ref.TypeRef()) +} + type CFTypeRef = C.CFTypeRef type CFStringRef = C.CFStringRef type CFErrorRef = C.CFErrorRef type CFDictionaryRef = C.CFDictionaryRef +type CFArrayRef = C.CFArrayRef type CFDataRef = C.CFDataRef type TypeRef C.CFTypeRef @@ -167,6 +173,28 @@ func NewDictionaryRef(ref TypeRef) *DictionaryRef { func (v *DictionaryRef) Release() { Release(v) } func (v *DictionaryRef) TypeRef() CFTypeRef { return C.CFTypeRef(v.Value) } +type ArrayRef struct { + Value C.CFArrayRef +} + +func NewArrayRef(ref TypeRef) *ArrayRef { + return &ArrayRef{ + Value: C.CFArrayRef(ref), + } +} + +func (v *ArrayRef) Release() { Release(v) } +func (v *ArrayRef) TypeRef() CFTypeRef { return C.CFTypeRef(v.Value) } + +func (v *ArrayRef) Len() int { + return int(C.CFArrayGetCount(v.Value)) +} + +func (v *ArrayRef) Get(index int) TypeRef { + item := C.CFArrayGetValueAtIndex(v.Value, C.CFIndex(index)) + return TypeRef(item) +} + //nolint:errname // type name matches original name type ErrorRef C.CFErrorRef diff --git a/internal/darwin/security/security_darwin.go b/internal/darwin/security/security_darwin.go index 7ce8a7d3..6c83b93b 100644 --- a/internal/darwin/security/security_darwin.go +++ b/internal/darwin/security/security_darwin.go @@ -80,9 +80,11 @@ var ( KSecClassIdentity = cf.TypeRef(C.kSecClassIdentity) KSecMatchLimit = cf.TypeRef(C.kSecMatchLimit) KSecMatchLimitOne = cf.TypeRef(C.kSecMatchLimitOne) + KSecMatchLimitAll = cf.TypeRef(C.kSecMatchLimitAll) KSecPublicKeyAttrs = cf.TypeRef(C.kSecPublicKeyAttrs) KSecPrivateKeyAttrs = cf.TypeRef(C.kSecPrivateKeyAttrs) KSecReturnRef = cf.TypeRef(C.kSecReturnRef) + KSecReturnAttributes = cf.TypeRef(C.kSecReturnAttributes) KSecValueRef = cf.TypeRef(C.kSecValueRef) KSecValueData = cf.TypeRef(C.kSecValueData) ) @@ -138,6 +140,20 @@ const ( KSecAccessControlOr = SecAccessControlCreateFlags(C.kSecAccessControlOr) ) +type SecKeychainItemRef struct { + Value C.SecKeychainItemRef +} + +func NewSecKeychainItemRef(ref cf.TypeRef) *SecKeychainItemRef { + return &SecKeychainItemRef{ + Value: C.SecKeychainItemRef(ref), + } +} + +func (v *SecKeychainItemRef) Release() { cf.Release(v) } +func (v *SecKeychainItemRef) TypeRef() cf.CFTypeRef { return cf.CFTypeRef(v.Value) } +func (v *SecKeychainItemRef) Retain() { cf.Retain(v) } + type SecKeyRef struct { Value C.SecKeyRef } @@ -150,6 +166,7 @@ func NewSecKeyRef(ref cf.TypeRef) *SecKeyRef { func (v *SecKeyRef) Release() { cf.Release(v) } func (v *SecKeyRef) TypeRef() cf.CFTypeRef { return cf.CFTypeRef(v.Value) } +func (v *SecKeyRef) Retain() { cf.Retain(v) } type SecCertificateRef struct { Value C.SecCertificateRef @@ -309,6 +326,54 @@ func GetSecAttrApplicationLabel(v *cf.DictionaryRef) []byte { ) } +func GetSecAttrApplicationTag(v *cf.DictionaryRef) string { + data := C.CFDataRef(C.CFDictionaryGetValue(C.CFDictionaryRef(v.Value), unsafe.Pointer(C.kSecAttrApplicationTag))) + return string(C.GoBytes( + unsafe.Pointer(C.CFDataGetBytePtr(data)), + C.int(C.CFDataGetLength(data)), + )) +} + +func GetSecAttrLabel(v *cf.DictionaryRef) (label string) { + ref := C.CFStringRef(C.CFDictionaryGetValue(C.CFDictionaryRef(v.Value), unsafe.Pointer(C.kSecAttrLabel))) + if cstr := C.CFStringGetCStringPtr(ref, C.kCFStringEncodingUTF8); cstr != nil { + label = C.GoString(cstr) + } + return label +} + +func GetSecAttrTokenID(v *cf.DictionaryRef) (tokenID string) { + ref := C.CFStringRef(C.CFDictionaryGetValue(C.CFDictionaryRef(v.Value), unsafe.Pointer(C.kSecAttrTokenID))) + if cstr := C.CFStringGetCStringPtr(ref, C.kCFStringEncodingUTF8); cstr != nil { + tokenID = C.GoString(cstr) + } + return tokenID +} + +func GetSecAttrAccessControl(v *cf.DictionaryRef) *SecAccessControlRef { + var keyAttributes unsafe.Pointer + tokenID := GetSecAttrTokenID(v) + if tokenID == "com.apple.setoken" { + keyAttributes = C.CFDictionaryGetValue(C.CFDictionaryRef(v.Value), unsafe.Pointer(C.kSecPrivateKeyAttrs)) + } else { + keyAttributes = C.CFDictionaryGetValue(C.CFDictionaryRef(v.Value), unsafe.Pointer(C.kSecPublicKeyAttrs)) + } + if keyAttributes == nil { + return nil + } + + dv := C.CFDictionaryGetValue(C.CFDictionaryRef(keyAttributes), unsafe.Pointer(C.kSecAttrAccessControl)) + if dv == nil { + return nil + } + + ref := &SecAccessControlRef{ + ref: C.SecAccessControlRef(dv), + } + + return ref +} + func GetSecValueData(v *cf.DictionaryRef) []byte { data := C.CFDataRef(C.CFDictionaryGetValue(C.CFDictionaryRef(v.Value), unsafe.Pointer(C.kSecValueData))) return C.GoBytes( diff --git a/kms/apiv1/options.go b/kms/apiv1/options.go index faa7737b..3b50b942 100644 --- a/kms/apiv1/options.go +++ b/kms/apiv1/options.go @@ -18,6 +18,18 @@ type KeyManager interface { Close() error } +// SearchableKeyManager is an optional interface for KMS implementations +// that support searching for keys based on certain attributes. +// +// # Experimental +// +// Notice: This API is EXPERIMENTAL and may be changed or removed in a later +// release. +type SearchableKeyManager interface { + KeyManager + SearchKeys(req *SearchKeysRequest) (*SearchKeysResponse, error) +} + // Decrypter is an interface implemented by KMSes that are used // in operations that require decryption type Decrypter interface { diff --git a/kms/apiv1/requests.go b/kms/apiv1/requests.go index f1430e1c..394dd0ac 100644 --- a/kms/apiv1/requests.go +++ b/kms/apiv1/requests.go @@ -178,6 +178,24 @@ type CreateKeyResponse struct { CreateSignerRequest CreateSignerRequest } +// SearchKeysRequest is the request for the SearchKeys method. It takes +// a Query string with the attributes to match when searching the +// KMS. +type SearchKeysRequest struct { + Query string +} + +// SearchKeyResult is a single result returned from the SearchKeys +// method. +type SearchKeyResult CreateKeyResponse + +// SearchKeysResponse is the response for the SearchKeys method. It +// wraps a slice of SearchKeyResult structs. The Results slice can +// be empty in case no key was found for the search query. +type SearchKeysResponse struct { + Results []SearchKeyResult +} + // CreateSignerRequest is the parameter used in the kms.CreateSigner method. type CreateSignerRequest struct { Signer crypto.Signer diff --git a/kms/mackms/mackms.go b/kms/mackms/mackms.go index 4efdb366..889bafe1 100644 --- a/kms/mackms/mackms.go +++ b/kms/mackms/mackms.go @@ -58,6 +58,14 @@ type keyAttributes struct { keySize int } +type keySearchAttributes struct { + label string + tag string + hash []byte + secureEnclaveSet bool + useSecureEnclave bool +} + type certAttributes struct { label string serialNumber *big.Int @@ -555,6 +563,89 @@ func (*MacKMS) DeleteCertificate(req *apiv1.DeleteCertificateRequest) error { return nil } +// SearchKeys searches for keys according to the query URI in the request. By default, +// all keys managed by the KMS using the default tag, and both Secure Enclave as well as +// non-Secure Enclave keys will be returned. +// +// - "" will return all keys managed by the KMS (using the default tag) +// - "mackms:" will return all keys managed by the KMS (using the default tag) +// - "mackms:label=my-label" will return all keys using label "my-label" (and the default tag) +// - "mackms:hash=the-hash" will return all keys having hash "hash" (and the default tag; generally one result) +// - "mackms:tag=my-tag" will search for all keys with "my-tag" +// - "mackms:se=true" will return all Secure Enclave keys managed by the KMS (using the default tag) +// - "mackms:se=false" will return all non-Secure Enclave keys managed by the KMS (using the default tag) +// +// # Experimental +// +// Notice: This API is EXPERIMENTAL and may be changed or removed in a later +// release. +func (k *MacKMS) SearchKeys(req *apiv1.SearchKeysRequest) (*apiv1.SearchKeysResponse, error) { + if req.Query == "" { + return nil, fmt.Errorf("searchKeysRequest 'query' cannot be empty") + } + + u, err := parseSearchURI(req.Query) + if err != nil { + return nil, fmt.Errorf("failed parsing query: %w", err) + } + + keys, err := getPrivateKeys(u) + if err != nil { + return nil, fmt.Errorf("failed getting keys: %w", err) + } + + results := make([]apiv1.SearchKeyResult, len(keys)) + for i, key := range keys { + d := cf.NewDictionaryRef(cf.TypeRef(key.TypeRef())) + defer key.Release() + defer d.Release() + + name := uri.New(Scheme, url.Values{}) + tokenID := security.GetSecAttrTokenID(d) + keyInSecureEnclave := tokenID == "com.apple.setoken" + switch { + case !u.secureEnclaveSet && keyInSecureEnclave: + name.Values.Set("se", "true") + case !u.secureEnclaveSet && !keyInSecureEnclave: + name.Values.Set("se", "false") + case u.useSecureEnclave && keyInSecureEnclave: + name.Values.Set("se", "true") + case !u.useSecureEnclave && !keyInSecureEnclave: + name.Values.Set("se", "false") + default: + // skip in case the query doesn't match the actual property + continue + } + + name.Values.Set("hash", hex.EncodeToString(security.GetSecAttrApplicationLabel(d))) + name.Values.Set("label", security.GetSecAttrLabel(d)) + name.Values.Set("tag", security.GetSecAttrApplicationTag(d)) + + // obtain the public key by requesting it, as the current + // representation of the key includes just the attributes. + pub, err := k.GetPublicKey(&apiv1.GetPublicKeyRequest{ + Name: name.String(), + }) + if err != nil { + return nil, fmt.Errorf("failed getting public key: %w", err) + } + + results[i] = apiv1.SearchKeyResult{ + Name: name.String(), + PublicKey: pub, + CreateSignerRequest: apiv1.CreateSignerRequest{ + SigningKey: name.String(), + }, + } + } + + return &apiv1.SearchKeysResponse{ + Results: results, + }, nil +} + +var _ apiv1.SearchableKeyManager = (*MacKMS)(nil) + func deleteItem(dict cf.Dictionary, hash []byte) error { if len(hash) > 0 { d, err := cf.NewData(hash) @@ -625,6 +716,70 @@ func getPrivateKey(u *keyAttributes) (*security.SecKeyRef, error) { return security.NewSecKeyRef(key), nil } +func getPrivateKeys(u *keySearchAttributes) ([]*security.SecKeychainItemRef, error) { + dict := cf.Dictionary{ + security.KSecClass: security.KSecClassKey, + security.KSecAttrKeyClass: security.KSecAttrKeyClassPrivate, + security.KSecReturnAttributes: cf.True, // return keychain attributes, i.e. tag and label + security.KSecMatchLimit: security.KSecMatchLimitAll, + } + + if u.tag != "" { + cfTag, err := cf.NewData([]byte(u.tag)) + if err != nil { + return nil, err + } + defer cfTag.Release() + dict[security.KSecAttrApplicationTag] = cfTag + } + if u.label != "" { + cfLabel, err := cf.NewString(u.label) + if err != nil { + return nil, err + } + defer cfLabel.Release() + dict[security.KSecAttrLabel] = cfLabel + } + if len(u.hash) > 0 { + cfHash, err := cf.NewData(u.hash) + if err != nil { + return nil, err + } + defer cfHash.Release() + dict[security.KSecAttrApplicationLabel] = cfHash + } + + // construct the query + query, err := cf.NewDictionary(dict) + if err != nil { + return nil, err + } + defer query.Release() + + // perform the query + var result cf.TypeRef + err = security.SecItemCopyMatching(query, &result) + if err != nil { + if errors.Is(err, security.ErrNotFound) { + return []*security.SecKeychainItemRef{}, nil + } + return nil, fmt.Errorf("macOS SecItemCopyMatching failed: %w", err) + } + + array := cf.NewArrayRef(result) + defer array.Release() + + keys := make([]*security.SecKeychainItemRef, array.Len()) + for i := 0; i < array.Len(); i++ { + item := array.Get(i) + key := security.NewSecKeychainItemRef(item) + key.Retain() // retain the key, so that it's not released early + keys[i] = key + } + + return keys, nil +} + func extractPublicKey(secKeyRef *security.SecKeyRef) (crypto.PublicKey, []byte, error) { // Get the hash of the public key. We can also calculate this from the // external representation below, but in case Apple decides to switch from @@ -915,6 +1070,49 @@ func parseCertURI(rawuri string, requireValue bool) (*certAttributes, error) { }, nil } +func parseSearchURI(rawuri string) (*keySearchAttributes, error) { + // When rawuri is just the key name + if !strings.HasPrefix(strings.ToLower(rawuri), Scheme) { + return &keySearchAttributes{ + label: rawuri, + tag: DefaultTag, + }, nil + } + + // When rawuri is a mackms uri. + u, err := uri.Parse(rawuri) + if err != nil { + return nil, err + } + + // Special case for mackms:label + if len(u.Values) == 1 { + for k, v := range u.Values { + if (len(v) == 1 && v[0] == "") || len(v) == 0 { + return &keySearchAttributes{ + label: k, + tag: DefaultTag, + }, nil + } + } + } + + // With regular values, uris look like this: + // mackms:label=my-key;tag=my-tag;hash=010a...;se=true;bio=true + label := u.Get("label") // when searching, the label can be empty + tag := u.Get("tag") + if tag == "" { + tag = DefaultTag + } + return &keySearchAttributes{ + label: label, + tag: tag, + hash: u.GetEncoded("hash"), + secureEnclaveSet: u.Values.Has("se"), + useSecureEnclave: u.GetBool("se"), + }, nil +} + // encodeSerialNumber encodes the serial number of a certificate in ASN.1. // Negative serial numbers are not allowed. func encodeSerialNumber(s *big.Int) []byte { diff --git a/kms/mackms/mackms_test.go b/kms/mackms/mackms_test.go index a93b6d59..6bf27402 100644 --- a/kms/mackms/mackms_test.go +++ b/kms/mackms/mackms_test.go @@ -1233,3 +1233,55 @@ func Test_apiv1Error(t *testing.T) { }) } } + +func TestMacKMS_SearchKeys(t *testing.T) { + name, err := randutil.Hex(10) + require.NoError(t, err) + tag := fmt.Sprintf("com.smallstep.crypto.test.%s", name) // unique tag per test execution + + // initialize MacKMS + k := &MacKMS{} + + // search by tag; expect 0 keys before the test + got, err := k.SearchKeys(&apiv1.SearchKeysRequest{Query: fmt.Sprintf("mackms:tag=%s", tag)}) + require.NoError(t, err) + require.NotNil(t, got) + require.Len(t, got.Results, 0) + + key1, err := k.CreateKey(&apiv1.CreateKeyRequest{Name: fmt.Sprintf("mackms:name=test-step-1;label=test-step-1;tag=%s;se=false", tag)}) + require.NoError(t, err) + key2, err := k.CreateKey(&apiv1.CreateKeyRequest{Name: fmt.Sprintf("mackms:name=test-step-2;label=test-step-2;tag=%s;se=false", tag)}) + require.NoError(t, err) + u1, err := uri.ParseWithScheme(Scheme, key1.Name) + require.NoError(t, err) + u2, err := uri.ParseWithScheme(Scheme, key2.Name) + require.NoError(t, err) + expectedHashes := []string{u1.Get("hash"), u2.Get("hash")} + require.Len(t, expectedHashes, 2) + t.Cleanup(func() { + err = k.DeleteKey(&apiv1.DeleteKeyRequest{Name: key1.Name}) + require.NoError(t, err) + err = k.DeleteKey(&apiv1.DeleteKeyRequest{Name: key2.Name}) + require.NoError(t, err) + }) + + // search by tag + got, err = k.SearchKeys(&apiv1.SearchKeysRequest{Query: fmt.Sprintf("mackms:tag=%s", tag)}) + require.NoError(t, err) + require.NotNil(t, got) + require.Len(t, got.Results, 2) + + // check if the correct keys were found by comparing hashes + var hashes []string + for _, key := range got.Results { + u, err := uri.ParseWithScheme(Scheme, key.Name) + require.NoError(t, err) + assert.Equal(t, tag, u.Get("tag")) + if hash := u.Get("hash"); hash != "" { + hashes = append(hashes, hash) + } + + } + + assert.Equal(t, expectedHashes, hashes) +}