diff --git a/builtin/credential/cert/backend_test.go b/builtin/credential/cert/backend_test.go index 9ad940cda..db400dab7 100644 --- a/builtin/credential/cert/backend_test.go +++ b/builtin/credential/cert/backend_test.go @@ -1107,6 +1107,10 @@ func TestBackend_ext_singleCert(t *testing.T) { testAccStepLoginInvalid(t, connState), testAccStepCert(t, "web", ca, "foo", allowed{names: "invalid", ext: "2.1.1.1:*,2.1.1.2:The Wrong Value"}, false), testAccStepLoginInvalid(t, connState), + testAccStepCert(t, "web", ca, "foo", allowed{metadata_ext: "2.1.1.1,1.2.3.45"}, false), + testAccStepLoginWithMetadata(t, connState, "web", map[string]string{"2-1-1-1": "A UTF8String Extension"}), + testAccStepCert(t, "web", ca, "foo", allowed{metadata_ext: "1.2.3.45"}, false), + testAccStepLoginWithMetadata(t, connState, "web", map[string]string{}), }, }) } @@ -1556,6 +1560,40 @@ func testAccStepLoginDefaultLease(t *testing.T, connState tls.ConnectionState) l } } +func testAccStepLoginWithMetadata(t *testing.T, connState tls.ConnectionState, certName string, metadata map[string]string) logicaltest.TestStep { + return logicaltest.TestStep{ + Operation: logical.UpdateOperation, + Path: "login", + Unauthenticated: true, + ConnState: &connState, + Check: func(resp *logical.Response) error { + // Check for fixed metadata too + metadata["cert_name"] = certName + metadata["common_name"] = connState.PeerCertificates[0].Subject.CommonName + metadata["serial_number"] = connState.PeerCertificates[0].SerialNumber.String() + metadata["subject_key_id"] = certutil.GetHexFormatted(connState.PeerCertificates[0].SubjectKeyId, ":") + metadata["authority_key_id"] = certutil.GetHexFormatted(connState.PeerCertificates[0].AuthorityKeyId, ":") + + for key, expected := range metadata { + value, ok := resp.Auth.Metadata[key] + if !ok { + t.Fatalf("missing metadata key: %s", key) + } + + if value != expected { + t.Fatalf("expected metadata key %s to equal %s, but got: %s", key, expected, value) + } + } + + fn := logicaltest.TestCheckAuth([]string{"default", "foo"}) + return fn(resp) + }, + Data: map[string]interface{}{ + "metadata": metadata, + }, + } +} + func testAccStepLoginInvalid(t *testing.T, connState tls.ConnectionState) logicaltest.TestStep { return testAccStepLoginWithNameInvalid(t, connState, "") } @@ -1633,6 +1671,7 @@ type allowed struct { uris string // allowed uris in SAN extension of the certificate organizational_units string // allowed OUs in the certificate ext string // required extensions in the certificate + metadata_ext string // allowed metadata extensions to add to identity alias } func testAccStepCert( @@ -1652,6 +1691,7 @@ func testAccStepCert( "allowed_uri_sans": testData.uris, "allowed_organizational_units": testData.organizational_units, "required_extensions": testData.ext, + "allowed_metadata_extensions": testData.metadata_ext, "lease": 1000, }, Check: func(resp *logical.Response) error { diff --git a/builtin/credential/cert/path_certs.go b/builtin/credential/cert/path_certs.go index ca2258423..00e103b51 100644 --- a/builtin/credential/cert/path_certs.go +++ b/builtin/credential/cert/path_certs.go @@ -114,6 +114,14 @@ formatted as "oid:value". Expects the extension value to be some type of ASN1 en All values much match. Supports globbing on "value".`, }, + "allowed_metadata_extensions": { + Type: framework.TypeCommaStringSlice, + Description: `A comma-separated string or array of oid extensions. +Upon successfull authentication, these extensions will be added as metadata if they are present +in the certificate. The metadata key will be the string consisting of the oid numbers +separated by a dash (-) instead of a dot (.) to allow usage in ACL templates.`, + }, + "display_name": { Type: framework.TypeString, Description: `The display name to use for clients using this @@ -243,6 +251,7 @@ func (b *backend) pathCertRead(ctx context.Context, req *logical.Request, d *fra "allowed_uri_sans": cert.AllowedURISANs, "allowed_organizational_units": cert.AllowedOrganizationalUnits, "required_extensions": cert.RequiredExtensions, + "allowed_metadata_extensions": cert.AllowedMetadataExtensions, } cert.PopulateTokenData(data) @@ -309,6 +318,9 @@ func (b *backend) pathCertWrite(ctx context.Context, req *logical.Request, d *fr if requiredExtensionsRaw, ok := d.GetOk("required_extensions"); ok { cert.RequiredExtensions = requiredExtensionsRaw.([]string) } + if allowedMetadataExtensionsRaw, ok := d.GetOk("allowed_metadata_extensions"); ok { + cert.AllowedMetadataExtensions = allowedMetadataExtensionsRaw.([]string) + } // Get tokenutil fields if err := cert.ParseTokenFields(req, d); err != nil { @@ -424,6 +436,7 @@ type CertEntry struct { AllowedURISANs []string AllowedOrganizationalUnits []string RequiredExtensions []string + AllowedMetadataExtensions []string BoundCIDRs []*sockaddr.SockAddrMarshaler } diff --git a/builtin/credential/cert/path_login.go b/builtin/credential/cert/path_login.go index d9f5bb5e7..e458b3fda 100644 --- a/builtin/credential/cert/path_login.go +++ b/builtin/credential/cert/path_login.go @@ -89,19 +89,27 @@ func (b *backend) pathLogin(ctx context.Context, req *logical.Request, data *fra skid := base64.StdEncoding.EncodeToString(clientCerts[0].SubjectKeyId) akid := base64.StdEncoding.EncodeToString(clientCerts[0].AuthorityKeyId) + metadata := map[string]string{ + "cert_name": matched.Entry.Name, + "common_name": clientCerts[0].Subject.CommonName, + "serial_number": clientCerts[0].SerialNumber.String(), + "subject_key_id": certutil.GetHexFormatted(clientCerts[0].SubjectKeyId, ":"), + "authority_key_id": certutil.GetHexFormatted(clientCerts[0].AuthorityKeyId, ":"), + } + + // Add metadata from allowed_metadata_extensions when present, + // with sanitized oids (dash-separated instead of dot-separated) as keys. + for k, v := range b.certificateExtensionsMetadata(clientCerts[0], matched) { + metadata[k] = v + } + auth := &logical.Auth{ InternalData: map[string]interface{}{ "subject_key_id": skid, "authority_key_id": akid, }, DisplayName: matched.Entry.DisplayName, - Metadata: map[string]string{ - "cert_name": matched.Entry.Name, - "common_name": clientCerts[0].Subject.CommonName, - "serial_number": clientCerts[0].SerialNumber.String(), - "subject_key_id": certutil.GetHexFormatted(clientCerts[0].SubjectKeyId, ":"), - "authority_key_id": certutil.GetHexFormatted(clientCerts[0].AuthorityKeyId, ":"), - }, + Metadata: metadata, Alias: &logical.Alias{ Name: clientCerts[0].Subject.CommonName, }, @@ -409,6 +417,38 @@ func (b *backend) matchesCertificateExtensions(clientCert *x509.Certificate, con return true } +// certificateExtensionsMetadata returns the metadata from configured +// metadata extensions +func (b *backend) certificateExtensionsMetadata(clientCert *x509.Certificate, config *ParsedCert) map[string]string { + // If no metadata extensions are configured, return an empty map + if len(config.Entry.AllowedMetadataExtensions) == 0 { + return map[string]string{} + } + + // Build a map with the accepted oid strings as keys, and the metadata keys as values. + allowedOidMap := make(map[string]string, len(config.Entry.AllowedMetadataExtensions)) + for _, oidString := range config.Entry.AllowedMetadataExtensions { + // Avoid dots in metadata keys and put dashes instead, + // to allow use policy templates. + allowedOidMap[oidString] = strings.ReplaceAll(oidString, ".", "-") + } + + // Collect the metadata from accepted certificate extensions. + metadata := make(map[string]string, len(config.Entry.AllowedMetadataExtensions)) + for _, ext := range clientCert.Extensions { + if metadataKey, ok := allowedOidMap[ext.Id.String()]; ok { + // x509 Writes Extensions in ASN1 with a bitstring tag, which results in the field + // including its ASN.1 type tag bytes. For the sake of simplicity, assume string type + // and drop the tag bytes. And get the number of bytes from the tag. + var parsedValue string + asn1.Unmarshal(ext.Value, &parsedValue) + metadata[metadataKey] = parsedValue + } + } + + return metadata +} + // loadTrustedCerts is used to load all the trusted certificates from the backend func (b *backend) loadTrustedCerts(ctx context.Context, storage logical.Storage, certName string) (pool *x509.CertPool, trusted []*ParsedCert, trustedNonCAs []*ParsedCert) { pool = x509.NewCertPool() diff --git a/changelog/13348.txt b/changelog/13348.txt new file mode 100644 index 000000000..f7ca0c9e3 --- /dev/null +++ b/changelog/13348.txt @@ -0,0 +1,3 @@ +```release-note:improvement +auth/cert: Add certificate extensions as metadata +```