Skip to content
Draft
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
183 changes: 162 additions & 21 deletions kms/mackms/mackms.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,63 @@ type certAttributes struct {
serialNumber *big.Int
keychain string
useDataProtectionKeychain bool

// Subject distinguished name components (cn, o, ou, l, st, c) used to
// filter certificates after retrieving them from the keychain. The
// keychain cannot match individual subject components, and it stores some
// of the subject values in uppercase.
commonName string
organization []string
organizationalUnit []string
locality []string
province []string
country []string
}

// hasSubjectQuery reports whether at least one subject distinguished name
// component (cn, o, ou, l, st, c) is set.
func (u *certAttributes) hasSubjectQuery() bool {
return u.commonName != "" || len(u.organization) > 0 ||
len(u.organizationalUnit) > 0 || len(u.locality) > 0 ||
len(u.province) > 0 || len(u.country) > 0
}

// matchesSubject reports whether the certificate subject contains all the
// subject distinguished name components in u. Comparisons are exact and
// case-sensitive; for multi-valued components every requested value must be
// present in the certificate subject. It returns true if no components are
// set.
func (u *certAttributes) matchesSubject(cert *x509.Certificate) bool {
if u.commonName != "" && cert.Subject.CommonName != u.commonName {
return false
}
return containsAll(cert.Subject.Organization, u.organization) &&
containsAll(cert.Subject.OrganizationalUnit, u.organizationalUnit) &&
containsAll(cert.Subject.Locality, u.locality) &&
containsAll(cert.Subject.Province, u.province) &&
containsAll(cert.Subject.Country, u.country)
}

// containsAll reports whether values contains all the elements in want. It
// returns true if want is empty.
func containsAll(values, want []string) bool {
for _, w := range want {
if !slices.Contains(values, w) {
return false
}
}
return true
}

// nonEmptyValues returns the values in vs that are not the empty string.
func nonEmptyValues(vs []string) []string {
var values []string
for _, v := range vs {
if v != "" {
values = append(values, v)
}
}
return values
}

type algorithmAttributes struct {
Expand Down Expand Up @@ -376,16 +433,31 @@ func (k *MacKMS) CreateSigner(req *apiv1.CreateSignerRequest) (crypto.Signer, er
}, nil
}

// LoadCertificate returns an [x509.Certificate] by its label and/or serial
// number. If multiple certificates match, it will return the certificate with
// the most recent NotBefore date among those that have already become valid.
// Future certificates maintain their original order. Expiration is not checked.
// By default Apple Keychain will use the certificate common name as the label.
// LoadCertificate returns an [x509.Certificate] by its label, serial number,
// and/or subject distinguished name components. If multiple certificates
// match, it will return the certificate with the most recent NotBefore date
// among those that have already become valid. Future certificates maintain
// their original order. Expiration is not checked. By default Apple Keychain
// will use the certificate common name as the label.
//
// Valid names (URIs) are:
// - mackms:label=test@example.com
// - mackms:serial=2c273934eda8454d2595a94497e2395a
// - mackms:label=test@example.com;serial=2c273934eda8454d2595a94497e2395a
// - mackms:cn=My+Cert
// - mackms:cn=My+Cert;o=Smallstep;ou=Engineering;ou=Platform
// - mackms:label=test@example.com;ou=Engineering
//
// The "cn" (common name), "o" (organization), "ou" (organizational unit), "l"
// (locality), "st" (state or province), and "c" (country) attributes select
// certificates by their subject. All the given components must match using
// exact, case-sensitive comparison. The "cn" attribute is single-valued; the
// others can be repeated (e.g. "ou=a;ou=b"), and every given value must be
// present in the certificate subject. Values must be URL-encoded; spaces are
// encoded as "+" or "%20". Subject components can be combined with "label" and
// "serial", which narrow the keychain query. Note that a URI with just an
// attribute name and no value (e.g. "mackms:cn") is the shorthand for a label
// with that name, so subject components always require a value.
//
// On code-signed applications, it is possible to use the Data Protection
// Keychain by default if [UseDataProtectionKeychain] is set to true. You can
Expand All @@ -399,7 +471,7 @@ func (k *MacKMS) LoadCertificate(req *apiv1.LoadCertificateRequest) (*x509.Certi
return nil, fmt.Errorf("loadCertificateRequest 'name' cannot be empty")
}

// Require label or serial
// Require label, serial, or a subject component
u, err := parseCertURI(req.Name, k.useDataProtectionKeychain, true)
if err != nil {
return nil, fmt.Errorf("mackms LoadCertificate failed: %w", err)
Expand All @@ -424,6 +496,9 @@ func (k *MacKMS) LoadCertificate(req *apiv1.LoadCertificateRequest) (*x509.Certi
// - "mackms:label=my-label" will use "my-label"
// - "mackms:my-label" will use the "my-label"
//
// The "serial" attribute and the subject components ("cn", "o", "ou", "l",
// "st", "c") are ignored when storing a certificate.
//
// On code-signed applications, it is possible to use the Data Protection
// Keychain by default if [UseDataProtectionKeychain] is set to true. You can
// also select the keychain using the "keychain" attribute:
Expand Down Expand Up @@ -464,23 +539,29 @@ func (k *MacKMS) StoreCertificate(req *apiv1.StoreCertificateRequest) error {
return nil
}

// LoadCertificateChain returns the leaf [x509.Certificate] by its label and/or
// serial number and its intermediate certificates. If multiple certificates
// match, it will return the certificate with the most recent NotBefore date
// among those that have already become valid. Future certificates maintain
// their original order. Expiration is not checked. By default Apple Keychain
// will use the certificate common name as the label.
// LoadCertificateChain returns the leaf [x509.Certificate] by its label,
// serial number, and/or subject distinguished name components, together with
// its intermediate certificates. If multiple certificates match, it will
// return the certificate with the most recent NotBefore date among those that
// have already become valid. Future certificates maintain their original
// order. Expiration is not checked. By default Apple Keychain will use the
// certificate common name as the label.
//
// Valid names (URIs) are:
// - mackms:label=test@example.com
// - mackms:serial=2c273934eda8454d2595a94497e2395a
// - mackms:label=test@example.com;serial=2c273934eda8454d2595a94497e2395a
// - mackms:cn=My+Cert;ou=Engineering
//
// Subject components select the leaf certificate only; intermediates are
// located using the authority key identifier. See [MacKMS.LoadCertificate] for
// the subject component matching rules.
func (k *MacKMS) LoadCertificateChain(req *apiv1.LoadCertificateChainRequest) ([]*x509.Certificate, error) {
if req.Name == "" {
return nil, fmt.Errorf("loadCertificateChainRequest 'name' cannot be empty")
}

// Require label or serial
// Require label, serial, or a subject component
u, err := parseCertURI(req.Name, k.useDataProtectionKeychain, true)
if err != nil {
return nil, fmt.Errorf("mackms LoadCertificateChain failed: %w", err)
Expand Down Expand Up @@ -617,7 +698,18 @@ func (*MacKMS) DeleteKey(req *apiv1.DeleteKeyRequest) error {
}

// DeleteCertificate deletes the certificate referenced by the URI in the
// request name.
// request name. It deletes at most one certificate.
//
// Valid names (URIs) are:
// - mackms:label=test@example.com
// - mackms:serial=2c273934eda8454d2595a94497e2395a
// - mackms:label=test@example.com;serial=2c273934eda8454d2595a94497e2395a
// - mackms:cn=My+Cert;ou=Engineering
//
// When subject components ("cn", "o", "ou", "l", "st", "c") are present, the
// certificate is first located using the same selection rules as
// [MacKMS.LoadCertificate], and then deleted by its serial number and, when
// available, its subject key identifier.
//
// # Experimental
//
Expand All @@ -637,6 +729,27 @@ func (k *MacKMS) DeleteCertificate(req *apiv1.DeleteCertificateRequest) error {
security.KSecClass: security.KSecClassCertificate,
security.KSecMatchLimit: security.KSecMatchLimitOne,
}

serialNumber := u.serialNumber
if u.hasSubjectQuery() {
// The keychain cannot match individual subject components. Find the
// matching certificate first, using the same selection rules as
// LoadCertificate, and delete it by its serial number and, when
// available, its subject key identifier.
cert, err := loadCertificate(u, nil)
if err != nil {
return fmt.Errorf("mackms DeleteCertificate failed: %w", apiv1Error(err))
}
serialNumber = cert.SerialNumber
if len(cert.SubjectKeyId) > 0 {
cfSubjectKeyID, err := cf.NewData(cert.SubjectKeyId)
if err != nil {
return fmt.Errorf("mackms DeleteCertificate failed: %w", err)
}
defer cfSubjectKeyID.Release()
query[security.KSecAttrSubjectKeyID] = cfSubjectKeyID
}
}
if u.label != "" {
cfLabel, err := cf.NewString(u.label)
if err != nil {
Expand All @@ -645,8 +758,8 @@ func (k *MacKMS) DeleteCertificate(req *apiv1.DeleteCertificateRequest) error {
defer cfLabel.Release()
query[security.KSecAttrLabel] = cfLabel
}
if u.serialNumber != nil {
cfSerial, err := cf.NewData(encodeSerialNumber(u.serialNumber))
if serialNumber != nil {
cfSerial, err := cf.NewData(encodeSerialNumber(serialNumber))
if err != nil {
return fmt.Errorf("mackms DeleteCertificate failed: %w", err)
}
Expand Down Expand Up @@ -1090,6 +1203,22 @@ func loadCertificate(u *certAttributes, subjectKeyID []byte) (*x509.Certificate,
return nil, err
}
}

// Filter the certificates by the subject distinguished name components
// (cn, o, ou, l, st, c) in the uri. The keychain cannot match individual
// subject components, and it normalizes the subject values it stores, so
// the parsed certificates are filtered instead.
if u.hasSubjectQuery() {
certs = slices.DeleteFunc(certs, func(cert *x509.Certificate) bool {
return !u.matchesSubject(cert)
})
if len(certs) == 0 {
return nil, apiv1.NotFoundError{
Message: "certificate not found",
}
}
}

if len(certs) == 1 {
return certs[0], nil
}
Expand Down Expand Up @@ -1283,16 +1412,28 @@ func parseCertURI(rawuri string, useDataProtectionKeychain, requireValue bool) (
if err != nil {
return nil, fmt.Errorf("error parsing %q: %w", rawuri, err)
}
if requireValue && label == "" && serialNumber == nil {
return nil, fmt.Errorf("error parsing %q: label or serial is required", rawuri)
}

return &certAttributes{
// Subject distinguished name components (cn, o, ou, l, st, c) are used to
// filter the certificates retrieved from the keychain. Empty values are
// ignored, and o, ou, l, st, and c can be repeated (e.g. "ou=a;ou=b").
values := uri.Values(u)
attrs := &certAttributes{
label: label,
serialNumber: serialNumber,
useDataProtectionKeychain: isDataProtectionKeychain(keychain, useDataProtectionKeychain),
keychain: keychain,
}, nil
commonName: u.Get("cn"),
organization: nonEmptyValues(values["o"]),
organizationalUnit: nonEmptyValues(values["ou"]),
locality: nonEmptyValues(values["l"]),
province: nonEmptyValues(values["st"]),
country: nonEmptyValues(values["c"]),
}
if requireValue && label == "" && serialNumber == nil && !attrs.hasSubjectQuery() {
return nil, fmt.Errorf("error parsing %q: label, serial, or a subject component (cn, o, ou, l, st, c) is required", rawuri)
}

return attrs, nil
}

func parseSearchURI(rawuri string) (*keySearchAttributes, error) {
Expand Down
Loading