diff --git a/README.md b/README.md index a967da0..b4c29db 100644 --- a/README.md +++ b/README.md @@ -290,6 +290,49 @@ func main() { } ``` +## Credential Record + +The WebAuthn Level 3 specification describes the Credential Record which includes several required and optional elements +that you should store for. See [§ 4 Terminology](https://www.w3.org/TR/webauthn-3/#credential-record) for details. + +This section describes this element. + +The fields listed in the specification have corresponding fields in the [webauthn.Credential] struct. See the below +table for more information. We also include JSON mappings for those that wish to just store these values as JSON. + +| Specification Field | Library Field | JSON Field | Notes | +|:-------------------------:|:--------------------------:|:--------------------------:|:-----------------------------------------------------------------------------------------:| +| type | N/A | N/A | This field is always `publicKey` for WebAuthn | +| id | ID | id | | +| publicKey | PublicKey | publicKey | | +| signCount | Authenticator.SignCount | authenticator.signCount | | +| transports | Transport | transport | | +| uvInitialized | Flags.UserVerified | flags.userVerified | | +| backupEligible | Flags.BackupEligible | flags.backupEligible | | +| backupState | Flags.BackupState | flags.backupState | | +| attestationObject | Attestation.Object | attestation.object | This field is a composite of the attestationObject and the relevant values to validate it | +| attestationClientDataJSON | Attestation.ClientDataJSON | attestation.clientDataJSON | | + +### Storage + +It is also important to note that restoring the [webauthn.Credential] with the correct values will likely affect the +validity of the [webauthn.Credential], i.e. if some values are not restored the [webauthn.Credential] may fail +validation in this scenario. + +### Verification + +As long as the [webauthn.Credential] struct has exactly the same values when restored the [Credential Verify] function +can be leveraged to verify the credential against the [metadata.Provider]. This can be either done during registration, +on every login, or with a audit schedule. + +In addition to using the [Credential Verify] function the +[webauthn.Config](https://pkg.go.dev/github.com/go-webauthn/webauthn/webauthn#Config) can contain a provider which will +process all registrations automatically. + +At this time no tooling exists to verify the credential automatically outside the registration flow. Implementation of +this is considered domain logic and beyond the scope of what we provide documentation for; we just provide the necessary +tooling to implement this yourself. + ## Acknowledgements We graciously acknowledge the original authors of this library [github.com/duo-labs/webauthn] for their amazing @@ -297,3 +340,6 @@ implementation. Without their amazing work this library could not exist. [github.com/duo-labs/webauthn]: https://github.com/duo-labs/webauthn +[webauthn.Credential]: https://pkg.go.dev/github.com/go-webauthn/webauthn/webauthn#Credential +[metadata.Provider]: https://pkg.go.dev/github.com/go-webauthn/webauthn/metadata#Provider +[Credential Verify]: https://pkg.go.dev/github.com/go-webauthn/webauthn/webauthn#Credential.Verify \ No newline at end of file diff --git a/metadata/const.go b/metadata/const.go new file mode 100644 index 0000000..97a5ad4 --- /dev/null +++ b/metadata/const.go @@ -0,0 +1,35 @@ +package metadata + +const ( + // https://secure.globalsign.com/cacert/root-r3.crt + ProductionMDSRoot = "MIIDXzCCAkegAwIBAgILBAAAAAABIVhTCKIwDQYJKoZIhvcNAQELBQAwTDEgMB4GA1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjMxEzARBgNVBAoTCkdsb2JhbFNpZ24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDkwMzE4MTAwMDAwWhcNMjkwMzE4MTAwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMzETMBEGA1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMwldpB5BngiFvXAg7aEyiie/QV2EcWtiHL8RgJDx7KKnQRfJMsuS+FggkbhUqsMgUdwbN1k0ev1LKMPgj0MK66X17YUhhB5uzsTgHeMCOFJ0mpiLx9e+pZo34knlTifBtc+ycsmWQ1z3rDI6SYOgxXG71uL0gRgykmmKPZpO/bLyCiR5Z2KYVc3rHQU3HTgOu5yLy6c+9C7v/U9AOEGM+iCK65TpjoWc4zdQQ4gOsC0p6Hpsk+QLjJg6VfLuQSSaGjlOCZgdbKfd/+RFO+uIEn8rUAVSNECMWEZXriX7613t2Saer9fwRPvm2L7DWzgVGkWqQPabumDk3F2xmmFghcCAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFI/wS3+oLkUkrk1Q+mOai97i3Ru8MA0GCSqGSIb3DQEBCwUAA4IBAQBLQNvAUKr+yAzv95ZURUm7lgAJQayzE4aGKAczymvmdLm6AC2upArT9fHxD4q/c2dKg8dEe3jgr25sbwMpjjM5RcOO5LlXbKr8EpbsU8Yt5CRsuZRj+9xTaGdWPoO4zzUhw8lo/s7awlOqzJCK6fBdRoyV3XpYKBovHd7NADdBj+1EbddTKJd+82cEHhXXipa0095MJ6RMG3NzdvQXmcIfeg7jLQitChws/zyrVQ4PkX4268NXSb7hLi18YIvDQVETI53O9zJrlAGomecsMx86OyXShkDOOyyGeMlhLxS67ttVb9+E7gUJTb0o2HLO02JQZR7rkpeDMdmztcpHWD9f" + + // Production MDS URL + ProductionMDSURL = "https://mds.fidoalliance.org" + + // https://mds3.fido.tools/pki/MDS3ROOT.crt + ConformanceMDSRoot = "MIICaDCCAe6gAwIBAgIPBCqih0DiJLW7+UHXx/o1MAoGCCqGSM49BAMDMGcxCzAJBgNVBAYTAlVTMRYwFAYDVQQKDA1GSURPIEFsbGlhbmNlMScwJQYDVQQLDB5GQUtFIE1ldGFkYXRhIDMgQkxPQiBST09UIEZBS0UxFzAVBgNVBAMMDkZBS0UgUm9vdCBGQUtFMB4XDTE3MDIwMTAwMDAwMFoXDTQ1MDEzMTIzNTk1OVowZzELMAkGA1UEBhMCVVMxFjAUBgNVBAoMDUZJRE8gQWxsaWFuY2UxJzAlBgNVBAsMHkZBS0UgTWV0YWRhdGEgMyBCTE9CIFJPT1QgRkFLRTEXMBUGA1UEAwwORkFLRSBSb290IEZBS0UwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAASKYiz3YltC6+lmxhPKwA1WFZlIqnX8yL5RybSLTKFAPEQeTD9O6mOz+tg8wcSdnVxHzwnXiQKJwhrav70rKc2ierQi/4QUrdsPes8TEirZOkCVJurpDFbXZOgs++pa4XmjYDBeMAsGA1UdDwQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBQGcfeCs0Y8D+lh6U5B2xSrR74eHTAfBgNVHSMEGDAWgBQGcfeCs0Y8D+lh6U5B2xSrR74eHTAKBggqhkjOPQQDAwNoADBlAjEA/xFsgri0xubSa3y3v5ormpPqCwfqn9s0MLBAtzCIgxQ/zkzPKctkiwoPtDzI51KnAjAmeMygX2S5Ht8+e+EQnezLJBJXtnkRWY+Zt491wgt/AwSs5PHHMv5QgjELOuMxQBc=" + + // Example from https://fidoalliance.org/specs/mds/fido-metadata-service-v3.0-ps-20210518.html + ExampleMDSRoot = "MIIGGTCCBAGgAwIBAgIUdT9qLX0sVMRe8l0sLmHd3mZovQ0wDQYJKoZIhvcNAQELBQAwgZsxHzAdBgNVBAMMFkVYQU1QTEUgTURTMyBURVNUIFJPT1QxIjAgBgkqhkiG9w0BCQEWE2V4YW1wbGVAZXhhbXBsZS5jb20xFDASBgNVBAoMC0V4YW1wbGUgT1JHMRAwDgYDVQQLDAdFeGFtcGxlMQswCQYDVQQGEwJVUzELMAkGA1UECAwCTVkxEjAQBgNVBAcMCVdha2VmaWVsZDAeFw0yMTA0MTkxMTM1MDdaFw00ODA5MDQxMTM1MDdaMIGbMR8wHQYDVQQDDBZFWEFNUExFIE1EUzMgVEVTVCBST09UMSIwIAYJKoZIhvcNAQkBFhNleGFtcGxlQGV4YW1wbGUuY29tMRQwEgYDVQQKDAtFeGFtcGxlIE9SRzEQMA4GA1UECwwHRXhhbXBsZTELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk1ZMRIwEAYDVQQHDAlXYWtlZmllbGQwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDDjF5wyEWuhwDHsZosGdGFTCcI677rW881vV+UfW38J+K2ioFFNeGVsxbcebK6AVOiCDPFj0974IpeD9SFOhwAHoDu/LCfXdQWp8ZgQ91ULYWoW8o7NNSp01nbN9zmaO6/xKNCa0bzjmXoGqglqnP1AtRcWYvXOSKZy1rcPeDv4Dhcpdp6W72fBw0eWIqOhsrItuY2/N8ItBPiG03EX72nACq4nZJ/nAIcUbER8STSFPPzvE97TvShsi1FD8aO6l1WkR/QkreAGjMI++GbB2Qc1nN9Y/VEDbMDhQtxXQRdpFwubTjejkN9hKOtF3B71YrwIrng3V9RoPMFdapWMzSlI+WWHog0oTj1PqwJDDg7+z1I6vSDeVWAMKr9mq1w1OGNzgBopIjd9lRWkRtt2kQSPX9XxqS4E1gDDr8MKbpM3JuubQtNCg9D7Ljvbz6vwvUrbPHH+oREvucsp0PZ5PpizloepGIcLFxDQqCulGY2n7Ahl0JOFXJqOFCaK3TWHwBvZsaY5DgBuUvdUrwtgZNg2eg2omWXEepiVFQn3Fvj43Wh2npPMgIe5P0rwncXvROxaczd4rtajKS1ucoB9b9iKqM2+M1y/FDIgVf1fWEHwK7YdzxMlgOeLdeV/kqRU5PEUlLU9a2EwdOErrPbPKZmIfbs/L4B3k4zejMDH3Y+ZwIDAQABo1MwUTAdBgNVHQ4EFgQU8sWwq1TrurK7xMTwO1dKfeJBbCMwHwYDVR0jBBgwFoAU8sWwq1TrurK7xMTwO1dKfeJBbCMwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAgEAFw6M1PiIfCPIBQ5EBUPNmRvRFuDpolOmDofnf/+mv63LqwQZAdo/W8tzZ9kOFhq24SiLw0H7fsdG/jeREXiIZMNoW/rA6Uac8sU+FYF7Q+qp6CQLlSQbDcpVMifTQjcBk2xh+aLK9SrrXBqnTAhwS+offGtAW8DpoLuH4tAcQmIjlgMlN65jnELCuqNR/wpA+zch8LZW8saQ2cwRCwdr8mAzZoLbsDSVCHxQF3/kQjPT7Nao1q2iWcY3OYcRmKrieHDP67yeLUbVmetfZis2d6ZlkqHLB4ZW1xX4otsEFkuTJA3HWDRsNyhTwx1YoCLsYut5Zp0myqPNBq28w6qGMyyoJN0Z4RzMEO3R6i/MQNfhK55/8O2HciM6xb5t/aBSuHPKlBDrFWhpRnKYkaNtlUo35qV5IbKGKau3SdZdSRciaXUd/p81YmoF01UlhhMz/Rqr1k2gyA0a9tF8+awCeanYt5izl8YO0FlrOU1SQ5UQw4szqqZqbrf4e8fRuU2TXNx4zk+ImE7WRB44f6mSD746ZCBRogZ/SA5jUBu+OPe4/sEtERWRcQD+fXgce9ZEN0+peyJIKAsl5Rm2Bmgyg5IoyWwSG5W+WekGyEokpslou2Yc6EjUj5ndZWz5EiHAiQ74hNfDoCZIxVVLU3Qbp8a0S1bmsoT2JOsspIbtZUg=" +) + +const ( + HeaderX509URI = "x5u" + HeaderX509Certificate = "x5c" +) + +var ( + errIntermediateCertRevoked = &MetadataError{ + Type: "intermediate_revoked", + Details: "Intermediate certificate is on issuers revocation list", + } + errLeafCertRevoked = &MetadataError{ + Type: "leaf_revoked", + Details: "Leaf certificate is on issuers revocation list", + } + errCRLUnavailable = &MetadataError{ + Type: "crl_unavailable", + Details: "Certificate revocation list is unavailable", + } +) diff --git a/metadata/decode.go b/metadata/decode.go new file mode 100644 index 0000000..f3c3de7 --- /dev/null +++ b/metadata/decode.go @@ -0,0 +1,276 @@ +package metadata + +import ( + "crypto/x509" + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/go-webauthn/x/revoke" + "github.com/golang-jwt/jwt/v5" + "github.com/mitchellh/mapstructure" +) + +// NewDecoder returns a new metadata decoder. +func NewDecoder(opts ...DecoderOption) (decoder *Decoder, err error) { + decoder = &Decoder{ + client: &http.Client{}, + parser: jwt.NewParser(), + hook: mapstructure.ComposeDecodeHookFunc(), + } + + for _, opt := range opts { + if err = opt(decoder); err != nil { + return nil, fmt.Errorf("failed to apply decoder option: %w", err) + } + } + + if decoder.root == "" { + decoder.root = ProductionMDSRoot + } + + return decoder, nil +} + +// Decoder handles decoding and specialized parsing of the metadata blob. +type Decoder struct { + client *http.Client + parser *jwt.Parser + hook mapstructure.DecodeHookFunc + root string + ignoreEntryParsingErrors bool +} + +// Parse handles parsing of the raw JSON values of the metadata blob. Should be used after using Decode or DecodeBytes. +func (d *Decoder) Parse(payload *PayloadJSON) (metadata *Metadata, err error) { + metadata = &Metadata{ + Parsed: Parsed{ + LegalHeader: payload.LegalHeader, + Number: payload.Number, + }, + } + + if metadata.Parsed.NextUpdate, err = time.Parse(time.DateOnly, payload.NextUpdate); err != nil { + return nil, fmt.Errorf("error occurred parsing next update value '%s': %w", payload.NextUpdate, err) + } + + var parsed Entry + + for _, entry := range payload.Entries { + if parsed, err = entry.Parse(); err != nil { + metadata.Unparsed = append(metadata.Unparsed, EntryError{ + Error: err, + EntryJSON: entry, + }) + + continue + } + + metadata.Parsed.Entries = append(metadata.Parsed.Entries, parsed) + } + + if n := len(metadata.Unparsed); n != 0 && !d.ignoreEntryParsingErrors { + return metadata, fmt.Errorf("error occurred parsing metadata: %d entries had errors during parsing", n) + } + + return metadata, nil +} + +// Decode the blob from an io.ReadCloser. This function will close the io.ReadCloser after completing. +func (d *Decoder) Decode(r io.ReadCloser) (payload *PayloadJSON, err error) { + defer r.Close() + + bytes, err := io.ReadAll(r) + if err != nil { + return nil, err + } + + return d.DecodeBytes(bytes) +} + +// DecodeBytes handles decoding raw bytes. If you have a read closer it's suggested to use Decode. +func (d *Decoder) DecodeBytes(bytes []byte) (payload *PayloadJSON, err error) { + var token *jwt.Token + + if token, err = d.parser.Parse(string(bytes), func(token *jwt.Token) (any, error) { + // 2. If the x5u attribute is present in the JWT Header, then + if _, ok := token.Header[HeaderX509URI].([]any); ok { + // never seen an x5u here, although it is in the spec + return nil, errors.New("x5u encountered in header of metadata TOC payload") + } + + // 3. If the x5u attribute is missing, the chain should be retrieved from the x5c attribute. + var ( + x5c, chain []any + ok, valid bool + ) + + if x5c, ok = token.Header[HeaderX509Certificate].([]any); !ok { + // If that attribute is missing as well, Metadata TOC signing trust anchor is considered the TOC signing certificate chain. + chain[0] = d.root + } else { + chain = x5c + } + + // The certificate chain MUST be verified to properly chain to the metadata TOC signing trust anchor. + if valid, err = validateChain(d.root, chain); !valid || err != nil { + return nil, err + } + + // Chain validated, extract the TOC signing certificate from the chain. Create a buffer large enough to hold the + // certificate bytes. + o := make([]byte, base64.StdEncoding.DecodedLen(len(chain[0].(string)))) + + var ( + n int + cert *x509.Certificate + ) + + // Decode the base64 certificate into the buffer. + if n, err = base64.StdEncoding.Decode(o, []byte(chain[0].(string))); err != nil { + return nil, err + } + + // Parse the certificate from the buffer. + if cert, err = x509.ParseCertificate(o[:n]); err != nil { + return nil, err + } + + // 4. Verify the signature of the Metadata TOC object using the TOC signing certificate chain + // jwt.Parse() uses the TOC signing certificate public key internally to verify the signature. + return cert.PublicKey, err + }); err != nil { + return nil, err + } + + var decoder *mapstructure.Decoder + + payload = &PayloadJSON{} + + if decoder, err = mapstructure.NewDecoder(&mapstructure.DecoderConfig{ + Metadata: nil, + Result: payload, + DecodeHook: d.hook, + TagName: "json", + }); err != nil { + return nil, err + } + + if err = decoder.Decode(token.Claims); err != nil { + return payload, err + } + + return payload, nil +} + +// DecoderOption is a representation of a function that can set options within a decoder. +type DecoderOption func(decoder *Decoder) (err error) + +// WithIgnoreEntryParsingErrors is a DecoderOption which ignores errors when parsing individual entries. The values for +// these entries will exist as an unparsed entry. +func WithIgnoreEntryParsingErrors() DecoderOption { + return func(decoder *Decoder) (err error) { + decoder.ignoreEntryParsingErrors = true + + return nil + } +} + +// WithRootCertificate overrides the root certificate used to validate the authenticity of the metadata payload. +func WithRootCertificate(value string) DecoderOption { + return func(decoder *Decoder) (err error) { + decoder.root = value + + return nil + } +} + +func validateChain(root string, chain []any) (bool, error) { + oRoot := make([]byte, base64.StdEncoding.DecodedLen(len(root))) + + nRoot, err := base64.StdEncoding.Decode(oRoot, []byte(root)) + if err != nil { + return false, err + } + + rootcert, err := x509.ParseCertificate(oRoot[:nRoot]) + if err != nil { + return false, err + } + + roots := x509.NewCertPool() + + roots.AddCert(rootcert) + + o := make([]byte, base64.StdEncoding.DecodedLen(len(chain[1].(string)))) + + n, err := base64.StdEncoding.Decode(o, []byte(chain[1].(string))) + if err != nil { + return false, err + } + + intcert, err := x509.ParseCertificate(o[:n]) + if err != nil { + return false, err + } + + if revoked, ok := revoke.VerifyCertificate(intcert); !ok { + issuer := intcert.IssuingCertificateURL + + if issuer != nil { + return false, errCRLUnavailable + } + } else if revoked { + return false, errIntermediateCertRevoked + } + + ints := x509.NewCertPool() + ints.AddCert(intcert) + + l := make([]byte, base64.StdEncoding.DecodedLen(len(chain[0].(string)))) + + n, err = base64.StdEncoding.Decode(l, []byte(chain[0].(string))) + if err != nil { + return false, err + } + + leafcert, err := x509.ParseCertificate(l[:n]) + if err != nil { + return false, err + } + + if revoked, ok := revoke.VerifyCertificate(leafcert); !ok { + return false, errCRLUnavailable + } else if revoked { + return false, errLeafCertRevoked + } + + opts := x509.VerifyOptions{ + Roots: roots, + Intermediates: ints, + } + + _, err = leafcert.Verify(opts) + + return err == nil, err +} + +func mdsParseX509Certificate(value string) (certificate *x509.Certificate, err error) { + var n int + + raw := make([]byte, base64.StdEncoding.DecodedLen(len(value))) + + if n, err = base64.StdEncoding.Decode(raw, []byte(strings.TrimSpace(value))); err != nil { + return nil, fmt.Errorf("error occurred parsing *x509.certificate: error occurred decoding base64 data: %w", err) + } + + if certificate, err = x509.ParseCertificate(raw[:n]); err != nil { + return nil, err + } + + return certificate, nil +} diff --git a/metadata/doc.go b/metadata/doc.go new file mode 100644 index 0000000..7db8c71 --- /dev/null +++ b/metadata/doc.go @@ -0,0 +1,2 @@ +// Package metadata handles metadata validation instrumentation. +package metadata diff --git a/metadata/metadata.go b/metadata/metadata.go index d97d809..0a43e68 100644 --- a/metadata/metadata.go +++ b/metadata/metadata.go @@ -2,533 +2,891 @@ package metadata import ( "crypto/x509" - "encoding/base64" - "errors" - "io" + "fmt" "net/http" - "reflect" + "net/url" + "strings" "time" - "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" - "github.com/mitchellh/mapstructure" +) - "github.com/go-webauthn/x/revoke" +// Fetch creates a new HTTP client and gets the production metadata, decodes it, and parses it. This is an +// instrumentation simplification that makes it easier to either just grab the latest metadata or for implementers to +// see the rough process of retrieving it to implement any of their own logic. +func Fetch() (metadata *Metadata, err error) { + var ( + decoder *Decoder + payload *PayloadJSON + res *http.Response + ) + + if decoder, err = NewDecoder(WithIgnoreEntryParsingErrors()); err != nil { + return nil, err + } - "github.com/go-webauthn/webauthn/protocol/webauthncose" -) + client := &http.Client{} + + if res, err = client.Get(ProductionMDSURL); err != nil { + return nil, err + } + + if payload, err = decoder.Decode(res.Body); err != nil { + return nil, err + } -type PublicKeyCredentialParameters struct { - Type string `json:"type"` - Alg webauthncose.COSEAlgorithmIdentifier `json:"alg"` + return decoder.Parse(payload) } -const ( - // https://secure.globalsign.com/cacert/root-r3.crt - ProductionMDSRoot = "MIIDXzCCAkegAwIBAgILBAAAAAABIVhTCKIwDQYJKoZIhvcNAQELBQAwTDEgMB4GA1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjMxEzARBgNVBAoTCkdsb2JhbFNpZ24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDkwMzE4MTAwMDAwWhcNMjkwMzE4MTAwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMzETMBEGA1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMwldpB5BngiFvXAg7aEyiie/QV2EcWtiHL8RgJDx7KKnQRfJMsuS+FggkbhUqsMgUdwbN1k0ev1LKMPgj0MK66X17YUhhB5uzsTgHeMCOFJ0mpiLx9e+pZo34knlTifBtc+ycsmWQ1z3rDI6SYOgxXG71uL0gRgykmmKPZpO/bLyCiR5Z2KYVc3rHQU3HTgOu5yLy6c+9C7v/U9AOEGM+iCK65TpjoWc4zdQQ4gOsC0p6Hpsk+QLjJg6VfLuQSSaGjlOCZgdbKfd/+RFO+uIEn8rUAVSNECMWEZXriX7613t2Saer9fwRPvm2L7DWzgVGkWqQPabumDk3F2xmmFghcCAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFI/wS3+oLkUkrk1Q+mOai97i3Ru8MA0GCSqGSIb3DQEBCwUAA4IBAQBLQNvAUKr+yAzv95ZURUm7lgAJQayzE4aGKAczymvmdLm6AC2upArT9fHxD4q/c2dKg8dEe3jgr25sbwMpjjM5RcOO5LlXbKr8EpbsU8Yt5CRsuZRj+9xTaGdWPoO4zzUhw8lo/s7awlOqzJCK6fBdRoyV3XpYKBovHd7NADdBj+1EbddTKJd+82cEHhXXipa0095MJ6RMG3NzdvQXmcIfeg7jLQitChws/zyrVQ4PkX4268NXSb7hLi18YIvDQVETI53O9zJrlAGomecsMx86OyXShkDOOyyGeMlhLxS67ttVb9+E7gUJTb0o2HLO02JQZR7rkpeDMdmztcpHWD9f" - // Production MDS URL - ProductionMDSURL = "https://mds.fidoalliance.org" - // https://mds3.fido.tools/pki/MDS3ROOT.crt - ConformanceMDSRoot = "MIICaDCCAe6gAwIBAgIPBCqih0DiJLW7+UHXx/o1MAoGCCqGSM49BAMDMGcxCzAJBgNVBAYTAlVTMRYwFAYDVQQKDA1GSURPIEFsbGlhbmNlMScwJQYDVQQLDB5GQUtFIE1ldGFkYXRhIDMgQkxPQiBST09UIEZBS0UxFzAVBgNVBAMMDkZBS0UgUm9vdCBGQUtFMB4XDTE3MDIwMTAwMDAwMFoXDTQ1MDEzMTIzNTk1OVowZzELMAkGA1UEBhMCVVMxFjAUBgNVBAoMDUZJRE8gQWxsaWFuY2UxJzAlBgNVBAsMHkZBS0UgTWV0YWRhdGEgMyBCTE9CIFJPT1QgRkFLRTEXMBUGA1UEAwwORkFLRSBSb290IEZBS0UwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAASKYiz3YltC6+lmxhPKwA1WFZlIqnX8yL5RybSLTKFAPEQeTD9O6mOz+tg8wcSdnVxHzwnXiQKJwhrav70rKc2ierQi/4QUrdsPes8TEirZOkCVJurpDFbXZOgs++pa4XmjYDBeMAsGA1UdDwQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBQGcfeCs0Y8D+lh6U5B2xSrR74eHTAfBgNVHSMEGDAWgBQGcfeCs0Y8D+lh6U5B2xSrR74eHTAKBggqhkjOPQQDAwNoADBlAjEA/xFsgri0xubSa3y3v5ormpPqCwfqn9s0MLBAtzCIgxQ/zkzPKctkiwoPtDzI51KnAjAmeMygX2S5Ht8+e+EQnezLJBJXtnkRWY+Zt491wgt/AwSs5PHHMv5QgjELOuMxQBc=" - // Example from https://fidoalliance.org/specs/mds/fido-metadata-service-v3.0-ps-20210518.html - ExampleMDSRoot = "MIIGGTCCBAGgAwIBAgIUdT9qLX0sVMRe8l0sLmHd3mZovQ0wDQYJKoZIhvcNAQELBQAwgZsxHzAdBgNVBAMMFkVYQU1QTEUgTURTMyBURVNUIFJPT1QxIjAgBgkqhkiG9w0BCQEWE2V4YW1wbGVAZXhhbXBsZS5jb20xFDASBgNVBAoMC0V4YW1wbGUgT1JHMRAwDgYDVQQLDAdFeGFtcGxlMQswCQYDVQQGEwJVUzELMAkGA1UECAwCTVkxEjAQBgNVBAcMCVdha2VmaWVsZDAeFw0yMTA0MTkxMTM1MDdaFw00ODA5MDQxMTM1MDdaMIGbMR8wHQYDVQQDDBZFWEFNUExFIE1EUzMgVEVTVCBST09UMSIwIAYJKoZIhvcNAQkBFhNleGFtcGxlQGV4YW1wbGUuY29tMRQwEgYDVQQKDAtFeGFtcGxlIE9SRzEQMA4GA1UECwwHRXhhbXBsZTELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk1ZMRIwEAYDVQQHDAlXYWtlZmllbGQwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDDjF5wyEWuhwDHsZosGdGFTCcI677rW881vV+UfW38J+K2ioFFNeGVsxbcebK6AVOiCDPFj0974IpeD9SFOhwAHoDu/LCfXdQWp8ZgQ91ULYWoW8o7NNSp01nbN9zmaO6/xKNCa0bzjmXoGqglqnP1AtRcWYvXOSKZy1rcPeDv4Dhcpdp6W72fBw0eWIqOhsrItuY2/N8ItBPiG03EX72nACq4nZJ/nAIcUbER8STSFPPzvE97TvShsi1FD8aO6l1WkR/QkreAGjMI++GbB2Qc1nN9Y/VEDbMDhQtxXQRdpFwubTjejkN9hKOtF3B71YrwIrng3V9RoPMFdapWMzSlI+WWHog0oTj1PqwJDDg7+z1I6vSDeVWAMKr9mq1w1OGNzgBopIjd9lRWkRtt2kQSPX9XxqS4E1gDDr8MKbpM3JuubQtNCg9D7Ljvbz6vwvUrbPHH+oREvucsp0PZ5PpizloepGIcLFxDQqCulGY2n7Ahl0JOFXJqOFCaK3TWHwBvZsaY5DgBuUvdUrwtgZNg2eg2omWXEepiVFQn3Fvj43Wh2npPMgIe5P0rwncXvROxaczd4rtajKS1ucoB9b9iKqM2+M1y/FDIgVf1fWEHwK7YdzxMlgOeLdeV/kqRU5PEUlLU9a2EwdOErrPbPKZmIfbs/L4B3k4zejMDH3Y+ZwIDAQABo1MwUTAdBgNVHQ4EFgQU8sWwq1TrurK7xMTwO1dKfeJBbCMwHwYDVR0jBBgwFoAU8sWwq1TrurK7xMTwO1dKfeJBbCMwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAgEAFw6M1PiIfCPIBQ5EBUPNmRvRFuDpolOmDofnf/+mv63LqwQZAdo/W8tzZ9kOFhq24SiLw0H7fsdG/jeREXiIZMNoW/rA6Uac8sU+FYF7Q+qp6CQLlSQbDcpVMifTQjcBk2xh+aLK9SrrXBqnTAhwS+offGtAW8DpoLuH4tAcQmIjlgMlN65jnELCuqNR/wpA+zch8LZW8saQ2cwRCwdr8mAzZoLbsDSVCHxQF3/kQjPT7Nao1q2iWcY3OYcRmKrieHDP67yeLUbVmetfZis2d6ZlkqHLB4ZW1xX4otsEFkuTJA3HWDRsNyhTwx1YoCLsYut5Zp0myqPNBq28w6qGMyyoJN0Z4RzMEO3R6i/MQNfhK55/8O2HciM6xb5t/aBSuHPKlBDrFWhpRnKYkaNtlUo35qV5IbKGKau3SdZdSRciaXUd/p81YmoF01UlhhMz/Rqr1k2gyA0a9tF8+awCeanYt5izl8YO0FlrOU1SQ5UQw4szqqZqbrf4e8fRuU2TXNx4zk+ImE7WRB44f6mSD746ZCBRogZ/SA5jUBu+OPe4/sEtERWRcQD+fXgce9ZEN0+peyJIKAsl5Rm2Bmgyg5IoyWwSG5W+WekGyEokpslou2Yc6EjUj5ndZWz5EiHAiQ74hNfDoCZIxVVLU3Qbp8a0S1bmsoT2JOsspIbtZUg=" -) +type Metadata struct { + Parsed Parsed + Unparsed []EntryError +} + +func (m *Metadata) ToMap() (metadata map[uuid.UUID]*Entry) { + metadata = make(map[uuid.UUID]*Entry) + + for _, entry := range m.Parsed.Entries { + if entry.AaGUID != uuid.Nil { + metadata[entry.AaGUID] = &entry + } + } + + return metadata +} + +// Parsed is a structure representing the Parsed MDS3 dictionary. +// +// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.0-ps-20210518.html#metadata-blob-payload-entry-dictionary +type Parsed struct { + // The legalHeader, if present, contains a legal guide for accessing and using metadata, which itself MAY contain URL(s) pointing to further information, such as a full Terms and Conditions statement. + LegalHeader string + + // The serial number of this UAF Metadata TOC Payload. Serial numbers MUST be consecutive and strictly monotonic, i.e. the successor TOC will have a no value exactly incremented by one. + Number int + + // ISO-8601 formatted date when the next update will be provided at latest. + NextUpdate time.Time + + // List of zero or more MetadataTOCPayloadEntry objects. + Entries []Entry +} -// Metadata is a map of authenticator AAGUIDs to corresponding metadata statements -var Metadata = make(map[uuid.UUID]MetadataBLOBPayloadEntry) +// PayloadJSON is an intermediary JSON/JWT representation of the Parsed. +type PayloadJSON struct { + LegalHeader string `json:"legalHeader"` + Number int `json:"no"` + NextUpdate string `json:"nextUpdate"` + + Entries []EntryJSON `json:"entries"` +} + +func (j PayloadJSON) Parse() (payload Parsed, err error) { + var update time.Time + + if update, err = time.Parse(time.DateOnly, j.NextUpdate); err != nil { + return payload, fmt.Errorf("error occurred parsing next update value '%s': %w", j.NextUpdate, err) + } -// Conformance indicates if test metadata is currently being used -var Conformance = false + n := len(j.Entries) -var MDSRoot = ProductionMDSRoot + entries := make([]Entry, n) -// MetadataBLOBPayloadEntry - Represents the MetadataBLOBPayloadEntry -// https://fidoalliance.org/specs/mds/fido-metadata-service-v3.0-ps-20210518.html#metadata-blob-payload-entry-dictionary -type MetadataBLOBPayloadEntry struct { + for i := 0; i < n; i++ { + if entries[i], err = j.Entries[i].Parse(); err != nil { + return payload, fmt.Errorf("error occurred parsing entry %d: %w", i, err) + } + } + + return Parsed{ + LegalHeader: j.LegalHeader, + Number: j.Number, + NextUpdate: update, + Entries: entries, + }, nil +} + +// Entry is a structure representing the Entry MDS3 dictionary. +// +// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.0-ps-20210518.html#metadata-blob-payload-entry-dictionary +type Entry struct { // The Authenticator Attestation ID. Aaid string `json:"aaid"` + // The Authenticator Attestation GUID. - AaGUID string `json:"aaguid"` + AaGUID uuid.UUID `json:"aaguid"` + // A list of the attestation certificate public key identifiers encoded as hex string. AttestationCertificateKeyIdentifiers []string `json:"attestationCertificateKeyIdentifiers"` + // The metadataStatement JSON object as defined in FIDOMetadataStatement. - MetadataStatement MetadataStatement `json:"metadataStatement"` + MetadataStatement Statement `json:"metadataStatement"` + // Status of the FIDO Biometric Certification of one or more biometric components of the Authenticator BiometricStatusReports []BiometricStatusReport `json:"biometricStatusReports"` + // An array of status reports applicable to this authenticator. StatusReports []StatusReport `json:"statusReports"` + // ISO-8601 formatted date since when the status report array was set to the current value. - TimeOfLastStatusChange string `json:"timeOfLastStatusChange"` + TimeOfLastStatusChange time.Time + // URL of a list of rogue (i.e. untrusted) individual authenticators. - RogueListURL string `json:"rogueListURL"` + RogueListURL *url.URL + // The hash value computed over the Base64url encoding of the UTF-8 representation of the JSON encoded rogueList available at rogueListURL (with type rogueListEntry[]). - RogueListHash string `json:"rogueListHash"` + RogueListHash string +} + +// EntryJSON is an intermediary JSON/JWT structure representing the Entry MDS3 dictionary. +// +// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.0-ps-20210518.html#metadata-blob-payload-entry-dictionary +type EntryJSON struct { + Aaid string `json:"aaid"` + AaGUID string `json:"aaguid"` + AttestationCertificateKeyIdentifiers []string `json:"attestationCertificateKeyIdentifiers"` + + MetadataStatement StatementJSON `json:"metadataStatement"` + BiometricStatusReports []BiometricStatusReportJSON `json:"biometricStatusReports"` + StatusReports []StatusReportJSON `json:"statusReports"` + + TimeOfLastStatusChange string `json:"timeOfLastStatusChange"` + RogueListURL string `json:"rogueListURL"` + RogueListHash string `json:"rogueListHash"` +} + +func (j EntryJSON) Parse() (entry Entry, err error) { + var aaguid uuid.UUID + + if len(j.AaGUID) != 0 { + if aaguid, err = uuid.Parse(j.AaGUID); err != nil { + return entry, fmt.Errorf("error occurred parsing metadata entry with AAGUID '%s': error parsing AAGUID: %w", j.AaGUID, err) + } + } + + var statement Statement + + if statement, err = j.MetadataStatement.Parse(); err != nil { + return entry, fmt.Errorf("error occurred parsing metadata entry with AAGUID '%s': %w", j.AaGUID, err) + } + + var i, n int + + n = len(j.BiometricStatusReports) + + bsrs := make([]BiometricStatusReport, n) + + for i = 0; i < n; i++ { + if bsrs[i], err = j.BiometricStatusReports[i].Parse(); err != nil { + return entry, fmt.Errorf("error occurred parsing metadata entry with AAGUID '%s': error occurred parsing biometric status report %d: %w", j.AaGUID, i, err) + } + } + + n = len(j.StatusReports) + + srs := make([]StatusReport, n) + + for i = 0; i < n; i++ { + if srs[i], err = j.StatusReports[i].Parse(); err != nil { + return entry, fmt.Errorf("error occurred parsing metadata entry with AAGUID '%s': error occurred parsing status report %d: %w", j.AaGUID, i, err) + } + } + + var change time.Time + + if change, err = time.Parse(time.DateOnly, j.TimeOfLastStatusChange); err != nil { + return entry, fmt.Errorf("error occurred parsing metadata entry with AAGUID '%s': error occurred parsing time of last status change value: %w", j.AaGUID, err) + } + + var rogues *url.URL + + if len(j.RogueListURL) != 0 { + if rogues, err = url.ParseRequestURI(j.RogueListURL); err != nil { + return entry, fmt.Errorf("error occurred parsing metadata entry with AAGUID '%s': error occurred parsing rogue list URL value: %w", j.AaGUID, err) + } + + if len(j.RogueListHash) == 0 { + return entry, fmt.Errorf("error occurred parsing metadata entry with AAGUID '%s': error occurred validating rogue list URL value: the rogue list hash was absent", j.AaGUID) + } + } + + return Entry{ + Aaid: j.Aaid, + AaGUID: aaguid, + AttestationCertificateKeyIdentifiers: j.AttestationCertificateKeyIdentifiers, + MetadataStatement: statement, + BiometricStatusReports: bsrs, + StatusReports: srs, + TimeOfLastStatusChange: change, + RogueListURL: rogues, + RogueListHash: j.RogueListHash, + }, nil +} + +// Statement is a structure representing the Statement MDS3 dictionary. +// Authenticator metadata statements are used directly by the FIDO server at a relying party, but the information +// contained in the authoritative statement is used in several other places. +// +// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.0-ps-20210518.html#metadata-keys +type Statement struct { + // The legalHeader, if present, contains a legal guide for accessing and using metadata, which itself MAY contain URL(s) pointing to further information, such as a full Terms and Conditions statement. + LegalHeader string + + // The Authenticator Attestation ID. + Aaid string + + // The Authenticator Attestation GUID. + AaGUID uuid.UUID + + // A list of the attestation certificate public key identifiers encoded as hex string. + AttestationCertificateKeyIdentifiers []string + + // A human-readable, short description of the authenticator, in English. + Description string + + // A list of human-readable short descriptions of the authenticator in different languages. + AlternativeDescriptions map[string]string + + // Earliest (i.e. lowest) trustworthy authenticatorVersion meeting the requirements specified in this metadata statement. + AuthenticatorVersion uint32 + + // The FIDO protocol family. The values "uaf", "u2f", and "fido2" are supported. + ProtocolFamily string + + // he Metadata Schema version. + Schema uint16 + + // The FIDO unified protocol version(s) (related to the specific protocol family) supported by this authenticator. + Upv []Version + + // The list of authentication algorithms supported by the authenticator. + AuthenticationAlgorithms []AuthenticationAlgorithm + + // The list of public key formats supported by the authenticator during registration operations. + PublicKeyAlgAndEncodings []PublicKeyAlgAndEncoding + + // The supported attestation type(s). + AttestationTypes AuthenticatorAttestationTypes + + // A list of alternative VerificationMethodANDCombinations. + UserVerificationDetails [][]VerificationMethodDescriptor + + // A 16-bit number representing the bit fields defined by the KEY_PROTECTION constants in the FIDO Registry of Predefined Values + KeyProtection []string + + // This entry is set to true or it is omitted, if the Uauth private key is restricted by the authenticator to only sign valid FIDO signature assertions. + // This entry is set to false, if the authenticator doesn't restrict the Uauth key to only sign valid FIDO signature assertions. + IsKeyRestricted bool + + // This entry is set to true or it is omitted, if Uauth key usage always requires a fresh user verification + // This entry is set to false, if the Uauth key can be used without requiring a fresh user verification, e.g. without any additional user interaction, if the user was verified a (potentially configurable) caching time ago. + IsFreshUserVerificationRequired bool + + // A 16-bit number representing the bit fields defined by the MATCHER_PROTECTION constants in the FIDO Registry of Predefined Values + MatcherProtection []string + + // The authenticator's overall claimed cryptographic strength in bits (sometimes also called security strength or security level). + CryptoStrength uint16 + + // A 32-bit number representing the bit fields defined by the ATTACHMENT_HINT constants in the FIDO Registry of Predefined Values + AttachmentHint []string + + // A 16-bit number representing a combination of the bit flags defined by the TRANSACTION_CONFIRMATION_DISPLAY constants in the FIDO Registry of Predefined Values + TcDisplay []string + + // Supported MIME content type [RFC2049] for the transaction confirmation display, such as text/plain or image/png. + TcDisplayContentType string + + // A list of alternative DisplayPNGCharacteristicsDescriptor. Each of these entries is one alternative of supported image characteristics for displaying a PNG image. + TcDisplayPNGCharacteristics []DisplayPNGCharacteristicsDescriptor + + // Each element of this array represents a PKIX [RFC5280] X.509 certificate that is a valid trust anchor for this authenticator model. + // Multiple certificates might be used for different batches of the same model. + // The array does not represent a certificate chain, but only the trust anchor of that chain. + // A trust anchor can be a root certificate, an intermediate CA certificate or even the attestation certificate itself. + AttestationRootCertificates []*x509.Certificate + + // A list of trust anchors used for ECDAA attestation. This entry MUST be present if and only if attestationType includes ATTESTATION_ECDAA. + EcdaaTrustAnchors []EcdaaTrustAnchor + + // A data: url [RFC2397] encoded PNG [PNG] icon for the Authenticator. + Icon *url.URL + + // List of extensions supported by the authenticator. + SupportedExtensions []ExtensionDescriptor + + // Describes supported versions, extensions, AAGUID of the device and its capabilities + AuthenticatorGetInfo AuthenticatorGetInfo +} + +func (s *Statement) Verifier() (opts x509.VerifyOptions) { + roots := x509.NewCertPool() + + for _, root := range s.AttestationRootCertificates { + roots.AddCert(root) + } + + return x509.VerifyOptions{ + Roots: roots, + } +} + +// StatementJSON is an intermediary JSON/JWT structure representing the Statement MDS3 dictionary. +// Authenticator metadata statements are used directly by the FIDO server at a relying party, but the information +// contained in the authoritative statement is used in several other places. +// +// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.0-ps-20210518.html#metadata-keys +type StatementJSON struct { + LegalHeader string `json:"legalHeader"` + Aaid string `json:"aaid"` + AaGUID string `json:"aaguid"` + AttestationCertificateKeyIdentifiers []string `json:"attestationCertificateKeyIdentifiers"` + Description string `json:"description"` + AlternativeDescriptions map[string]string `json:"alternativeDescriptions"` + AuthenticatorVersion uint32 `json:"authenticatorVersion"` + ProtocolFamily string `json:"protocolFamily"` + Schema uint16 `json:"schema"` + Upv []Version `json:"upv"` + AuthenticationAlgorithms []AuthenticationAlgorithm `json:"authenticationAlgorithms"` + PublicKeyAlgAndEncodings []PublicKeyAlgAndEncoding `json:"publicKeyAlgAndEncodings"` + AttestationTypes []AuthenticatorAttestationType `json:"attestationTypes"` + UserVerificationDetails [][]VerificationMethodDescriptor `json:"userVerificationDetails"` + KeyProtection []string `json:"keyProtection"` + IsKeyRestricted bool `json:"isKeyRestricted"` + IsFreshUserVerificationRequired bool `json:"isFreshUserVerificationRequired"` + MatcherProtection []string `json:"matcherProtection"` + CryptoStrength uint16 `json:"cryptoStrength"` + AttachmentHint []string `json:"attachmentHint"` + TcDisplay []string `json:"tcDisplay"` + TcDisplayContentType string `json:"tcDisplayContentType"` + TcDisplayPNGCharacteristics []DisplayPNGCharacteristicsDescriptor `json:"tcDisplayPNGCharacteristics"` + AttestationRootCertificates []string `json:"attestationRootCertificates"` + EcdaaTrustAnchors []EcdaaTrustAnchor `json:"ecdaaTrustAnchors"` + Icon string `json:"icon"` + SupportedExtensions []ExtensionDescriptor `json:"supportedExtensions"` + AuthenticatorGetInfo AuthenticatorGetInfoJSON `json:"authenticatorGetInfo"` +} + +func (j StatementJSON) Parse() (statement Statement, err error) { + var aaguid uuid.UUID + + if len(j.AaGUID) != 0 { + if aaguid, err = uuid.Parse(j.AaGUID); err != nil { + return statement, fmt.Errorf("error occurred parsing statement with description '%s': error occurred parsing AAGUID value: %w", j.Description, err) + } + } + + n := len(j.AttestationRootCertificates) + + certificates := make([]*x509.Certificate, n) + + for i := 0; i < n; i++ { + if certificates[i], err = mdsParseX509Certificate(j.AttestationRootCertificates[i]); err != nil { + return statement, fmt.Errorf("error occurred parsing statement with description '%s': error occurred parsing attestation root certificate %d value: %w", j.Description, i, err) + } + } + + var icon *url.URL + + if len(j.Icon) != 0 { + if icon, err = url.ParseRequestURI(j.Icon); err != nil { + return statement, fmt.Errorf("error occurred parsing statement with description '%s': error occurred parsing icon value: %w", j.Description, err) + } + } + + var info AuthenticatorGetInfo + + if info, err = j.AuthenticatorGetInfo.Parse(); err != nil { + return statement, fmt.Errorf("error occurred parsing statement with description '%s': error occurred parsing authenticator get info value: %w", j.Description, err) + } + + return Statement{ + LegalHeader: j.LegalHeader, + Aaid: j.Aaid, + AaGUID: aaguid, + AttestationCertificateKeyIdentifiers: j.AttestationCertificateKeyIdentifiers, + Description: j.Description, + AlternativeDescriptions: j.AlternativeDescriptions, + AuthenticatorVersion: j.AuthenticatorVersion, + ProtocolFamily: j.ProtocolFamily, + Schema: j.Schema, + Upv: j.Upv, + AuthenticationAlgorithms: j.AuthenticationAlgorithms, + PublicKeyAlgAndEncodings: j.PublicKeyAlgAndEncodings, + AttestationTypes: j.AttestationTypes, + UserVerificationDetails: j.UserVerificationDetails, + KeyProtection: j.KeyProtection, + IsKeyRestricted: j.IsKeyRestricted, + IsFreshUserVerificationRequired: j.IsFreshUserVerificationRequired, + MatcherProtection: j.MatcherProtection, + CryptoStrength: j.CryptoStrength, + AttachmentHint: j.AttachmentHint, + TcDisplay: j.TcDisplay, + TcDisplayContentType: j.TcDisplayContentType, + TcDisplayPNGCharacteristics: j.TcDisplayPNGCharacteristics, + AttestationRootCertificates: certificates, + EcdaaTrustAnchors: j.EcdaaTrustAnchors, + Icon: icon, + SupportedExtensions: j.SupportedExtensions, + AuthenticatorGetInfo: info, + }, nil } -// https://fidoalliance.org/specs/mds/fido-metadata-service-v3.0-ps-20210518.html#biometricstatusreport-dictionary -// BiometricStatusReport - Contains the current BiometricStatusReport of one of the authenticator's biometric component. +// BiometricStatusReport is a structure representing the BiometricStatusReport MDS3 dictionary. +// Contains the current status of the authenticator's biometric component. +// +// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.0-ps-20210518.html#biometricstatusreport-dictionary type BiometricStatusReport struct { // Achieved level of the biometric certification of this biometric component of the authenticator - CertLevel uint16 `json:"certLevel"` + CertLevel uint16 + // A single USER_VERIFY constant indicating the modality of the biometric component - Modality string `json:"modality"` + Modality string + // ISO-8601 formatted date since when the certLevel achieved, if applicable. If no date is given, the status is assumed to be effective while present. - EffectiveDate string `json:"effectiveDate"` + EffectiveDate time.Time + // Describes the externally visible aspects of the Biometric Certification evaluation. - CertificationDescriptor string `json:"certificationDescriptor"` + CertificationDescriptor string + // The unique identifier for the issued Biometric Certification. - CertificateNumber string `json:"certificateNumber"` + CertificateNumber string + // The version of the Biometric Certification Policy the implementation is Certified to, e.g. "1.0.0". - CertificationPolicyVersion string `json:"certificationPolicyVersion"` + CertificationPolicyVersion string + // The version of the Biometric Requirements [FIDOBiometricsRequirements] the implementation is certified to, e.g. "1.0.0". + CertificationRequirementsVersion string +} + +// BiometricStatusReportJSON is a structure representing the BiometricStatusReport MDS3 dictionary. +// Contains the current status of the authenticator's biometric component. +// +// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.0-ps-20210518.html#biometricstatusreport-dictionary +type BiometricStatusReportJSON struct { + CertLevel uint16 `json:"certLevel"` + Modality string `json:"modality"` + EffectiveDate string `json:"effectiveDate"` + CertificationDescriptor string `json:"certificationDescriptor"` + CertificateNumber string `json:"certificateNumber"` + + CertificationPolicyVersion string `json:"certificationPolicyVersion"` CertificationRequirementsVersion string `json:"certificationRequirementsVersion"` } -// StatusReport - Contains the current BiometricStatusReport of one of the authenticator's biometric component. -// https://fidoalliance.org/specs/mds/fido-metadata-service-v3.0-ps-20210518.html#statusreport-dictionary +func (j BiometricStatusReportJSON) Parse() (report BiometricStatusReport, err error) { + var effective time.Time + + if effective, err = time.Parse(time.DateOnly, j.EffectiveDate); err != nil { + return report, fmt.Errorf("error occurred parsing effective date value: %w", err) + } + + return BiometricStatusReport{ + CertLevel: j.CertLevel, + Modality: j.Modality, + EffectiveDate: effective, + CertificationDescriptor: j.CertificationDescriptor, + CertificateNumber: j.CertificateNumber, + CertificationPolicyVersion: j.CertificationPolicyVersion, + CertificationRequirementsVersion: j.CertificationRequirementsVersion, + }, nil +} + +// StatusReport is a structure representing the StatusReport MDS3 dictionary. +// +// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.0-ps-20210518.html#statusreport-dictionary type StatusReport struct { // Status of the authenticator. Additional fields MAY be set depending on this value. - Status AuthenticatorStatus `json:"status"` + Status AuthenticatorStatus + // ISO-8601 formatted date since when the status code was set, if applicable. If no date is given, the status is assumed to be effective while present. - EffectiveDate string `json:"effectiveDate"` + EffectiveDate time.Time + // The authenticatorVersion that this status report relates to. In the case of FIDO_CERTIFIED* status values, the status applies to higher authenticatorVersions until there is a new statusReport. - AuthenticatorVersion uint32 `json:"authenticatorVersion"` + AuthenticatorVersion uint32 + // Base64-encoded [RFC4648] (not base64url!) DER [ITU-X690-2008] PKIX certificate value related to the current status, if applicable. - Certificate string `json:"certificate"` + Certificate *x509.Certificate + // HTTPS URL where additional information may be found related to the current status, if applicable. - URL string `json:"url"` + URL *url.URL + // Describes the externally visible aspects of the Authenticator Certification evaluation. - CertificationDescriptor string `json:"certificationDescriptor"` + CertificationDescriptor string + // The unique identifier for the issued Certification. - CertificateNumber string `json:"certificateNumber"` + CertificateNumber string + // The version of the Authenticator Certification Policy the implementation is Certified to, e.g. "1.0.0". - CertificationPolicyVersion string `json:"certificationPolicyVersion"` + CertificationPolicyVersion string + // The Document Version of the Authenticator Security Requirements (DV) [FIDOAuthenticatorSecurityRequirements] the implementation is certified to, e.g. "1.2.0". - CertificationRequirementsVersion string `json:"certificationRequirementsVersion"` + CertificationRequirementsVersion string } -// AuthenticatorAttestationType - The ATTESTATION constants are 16 bit long integers indicating the specific attestation that authenticator supports. -// Each constant has a case-sensitive string representation (in quotes), which is used in the authoritative metadata for FIDO authenticators. -type AuthenticatorAttestationType string - -const ( - // BasicFull - Indicates full basic attestation, based on an attestation private key shared among a class of authenticators (e.g. same model). Authenticators must provide its attestation signature during the registration process for the same reason. The attestation trust anchor is shared with FIDO Servers out of band (as part of the Metadata). This sharing process should be done according to [UAFMetadataService]. - BasicFull AuthenticatorAttestationType = "basic_full" - // BasicSurrogate - Just syntactically a Basic Attestation. The attestation object self-signed, i.e. it is signed using the UAuth.priv key, i.e. the key corresponding to the UAuth.pub key included in the attestation object. As a consequence it does not provide a cryptographic proof of the security characteristics. But it is the best thing we can do if the authenticator is not able to have an attestation private key. - BasicSurrogate AuthenticatorAttestationType = "basic_surrogate" - // Ecdaa - Indicates use of elliptic curve based direct anonymous attestation as defined in [FIDOEcdaaAlgorithm]. Support for this attestation type is optional at this time. It might be required by FIDO Certification. - Ecdaa AuthenticatorAttestationType = "ecdaa" - // AttCA - Indicates PrivacyCA attestation as defined in [TCG-CMCProfile-AIKCertEnroll]. Support for this attestation type is optional at this time. It might be required by FIDO Certification. - AttCA AuthenticatorAttestationType = "attca" - // AnonCA In this case, the authenticator uses an Anonymization CA which dynamically generates per-credential attestation certificates such that the attestation statements presented to Relying Parties do not provide uniquely identifiable information, e.g., that might be used for tracking purposes. The applicable [WebAuthn] attestation formats "fmt" are Google SafetyNet Attestation "android-safetynet", Android Keystore Attestation "android-key", Apple Anonymous Attestation "apple", and Apple Application Attestation "apple-appattest". - AnonCA AuthenticatorAttestationType = "anonca" - // None - Indicates absence of attestation - None AuthenticatorAttestationType = "none" -) +// StatusReportJSON is an intermediary JSON/JWT structure representing the StatusReport MDS3 dictionary. +// +// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.0-ps-20210518.html#statusreport-dictionary +type StatusReportJSON struct { + Status AuthenticatorStatus `json:"status"` + EffectiveDate string `json:"effectiveDate"` + AuthenticatorVersion uint32 `json:"authenticatorVersion"` + Certificate string `json:"certificate"` + URL string `json:"url"` + CertificationDescriptor string `json:"certificationDescriptor"` + CertificateNumber string `json:"certificateNumber"` + CertificationPolicyVersion string `json:"certificationPolicyVersion"` + CertificationRequirementsVersion string `json:"certificationRequirementsVersion"` +} -// AuthenticatorStatus - This enumeration describes the status of an authenticator model as identified by its AAID and potentially some additional information (such as a specific attestation key). -// https://fidoalliance.org/specs/mds/fido-metadata-service-v3.0-ps-20210518.html#authenticatorstatus-enum -type AuthenticatorStatus string - -const ( - // NotFidoCertified - This authenticator is not FIDO certified. - NotFidoCertified AuthenticatorStatus = "NOT_FIDO_CERTIFIED" - // FidoCertified - This authenticator has passed FIDO functional certification. This certification scheme is phased out and will be replaced by FIDO_CERTIFIED_L1. - FidoCertified AuthenticatorStatus = "FIDO_CERTIFIED" - // UserVerificationBypass - Indicates that malware is able to bypass the user verification. This means that the authenticator could be used without the user's consent and potentially even without the user's knowledge. - UserVerificationBypass AuthenticatorStatus = "USER_VERIFICATION_BYPASS" - // AttestationKeyCompromise - Indicates that an attestation key for this authenticator is known to be compromised. Additional data should be supplied, including the key identifier and the date of compromise, if known. - AttestationKeyCompromise AuthenticatorStatus = "ATTESTATION_KEY_COMPROMISE" - // UserKeyRemoteCompromise - This authenticator has identified weaknesses that allow registered keys to be compromised and should not be trusted. This would include both, e.g. weak entropy that causes predictable keys to be generated or side channels that allow keys or signatures to be forged, guessed or extracted. - UserKeyRemoteCompromise AuthenticatorStatus = "USER_KEY_REMOTE_COMPROMISE" - // UserKeyPhysicalCompromise - This authenticator has known weaknesses in its key protection mechanism(s) that allow user keys to be extracted by an adversary in physical possession of the device. - UserKeyPhysicalCompromise AuthenticatorStatus = "USER_KEY_PHYSICAL_COMPROMISE" - // UpdateAvailable - A software or firmware update is available for the device. Additional data should be supplied including a URL where users can obtain an update and the date the update was published. - UpdateAvailable AuthenticatorStatus = "UPDATE_AVAILABLE" - // Revoked - The FIDO Alliance has determined that this authenticator should not be trusted for any reason, for example if it is known to be a fraudulent product or contain a deliberate backdoor. - Revoked AuthenticatorStatus = "REVOKED" - // SelfAssertionSubmitted - The authenticator vendor has completed and submitted the self-certification checklist to the FIDO Alliance. If this completed checklist is publicly available, the URL will be specified in StatusReport.url. - SelfAssertionSubmitted AuthenticatorStatus = "SELF_ASSERTION_SUBMITTED" - // FidoCertifiedL1 - The authenticator has passed FIDO Authenticator certification at level 1. This level is the more strict successor of FIDO_CERTIFIED. - FidoCertifiedL1 AuthenticatorStatus = "FIDO_CERTIFIED_L1" - // FidoCertifiedL1plus - The authenticator has passed FIDO Authenticator certification at level 1+. This level is the more than level 1. - FidoCertifiedL1plus AuthenticatorStatus = "FIDO_CERTIFIED_L1plus" - // FidoCertifiedL2 - The authenticator has passed FIDO Authenticator certification at level 2. This level is more strict than level 1+. - FidoCertifiedL2 AuthenticatorStatus = "FIDO_CERTIFIED_L2" - // FidoCertifiedL2plus - The authenticator has passed FIDO Authenticator certification at level 2+. This level is more strict than level 2. - FidoCertifiedL2plus AuthenticatorStatus = "FIDO_CERTIFIED_L2plus" - // FidoCertifiedL3 - The authenticator has passed FIDO Authenticator certification at level 3. This level is more strict than level 2+. - FidoCertifiedL3 AuthenticatorStatus = "FIDO_CERTIFIED_L3" - // FidoCertifiedL3plus - The authenticator has passed FIDO Authenticator certification at level 3+. This level is more strict than level 3. - FidoCertifiedL3plus AuthenticatorStatus = "FIDO_CERTIFIED_L3plus" -) +func (j StatusReportJSON) Parse() (report StatusReport, err error) { + var certificate *x509.Certificate -// UndesiredAuthenticatorStatus is an array of undesirable authenticator statuses -var UndesiredAuthenticatorStatus = [...]AuthenticatorStatus{ - AttestationKeyCompromise, - UserVerificationBypass, - UserKeyRemoteCompromise, - UserKeyPhysicalCompromise, - Revoked, -} + if len(j.Certificate) != 0 { + if certificate, err = mdsParseX509Certificate(j.Certificate); err != nil { + return report, fmt.Errorf("error occurred parsing certificate value: %w", err) + } + } + + var effective time.Time + + if effective, err = time.Parse(time.DateOnly, j.EffectiveDate); err != nil { + return report, fmt.Errorf("error occurred parsing effective date value: %w", err) + } + + var uri *url.URL + + if len(j.URL) != 0 { + if uri, err = url.ParseRequestURI(j.URL); err != nil { + if !strings.HasPrefix(j.URL, "http") { + var e error -// IsUndesiredAuthenticatorStatus returns whether the supplied authenticator status is desirable or not -func IsUndesiredAuthenticatorStatus(status AuthenticatorStatus) bool { - for _, s := range UndesiredAuthenticatorStatus { - if s == status { - return true + if uri, e = url.ParseRequestURI(fmt.Sprintf("https://%s", j.URL)); e != nil { + return report, fmt.Errorf("error occurred parsing URL value: %w", err) + } + } } } - return false + return StatusReport{ + Status: j.Status, + EffectiveDate: effective, + AuthenticatorVersion: j.AuthenticatorVersion, + Certificate: certificate, + URL: uri, + CertificationDescriptor: j.CertificationDescriptor, + CertificateNumber: j.CertificateNumber, + CertificationPolicyVersion: j.CertificationPolicyVersion, + CertificationRequirementsVersion: j.CertificationRequirementsVersion, + }, nil } -// RogueListEntry - Contains a list of individual authenticators known to be rogue +// RogueListEntry is a structure representing the RogueListEntry MDS3 dictionary. +// +// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.0-ps-20210518.html#roguelistentry-dictionary type RogueListEntry struct { // Base64url encoding of the rogue authenticator's secret key Sk string `json:"sk"` + // ISO-8601 formatted date since when this entry is effective. Date string `json:"date"` } -// MetadataBLOBPayload - Represents the MetadataBLOBPayload -type MetadataBLOBPayload struct { - // The legalHeader, if present, contains a legal guide for accessing and using metadata, which itself MAY contain URL(s) pointing to further information, such as a full Terms and Conditions statement. - LegalHeader string `json:"legalHeader"` - // The serial number of this UAF Metadata TOC Payload. Serial numbers MUST be consecutive and strictly monotonic, i.e. the successor TOC will have a no value exactly incremented by one. - Number int `json:"no"` - // ISO-8601 formatted date when the next update will be provided at latest. - NextUpdate string `json:"nextUpdate"` - // List of zero or more MetadataTOCPayloadEntry objects. - Entries []MetadataBLOBPayloadEntry `json:"entries"` -} - -// CodeAccuracyDescriptor describes the relevant accuracy/complexity aspects of passcode user verification methods. +// CodeAccuracyDescriptor is a structure representing the CodeAccuracyDescriptor MDS3 dictionary. +// It describes the relevant accuracy/complexity aspects of passcode user verification methods. +// +// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.0-ps-20210518.html#codeaccuracydescriptor-dictionary type CodeAccuracyDescriptor struct { // The numeric system base (radix) of the code, e.g. 10 in the case of decimal digits. Base uint16 `json:"base"` + // The minimum number of digits of the given base required for that code, e.g. 4 in the case of 4 digits. MinLength uint16 `json:"minLength"` + // Maximum number of false attempts before the authenticator will block this method (at least for some time). 0 means it will never block. MaxRetries uint16 `json:"maxRetries"` + // Enforced minimum number of seconds wait time after blocking (e.g. due to forced reboot or similar). // 0 means this user verification method will be blocked, either permanently or until an alternative user verification method method succeeded. // All alternative user verification methods MUST be specified appropriately in the Metadata in userVerificationDetails. BlockSlowdown uint16 `json:"blockSlowdown"` } -// The BiometricAccuracyDescriptor describes relevant accuracy/complexity aspects in the case of a biometric user verification method. +// BiometricAccuracyDescriptor is a structure representing the BiometricAccuracyDescriptor MDS3 dictionary. +// It describes relevant accuracy/complexity aspects in the case of a biometric user verification method. +// +// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.0-ps-20210518.html#biometricaccuracydescriptor-dictionary type BiometricAccuracyDescriptor struct { // The false rejection rate [ISO19795-1] for a single template, i.e. the percentage of verification transactions with truthful claims of identity that are incorrectly denied. - SelfAttestedFRR int64 `json:"selfAttestedFRR "` + SelfAttestedFRR int64 `json:"selfAttestedFRR"` + // The false acceptance rate [ISO19795-1] for a single template, i.e. the percentage of verification transactions with wrongful claims of identity that are incorrectly confirmed. - SelfAttestedFAR int64 `json:"selfAttestedFAR "` + SelfAttestedFAR int64 `json:"selfAttestedFAR"` + // Maximum number of alternative templates from different fingers allowed. MaxTemplates uint16 `json:"maxTemplates"` + // Maximum number of false attempts before the authenticator will block this method (at least for some time). 0 means it will never block. MaxRetries uint16 `json:"maxRetries"` + // Enforced minimum number of seconds wait time after blocking (e.g. due to forced reboot or similar). // 0 means that this user verification method will be blocked either permanently or until an alternative user verification method succeeded. // All alternative user verification methods MUST be specified appropriately in the metadata in userVerificationDetails. BlockSlowdown uint16 `json:"blockSlowdown"` } -// The PatternAccuracyDescriptor describes relevant accuracy/complexity aspects in the case that a pattern is used as the user verification method. +// PatternAccuracyDescriptor is a structure representing the PatternAccuracyDescriptor MDS3 dictionary. +// It describes relevant accuracy/complexity aspects in the case that a pattern is used as the user verification method. +// +// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.0-ps-20210518.html#patternaccuracydescriptor-dictionary type PatternAccuracyDescriptor struct { // Number of possible patterns (having the minimum length) out of which exactly one would be the right one, i.e. 1/probability in the case of equal distribution. MinComplexity uint32 `json:"minComplexity"` + // Maximum number of false attempts before the authenticator will block authentication using this method (at least temporarily). 0 means it will never block. MaxRetries uint16 `json:"maxRetries"` + // Enforced minimum number of seconds wait time after blocking (due to forced reboot or similar mechanism). // 0 means this user verification method will be blocked, either permanently or until an alternative user verification method method succeeded. // All alternative user verification methods MUST be specified appropriately in the metadata under userVerificationDetails. BlockSlowdown uint16 `json:"blockSlowdown"` } -// VerificationMethodDescriptor - A descriptor for a specific base user verification method as implemented by the authenticator. +// VerificationMethodDescriptor is a structure representing the VerificationMethodDescriptor MDS3 dictionary. +// It describes a descriptor for a specific base user verification method as implemented by the authenticator. +// +// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.0-ps-20210518.html#verificationmethoddescriptor-dictionary type VerificationMethodDescriptor struct { // a single USER_VERIFY constant (see [FIDORegistry]), not a bit flag combination. This value MUST be non-zero. - UserVerificationMethod string `json:"userVerification"` + UserVerificationMethod string `json:"userVerificationMethod"` + // May optionally be used in the case of method USER_VERIFY_PASSCODE. CaDesc CodeAccuracyDescriptor `json:"caDesc"` + // May optionally be used in the case of method USER_VERIFY_FINGERPRINT, USER_VERIFY_VOICEPRINT, USER_VERIFY_FACEPRINT, USER_VERIFY_EYEPRINT, or USER_VERIFY_HANDPRINT. BaDesc BiometricAccuracyDescriptor `json:"baDesc"` + // May optionally be used in case of method USER_VERIFY_PATTERN. PaDesc PatternAccuracyDescriptor `json:"paDesc"` } -// The rgbPaletteEntry is an RGB three-sample tuple palette entry -type rgbPaletteEntry struct { +// RGBPaletteEntry is a structure representing the RGBPaletteEntry MDS3 dictionary. +// It describes an RGB three-sample tuple palette entry. +// +// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.0-ps-20210518.html#rgbpaletteentry-dictionary +type RGBPaletteEntry struct { // Red channel sample value R uint16 `json:"r"` + // Green channel sample value G uint16 `json:"g"` + // Blue channel sample value B uint16 `json:"b"` } -// The DisplayPNGCharacteristicsDescriptor describes a PNG image characteristics as defined in the PNG [PNG] spec for IHDR (image header) and PLTE (palette table) +// DisplayPNGCharacteristicsDescriptor is a structure representing the DisplayPNGCharacteristicsDescriptor MDS3 dictionary. +// It describes a PNG image characteristics as defined in the PNG [PNG] spec for IHDR (image header) and PLTE (palette table)/ +// +// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.0-ps-20210518.html#displaypngcharacteristicsdescriptor-dictionary type DisplayPNGCharacteristicsDescriptor struct { // image width Width uint32 `json:"width"` + // image height Height uint32 `json:"height"` + // Bit depth - bits per sample or per palette index. BitDepth byte `json:"bitDepth"` + // Color type defines the PNG image type. ColorType byte `json:"colorType"` + // Compression method used to compress the image data. Compression byte `json:"compression"` + // Filter method is the preprocessing method applied to the image data before compression. Filter byte `json:"filter"` + // Interlace method is the transmission order of the image data. Interlace byte `json:"interlace"` + // 1 to 256 palette entries - Plte []rgbPaletteEntry `json:"plte"` + Plte []RGBPaletteEntry `json:"plte"` } -// EcdaaTrustAnchor - In the case of ECDAA attestation, the ECDAA-Issuer's trust anchor MUST be specified in this field. +// EcdaaTrustAnchor is a structure representing the EcdaaTrustAnchor MDS3 dictionary. +// In the case of ECDAA attestation, the ECDAA-Issuer's trust anchor MUST be specified in this field. +// +// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.0-ps-20210518.html#ecdaatrustanchor-dictionary type EcdaaTrustAnchor struct { // base64url encoding of the result of ECPoint2ToB of the ECPoint2 X X string `json:"X"` + // base64url encoding of the result of ECPoint2ToB of the ECPoint2 Y Y string `json:"Y"` + // base64url encoding of the result of BigNumberToB(c) C string `json:"c"` + // base64url encoding of the result of BigNumberToB(sx) SX string `json:"sx"` + // base64url encoding of the result of BigNumberToB(sy) SY string `json:"sy"` + // Name of the Barreto-Naehrig elliptic curve for G1. "BN_P256", "BN_P638", "BN_ISOP256", and "BN_ISOP512" are supported. G1Curve string `json:"G1Curve"` } -// ExtensionDescriptor - This descriptor contains an extension supported by the authenticator. +// ExtensionDescriptor is a structure representing the ExtensionDescriptor MDS3 dictionary. +// This descriptor contains an extension supported by the authenticator. +// +// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.0-ps-20210518.html#extensiondescriptor-dictionary type ExtensionDescriptor struct { // Identifies the extension. ID string `json:"id"` + // The TAG of the extension if this was assigned. TAGs are assigned to extensions if they could appear in an assertion. Tag uint16 `json:"tag"` + // Contains arbitrary data further describing the extension and/or data needed to correctly process the extension. Data string `json:"data"` + // Indicates whether unknown extensions must be ignored (false) or must lead to an error (true) when the extension is to be processed by the FIDO Server, FIDO Client, ASM, or FIDO Authenticator. FailIfUnknown bool `json:"fail_if_unknown"` } -// MetadataStatement - Authenticator metadata statements are used directly by the FIDO server at a relying party, but the information contained in the authoritative statement is used in several other places. -type MetadataStatement struct { - // The legalHeader, if present, contains a legal guide for accessing and using metadata, which itself MAY contain URL(s) pointing to further information, such as a full Terms and Conditions statement. - LegalHeader string `json:"legalHeader"` - // The Authenticator Attestation ID. - Aaid string `json:"aaid"` - // The Authenticator Attestation GUID. - AaGUID string `json:"aaguid"` - // A list of the attestation certificate public key identifiers encoded as hex string. - AttestationCertificateKeyIdentifiers []string `json:"attestationCertificateKeyIdentifiers"` - // A human-readable, short description of the authenticator, in English. - Description string `json:"description"` - // A list of human-readable short descriptions of the authenticator in different languages. - AlternativeDescriptions map[string]string `json:"alternativeDescriptions"` - // Earliest (i.e. lowest) trustworthy authenticatorVersion meeting the requirements specified in this metadata statement. - AuthenticatorVersion uint32 `json:"authenticatorVersion"` - // The FIDO protocol family. The values "uaf", "u2f", and "fido2" are supported. - ProtocolFamily string `json:"protocolFamily"` - // The FIDO unified protocol version(s) (related to the specific protocol family) supported by this authenticator. - Upv []Version `json:"upv"` - // The list of authentication algorithms supported by the authenticator. - AuthenticationAlgorithms []AuthenticationAlgorithm `json:"authenticationAlgorithms"` - // The list of public key formats supported by the authenticator during registration operations. - PublicKeyAlgAndEncodings []PublicKeyAlgAndEncoding `json:"publicKeyAlgAndEncodings"` - // The supported attestation type(s). - AttestationTypes []AuthenticatorAttestationType `json:"attestationTypes"` - // A list of alternative VerificationMethodANDCombinations. - UserVerificationDetails [][]VerificationMethodDescriptor `json:"userVerificationDetails"` - // A 16-bit number representing the bit fields defined by the KEY_PROTECTION constants in the FIDO Registry of Predefined Values - KeyProtection []string `json:"keyProtection"` - // This entry is set to true or it is omitted, if the Uauth private key is restricted by the authenticator to only sign valid FIDO signature assertions. - // This entry is set to false, if the authenticator doesn't restrict the Uauth key to only sign valid FIDO signature assertions. - IsKeyRestricted bool `json:"isKeyRestricted"` - // This entry is set to true or it is omitted, if Uauth key usage always requires a fresh user verification - // This entry is set to false, if the Uauth key can be used without requiring a fresh user verification, e.g. without any additional user interaction, if the user was verified a (potentially configurable) caching time ago. - IsFreshUserVerificationRequired bool `json:"isFreshUserVerificationRequired"` - // A 16-bit number representing the bit fields defined by the MATCHER_PROTECTION constants in the FIDO Registry of Predefined Values - MatcherProtection []string `json:"matcherProtection"` - // The authenticator's overall claimed cryptographic strength in bits (sometimes also called security strength or security level). - CryptoStrength uint16 `json:"cryptoStrength"` - // A 32-bit number representing the bit fields defined by the ATTACHMENT_HINT constants in the FIDO Registry of Predefined Values - AttachmentHint []string `json:"attachmentHint"` - // A 16-bit number representing a combination of the bit flags defined by the TRANSACTION_CONFIRMATION_DISPLAY constants in the FIDO Registry of Predefined Values - TcDisplay []string `json:"tcDisplay"` - // Supported MIME content type [RFC2049] for the transaction confirmation display, such as text/plain or image/png. - TcDisplayContentType string `json:"tcDisplayContentType"` - // A list of alternative DisplayPNGCharacteristicsDescriptor. Each of these entries is one alternative of supported image characteristics for displaying a PNG image. - TcDisplayPNGCharacteristics []DisplayPNGCharacteristicsDescriptor `json:"tcDisplayPNGCharacteristics"` - // Each element of this array represents a PKIX [RFC5280] X.509 certificate that is a valid trust anchor for this authenticator model. - // Multiple certificates might be used for different batches of the same model. - // The array does not represent a certificate chain, but only the trust anchor of that chain. - // A trust anchor can be a root certificate, an intermediate CA certificate or even the attestation certificate itself. - AttestationRootCertificates []string `json:"attestationRootCertificates"` - // A list of trust anchors used for ECDAA attestation. This entry MUST be present if and only if attestationType includes ATTESTATION_ECDAA. - EcdaaTrustAnchors []EcdaaTrustAnchor `json:"ecdaaTrustAnchors"` - // A data: url [RFC2397] encoded PNG [PNG] icon for the Authenticator. - Icon string `json:"icon"` - // List of extensions supported by the authenticator. - SupportedExtensions []ExtensionDescriptor `json:"supportedExtensions"` - // Describes supported versions, extensions, AAGUID of the device and its capabilities - AuthenticatorGetInfo AuthenticatorGetInfo `json:"authenticatorGetInfo"` -} - -type AuthenticationAlgorithm string - -const ( - // An ECDSA signature on the NIST secp256r1 curve which must have raw R and S buffers, encoded in big-endian order. - ALG_SIGN_SECP256R1_ECDSA_SHA256_RAW AuthenticationAlgorithm = "secp256r1_ecdsa_sha256_raw" - // DER ITU-X690-2008 encoded ECDSA signature RFC5480 on the NIST secp256r1 curve. - ALG_SIGN_SECP256R1_ECDSA_SHA256_DER AuthenticationAlgorithm = "secp256r1_ecdsa_sha256_der" - // RSASSA-PSS RFC3447 signature must have raw S buffers, encoded in big-endian order RFC4055 RFC4056. - ALG_SIGN_RSASSA_PSS_SHA256_RAW AuthenticationAlgorithm = "rsassa_pss_sha256_raw" - // DER ITU-X690-2008 encoded OCTET STRING (not BIT STRING!) containing the RSASSA-PSS RFC3447 signature RFC4055 RFC4056. - ALG_SIGN_RSASSA_PSS_SHA256_DER AuthenticationAlgorithm = "rsassa_pss_sha256_der" - // An ECDSA signature on the secp256k1 curve which must have raw R and S buffers, encoded in big-endian order. - ALG_SIGN_SECP256K1_ECDSA_SHA256_RAW AuthenticationAlgorithm = "secp256k1_ecdsa_sha256_raw" - // DER ITU-X690-2008 encoded ECDSA signature RFC5480 on the secp256k1 curve. - ALG_SIGN_SECP256K1_ECDSA_SHA256_DER AuthenticationAlgorithm = "secp256k1_ecdsa_sha256_der" - // Chinese SM2 elliptic curve based signature algorithm combined with SM3 hash algorithm OSCCA-SM2 OSCCA-SM3. - ALG_SIGN_SM2_SM3_RAW AuthenticationAlgorithm = "sm2_sm3_raw" - // This is the EMSA-PKCS1-v1_5 signature as defined in RFC3447. - ALG_SIGN_RSA_EMSA_PKCS1_SHA256_RAW AuthenticationAlgorithm = "rsa_emsa_pkcs1_sha256_raw" - // DER ITU-X690-2008 encoded OCTET STRING (not BIT STRING!) containing the EMSA-PKCS1-v1_5 signature as defined in RFC3447. - ALG_SIGN_RSA_EMSA_PKCS1_SHA256_DER AuthenticationAlgorithm = "rsa_emsa_pkcs1_sha256_der" - // RSASSA-PSS RFC3447 signature must have raw S buffers, encoded in big-endian order RFC4055 RFC4056. - ALG_SIGN_RSASSA_PSS_SHA384_RAW AuthenticationAlgorithm = "rsassa_pss_sha384_raw" - // RSASSA-PSS RFC3447 signature must have raw S buffers, encoded in big-endian order RFC4055 RFC4056. - ALG_SIGN_RSASSA_PSS_SHA512_RAW AuthenticationAlgorithm = "rsassa_pss_sha512_raw" - // RSASSA-PKCS1-v1_5 RFC3447 with SHA256(aka RS256) signature must have raw S buffers, encoded in big-endian order RFC8017 RFC4056 - ALG_SIGN_RSASSA_PKCSV15_SHA256_RAW AuthenticationAlgorithm = "rsassa_pkcsv15_sha256_raw" - // RSASSA-PKCS1-v1_5 RFC3447 with SHA384(aka RS384) signature must have raw S buffers, encoded in big-endian order RFC8017 RFC4056 - ALG_SIGN_RSASSA_PKCSV15_SHA384_RAW AuthenticationAlgorithm = "rsassa_pkcsv15_sha384_raw" - // RSASSA-PKCS1-v1_5 RFC3447 with SHA512(aka RS512) signature must have raw S buffers, encoded in big-endian order RFC8017 RFC4056 - ALG_SIGN_RSASSA_PKCSV15_SHA512_RAW AuthenticationAlgorithm = "rsassa_pkcsv15_sha512_raw" - // RSASSA-PKCS1-v1_5 RFC3447 with SHA1(aka RS1) signature must have raw S buffers, encoded in big-endian order RFC8017 RFC4056 - ALG_SIGN_RSASSA_PKCSV15_SHA1_RAW AuthenticationAlgorithm = "rsassa_pkcsv15_sha1_raw" - // An ECDSA signature on the NIST secp384r1 curve with SHA384(aka: ES384) which must have raw R and S buffers, encoded in big-endian order. - ALG_SIGN_SECP384R1_ECDSA_SHA384_RAW AuthenticationAlgorithm = "secp384r1_ecdsa_sha384_raw" - // An ECDSA signature on the NIST secp512r1 curve with SHA512(aka: ES512) which must have raw R and S buffers, encoded in big-endian order. - ALG_SIGN_SECP521R1_ECDSA_SHA512_RAW AuthenticationAlgorithm = "secp521r1_ecdsa_sha512_raw" - // An EdDSA signature on the curve 25519, which must have raw R and S buffers, encoded in big-endian order. - ALG_SIGN_ED25519_EDDSA_SHA512_RAW AuthenticationAlgorithm = "ed25519_eddsa_sha512_raw" - // An EdDSA signature on the curve Ed448, which must have raw R and S buffers, encoded in big-endian order. - ALG_SIGN_ED448_EDDSA_SHA512_RAW AuthenticationAlgorithm = "ed448_eddsa_sha512_raw" -) - -// TODO: this goes away after webauthncose.CredentialPublicKey gets implemented -type algKeyCose struct { - KeyType webauthncose.COSEKeyType - Algorithm webauthncose.COSEAlgorithmIdentifier - Curve webauthncose.COSEEllipticCurve -} - -func algKeyCoseDictionary() func(AuthenticationAlgorithm) algKeyCose { - mapping := map[AuthenticationAlgorithm]algKeyCose{ - ALG_SIGN_SECP256R1_ECDSA_SHA256_RAW: {KeyType: webauthncose.EllipticKey, Algorithm: webauthncose.AlgES256, Curve: webauthncose.P256}, - ALG_SIGN_SECP256R1_ECDSA_SHA256_DER: {KeyType: webauthncose.EllipticKey, Algorithm: webauthncose.AlgES256, Curve: webauthncose.P256}, - ALG_SIGN_RSASSA_PSS_SHA256_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgPS256}, - ALG_SIGN_RSASSA_PSS_SHA256_DER: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgPS256}, - ALG_SIGN_SECP256K1_ECDSA_SHA256_RAW: {KeyType: webauthncose.EllipticKey, Algorithm: webauthncose.AlgES256K, Curve: webauthncose.Secp256k1}, - ALG_SIGN_SECP256K1_ECDSA_SHA256_DER: {KeyType: webauthncose.EllipticKey, Algorithm: webauthncose.AlgES256K, Curve: webauthncose.Secp256k1}, - ALG_SIGN_RSASSA_PSS_SHA384_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgPS384}, - ALG_SIGN_RSASSA_PSS_SHA512_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgPS512}, - ALG_SIGN_RSASSA_PKCSV15_SHA256_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgRS256}, - ALG_SIGN_RSASSA_PKCSV15_SHA384_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgRS384}, - ALG_SIGN_RSASSA_PKCSV15_SHA512_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgRS512}, - ALG_SIGN_RSASSA_PKCSV15_SHA1_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgRS1}, - ALG_SIGN_SECP384R1_ECDSA_SHA384_RAW: {KeyType: webauthncose.EllipticKey, Algorithm: webauthncose.AlgES384, Curve: webauthncose.P384}, - ALG_SIGN_SECP521R1_ECDSA_SHA512_RAW: {KeyType: webauthncose.EllipticKey, Algorithm: webauthncose.AlgES512, Curve: webauthncose.P521}, - ALG_SIGN_ED25519_EDDSA_SHA512_RAW: {KeyType: webauthncose.OctetKey, Algorithm: webauthncose.AlgEdDSA, Curve: webauthncose.Ed25519}, - ALG_SIGN_ED448_EDDSA_SHA512_RAW: {KeyType: webauthncose.OctetKey, Algorithm: webauthncose.AlgEdDSA, Curve: webauthncose.Ed448}, - } - - return func(key AuthenticationAlgorithm) algKeyCose { - return mapping[key] - } -} - -func AlgKeyMatch(key algKeyCose, algs []AuthenticationAlgorithm) bool { - for _, alg := range algs { - if reflect.DeepEqual(algKeyCoseDictionary()(alg), key) { - return true - } - } - - return false -} - -type PublicKeyAlgAndEncoding string - -const ( - // Raw ANSI X9.62 formatted Elliptic Curve public key. - ALG_KEY_ECC_X962_RAW PublicKeyAlgAndEncoding = "ecc_x962_raw" - // DER ITU-X690-2008 encoded ANSI X.9.62 formatted SubjectPublicKeyInfo RFC5480 specifying an elliptic curve public key. - ALG_KEY_ECC_X962_DER PublicKeyAlgAndEncoding = "ecc_x962_der" - // Raw encoded 2048-bit RSA public key RFC3447. - ALG_KEY_RSA_2048_RAW PublicKeyAlgAndEncoding = "rsa_2048_raw" - // ASN.1 DER [ITU-X690-2008] encoded 2048-bit RSA RFC3447 public key RFC4055. - ALG_KEY_RSA_2048_DER PublicKeyAlgAndEncoding = "rsa_2048_der" - // COSE_Key format, as defined in Section 7 of RFC8152. This encoding includes its own field for indicating the public key algorithm. - ALG_KEY_COSE PublicKeyAlgAndEncoding = "cose" -) - -// Version - Represents a generic version with major and minor fields. +// Version represents a generic version with major and minor fields. type Version struct { // Major version. Major uint16 `json:"major"` + // Minor version. Minor uint16 `json:"minor"` } type AuthenticatorGetInfo struct { // List of supported versions. - Versions []string `json:"versions"` + Versions []string + // List of supported extensions. - Extensions []string `json:"extensions"` + Extensions []string + // The claimed AAGUID. - AaGUID string `json:"aaguid"` + AaGUID uuid.UUID + // List of supported options. - Options map[string]bool `json:"options"` + Options map[string]bool + // Maximum message size supported by the authenticator. - MaxMsgSize uint `json:"maxMsgSize"` + MaxMsgSize uint + // List of supported PIN/UV auth protocols in order of decreasing authenticator preference. - PivUvAuthProtocols []uint `json:"pinUvAuthProtocols"` + PivUvAuthProtocols []uint + // Maximum number of credentials supported in credentialID list at a time by the authenticator. - MaxCredentialCountInList uint `json:"maxCredentialCountInList"` + MaxCredentialCountInList uint + // Maximum Credential ID Length supported by the authenticator. - MaxCredentialIdLength uint `json:"maxCredentialLength"` + MaxCredentialIdLength uint + // List of supported transports. - Transports []string `json:"transports"` + Transports []string + // List of supported algorithms for credential generation, as specified in WebAuthn. - Algorithms []PublicKeyCredentialParameters `json:"algorithms"` + Algorithms []PublicKeyCredentialParameters + // The maximum size, in bytes, of the serialized large-blob array that this authenticator can store. - MaxSerializedLargeBlobArray uint `json:"maxSerializedLargeBlobArray"` + MaxSerializedLargeBlobArray uint + // If this member is present and set to true, the PIN must be changed. - ForcePINChange bool `json:"forcePINChange"` + ForcePINChange bool + // This specifies the current minimum PIN length, in Unicode code points, the authenticator enforces for ClientPIN. - MinPINLength uint `json:"minPINLength"` + MinPINLength uint + // Indicates the firmware version of the authenticator model identified by AAGUID. - FirmwareVersion uint `json:"firmwareVersion"` + FirmwareVersion uint + // Maximum credBlob length in bytes supported by the authenticator. - MaxCredBlobLength uint `json:"maxCredBlobLength"` + MaxCredBlobLength uint + // This specifies the max number of RP IDs that authenticator can set via setMinPINLength subcommand. - MaxRPIDsForSetMinPINLength uint `json:"maxRPIDsForSetMinPINLength"` + MaxRPIDsForSetMinPINLength uint + // This specifies the preferred number of invocations of the getPinUvAuthTokenUsingUvWithPermissions subCommand the platform may attempt before falling back to the getPinUvAuthTokenUsingPinWithPermissions subCommand or displaying an error. - PreferredPlatformUvAttempts uint `json:"preferredPlatformUvAttempts"` + PreferredPlatformUvAttempts uint + // This specifies the user verification modality supported by the authenticator via authenticatorClientPIN's getPinUvAuthTokenUsingUvWithPermissions subcommand. - UvModality uint `json:"uvModality"` + UvModality uint + // This specifies a list of authenticator certifications. - Certifications map[string]float64 `json:"certifications"` + Certifications map[string]float64 + // If this member is present it indicates the estimated number of additional discoverable credentials that can be stored. - RemainingDiscoverableCredentials uint `json:"remainingDiscoverableCredentials"` + RemainingDiscoverableCredentials uint + // If present the authenticator supports the authenticatorConfig vendorPrototype subcommand, and its value is a list of authenticatorConfig vendorCommandId values supported, which MAY be empty. - VendorPrototypeConfigCommands []uint `json:"vendorPrototypeConfigCommands"` + VendorPrototypeConfigCommands []uint +} + +type AuthenticatorGetInfoJSON struct { + Versions []string `json:"versions"` + Extensions []string `json:"extensions"` + AaGUID string `json:"aaguid"` + Options map[string]bool `json:"options"` + MaxMsgSize uint `json:"maxMsgSize"` + PivUvAuthProtocols []uint `json:"pinUvAuthProtocols"` + MaxCredentialCountInList uint `json:"maxCredentialCountInList"` + MaxCredentialIdLength uint `json:"maxCredentialIdLength"` + Transports []string `json:"transports"` + Algorithms []PublicKeyCredentialParameters `json:"algorithms"` + MaxSerializedLargeBlobArray uint `json:"maxSerializedLargeBlobArray"` + ForcePINChange bool `json:"forcePINChange"` + MinPINLength uint `json:"minPINLength"` + FirmwareVersion uint `json:"firmwareVersion"` + MaxCredBlobLength uint `json:"maxCredBlobLength"` + MaxRPIDsForSetMinPINLength uint `json:"maxRPIDsForSetMinPINLength"` + PreferredPlatformUvAttempts uint `json:"preferredPlatformUvAttempts"` + UvModality uint `json:"uvModality"` + Certifications map[string]float64 `json:"certifications"` + RemainingDiscoverableCredentials uint `json:"remainingDiscoverableCredentials"` + VendorPrototypeConfigCommands []uint `json:"vendorPrototypeConfigCommands"` +} + +func (j AuthenticatorGetInfoJSON) Parse() (info AuthenticatorGetInfo, err error) { + var aaguid uuid.UUID + + if len(j.AaGUID) != 0 { + if aaguid, err = uuid.Parse(j.AaGUID); err != nil { + return info, fmt.Errorf("error occurred parsing AAGUID value: %w", err) + } + } + + return AuthenticatorGetInfo{ + Versions: j.Versions, + Extensions: j.Extensions, + AaGUID: aaguid, + Options: j.Options, + MaxMsgSize: j.MaxMsgSize, + PivUvAuthProtocols: j.PivUvAuthProtocols, + MaxCredentialCountInList: j.MaxCredentialCountInList, + MaxCredentialIdLength: j.MaxCredentialIdLength, + Transports: j.Transports, + Algorithms: j.Algorithms, + MaxSerializedLargeBlobArray: j.MaxSerializedLargeBlobArray, + ForcePINChange: j.ForcePINChange, + MinPINLength: j.MinPINLength, + FirmwareVersion: j.FirmwareVersion, + MaxCredBlobLength: j.MaxCredBlobLength, + MaxRPIDsForSetMinPINLength: j.MaxRPIDsForSetMinPINLength, + PreferredPlatformUvAttempts: j.PreferredPlatformUvAttempts, + UvModality: j.UvModality, + Certifications: j.Certifications, + RemainingDiscoverableCredentials: j.RemainingDiscoverableCredentials, + VendorPrototypeConfigCommands: j.VendorPrototypeConfigCommands, + }, nil } // MDSGetEndpointsRequest is the request sent to the conformance metadata getEndpoints endpoint. @@ -541,189 +899,21 @@ type MDSGetEndpointsRequest struct { type MDSGetEndpointsResponse struct { // The status of the response. Status string `json:"status"` + // An array of urls, each pointing to a MetadataTOCPayload. Result []string `json:"result"` } -func unmarshalMDSBLOB(body []byte, c http.Client) (MetadataBLOBPayload, error) { - var payload MetadataBLOBPayload - - token, err := jwt.Parse(string(body), func(token *jwt.Token) (any, error) { - // 2. If the x5u attribute is present in the JWT Header, then - if _, ok := token.Header["x5u"].(any); ok { - // never seen an x5u here, although it is in the spec - return nil, errors.New("x5u encountered in header of metadata TOC payload") - } - var chain []any - // 3. If the x5u attribute is missing, the chain should be retrieved from the x5c attribute. - - if x5c, ok := token.Header["x5c"].([]any); !ok { - // If that attribute is missing as well, Metadata TOC signing trust anchor is considered the TOC signing certificate chain. - chain[0] = MDSRoot - } else { - chain = x5c - } - - // The certificate chain MUST be verified to properly chain to the metadata TOC signing trust anchor. - valid, err := validateChain(chain, c) - if !valid || err != nil { - return nil, err - } - - // Chain validated, extract the TOC signing certificate from the chain. Create a buffer large enough to hold the - // certificate bytes. - o := make([]byte, base64.StdEncoding.DecodedLen(len(chain[0].(string)))) - - // base64 decode the certificate into the buffer. - n, err := base64.StdEncoding.Decode(o, []byte(chain[0].(string))) - if err != nil { - return nil, err - } - - // Parse the certificate from the buffer. - cert, err := x509.ParseCertificate(o[:n]) - if err != nil { - return nil, err - } - - // 4. Verify the signature of the Metadata TOC object using the TOC signing certificate chain - // jwt.Parse() uses the TOC signing certificate public key internally to verify the signature. - return cert.PublicKey, err - }) - - if err != nil { - return payload, err - } - - err = mapstructure.Decode(token.Claims, &payload) - - return payload, err -} - -func validateChain(chain []any, c http.Client) (bool, error) { - oRoot := make([]byte, base64.StdEncoding.DecodedLen(len(MDSRoot))) - - nRoot, err := base64.StdEncoding.Decode(oRoot, []byte(MDSRoot)) - if err != nil { - return false, err - } - - rootcert, err := x509.ParseCertificate(oRoot[:nRoot]) - if err != nil { - return false, err - } - - roots := x509.NewCertPool() - - roots.AddCert(rootcert) - - o := make([]byte, base64.StdEncoding.DecodedLen(len(chain[1].(string)))) - - n, err := base64.StdEncoding.Decode(o, []byte(chain[1].(string))) - if err != nil { - return false, err - } - - intcert, err := x509.ParseCertificate(o[:n]) - if err != nil { - return false, err - } - - if revoked, ok := revoke.VerifyCertificate(intcert); !ok { - issuer := intcert.IssuingCertificateURL - - if issuer != nil { - return false, errCRLUnavailable - } - } else if revoked { - return false, errIntermediateCertRevoked - } - - ints := x509.NewCertPool() - ints.AddCert(intcert) - - l := make([]byte, base64.StdEncoding.DecodedLen(len(chain[0].(string)))) - - n, err = base64.StdEncoding.Decode(l, []byte(chain[0].(string))) - if err != nil { - return false, err - } - - leafcert, err := x509.ParseCertificate(l[:n]) - if err != nil { - return false, err - } - - if revoked, ok := revoke.VerifyCertificate(leafcert); !ok { - return false, errCRLUnavailable - } else if revoked { - return false, errLeafCertRevoked - } - - opts := x509.VerifyOptions{ - Roots: roots, - Intermediates: ints, - } - - _, err = leafcert.Verify(opts) - - return err == nil, err -} - -type MetadataError struct { - // Short name for the type of error that has occurred. - Type string `json:"type"` - // Additional details about the error. - Details string `json:"error"` - // Information to help debug the error. - DevInfo string `json:"debug"` -} +// DefaultUndesiredAuthenticatorStatuses returns a copy of the defaultUndesiredAuthenticatorStatus slice. +func DefaultUndesiredAuthenticatorStatuses() []AuthenticatorStatus { + undesired := make([]AuthenticatorStatus, len(defaultUndesiredAuthenticatorStatus)) -var ( - errIntermediateCertRevoked = &MetadataError{ - Type: "intermediate_revoked", - Details: "Intermediate certificate is on issuers revocation list", - } - errLeafCertRevoked = &MetadataError{ - Type: "leaf_revoked", - Details: "Leaf certificate is on issuers revocation list", - } - errCRLUnavailable = &MetadataError{ - Type: "crl_unavailable", - Details: "Certificate revocation list is unavailable", - } -) + copy(undesired, defaultUndesiredAuthenticatorStatus[:]) -func (err *MetadataError) Error() string { - return err.Details + return undesired } -func PopulateMetadata(url string) error { - c := &http.Client{ - Timeout: time.Second * 30, - } - - res, err := c.Get(url) - if err != nil { - return err - } - - defer res.Body.Close() - - body, err := io.ReadAll(res.Body) - if err != nil { - return err - } - - blob, err := unmarshalMDSBLOB(body, *c) - if err != nil { - return err - } - - for _, entry := range blob.Entries { - aaguid, _ := uuid.Parse(entry.AaGUID) - Metadata[aaguid] = entry - } - - return err +type EntryError struct { + Error error + EntryJSON } diff --git a/metadata/metadata_test.go b/metadata/metadata_test.go index acb3381..b720fdf 100644 --- a/metadata/metadata_test.go +++ b/metadata/metadata_test.go @@ -3,109 +3,44 @@ package metadata import ( "bytes" "encoding/json" + "errors" "io" "net/http" "testing" "time" "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/go-webauthn/webauthn/protocol/webauthncose" ) -func downloadBytes(url string, c http.Client) ([]byte, error) { - res, err := c.Get(url) - if err != nil { - return nil, err - } - - defer res.Body.Close() - - body, _ := io.ReadAll(res.Body) - - return body, err -} - -func getEndpoints(c http.Client) ([]string, error) { - jsonReq, err := json.Marshal(MDSGetEndpointsRequest{Endpoint: "https://webauthn.io"}) - if err != nil { - return nil, err - } - - req, err := c.Post("https://mds3.fido.tools/getEndpoints", "application/json", bytes.NewBuffer(jsonReq)) - if err != nil { - return nil, err - } - - defer req.Body.Close() - body, _ := io.ReadAll(req.Body) - - var resp MDSGetEndpointsResponse - - if err = json.Unmarshal(body, &resp); err != nil { - return nil, err - } - - return resp.Result, err -} - -func getTestMetadata(s string, c http.Client) (MetadataStatement, error) { - var statement MetadataStatement - - // MDSGetEndpointsRequest is the request sent to the conformance metadata getEndpoints endpoint. - type MDSGetTestMetadata struct { - // The URL of the local server endpoint, e.g. https://webauthn.io/ - Endpoint string `json:"endpoint"` - TestCase string `json:"testcase"` - } - - jsonReq, err := json.Marshal(MDSGetTestMetadata{Endpoint: "https://webauthn.io", TestCase: s}) - if err != nil { - return statement, err - } - - req, err := c.Post("https://mds3.fido.tools/getTestMetadata", "application/json", bytes.NewBuffer(jsonReq)) - if err != nil { - return statement, err - } - - defer req.Body.Close() - - body, err := io.ReadAll(req.Body) - if err != nil { - return statement, err - } - - type ConformanceResponse struct { - Status string `json:"status"` - Result MetadataStatement `json:"result"` - } +func TestProductionMetadataTOCParsing(t *testing.T) { + decoder, err := NewDecoder(WithIgnoreEntryParsingErrors()) + require.NoError(t, err) - var resp ConformanceResponse + client := &http.Client{} - if err = json.Unmarshal(body, &resp); err != nil { - return statement, err - } + res, err := client.Get(ProductionMDSURL) + require.NoError(t, err) - statement = resp.Result + payload, err := decoder.Decode(res.Body) + require.NoError(t, err) - return statement, err -} + var metadata *Metadata -func TestProductionMetadataTOCParsing(t *testing.T) { - if err := PopulateMetadata(ProductionMDSURL); err != nil { - t.Fatal(err) - } + metadata, err = decoder.Parse(payload) + require.NoError(t, err) + require.NotNil(t, metadata) } func TestConformanceMetadataTOCParsing(t *testing.T) { - MDSRoot = ConformanceMDSRoot - Conformance = true - httpClient := &http.Client{ + client := &http.Client{ Timeout: time.Second * 30, } - tests := []struct { + testCases := []struct { name string pass bool }{ @@ -135,53 +70,60 @@ func TestConformanceMetadataTOCParsing(t *testing.T) { }, } - endpoints, err := getEndpoints(*httpClient) - if err != nil { - t.Fatal(err) - } + endpoints, err := getEndpoints(client) + require.NoError(t, err) + + decoder, err := NewDecoder(WithRootCertificate(ConformanceMDSRoot)) + + require.NoError(t, err) + + metadata := make(map[uuid.UUID]EntryJSON) + + var ( + res *http.Response + blob *PayloadJSON + me *MetadataError + ) for _, endpoint := range endpoints { - bytes, err := downloadBytes(endpoint, *httpClient) - if err != nil { - t.Fatal(err) - } + res, err = client.Get(endpoint) + require.NoError(t, err) - blob, err := unmarshalMDSBLOB(bytes, *httpClient) - if err != nil { - if me, ok := err.(*MetadataError); ok { + if blob, err = decoder.Decode(res.Body); err != nil { + if errors.As(err, &me) { t.Log(me.Details) } } - for _, entry := range blob.Entries { - aaguid, _ := uuid.Parse(entry.AaGUID) - Metadata[aaguid] = entry + if blob != nil { + for _, entry := range blob.Entries { + aaguid, _ := uuid.Parse(entry.AaGUID) + metadata[aaguid] = entry + } } } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - statement, err := getTestMetadata(tt.name, *httpClient) - if err != nil { - t.Fatal(err) - } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + statement, err := getTestMetadata(tc.name, client) + require.NoError(t, err) + aaguid, _ := uuid.Parse(statement.AaGUID) - if meta, ok := Metadata[aaguid]; ok { - if tt.pass { - t.Logf("Found aaguid %s in test metadata", meta.AaGUID) - } else { - if IsUndesiredAuthenticatorStatus(meta.StatusReports[0].Status) { - t.Logf("Found authenticator %s with bad status in test metadata, %s", meta.AaGUID, meta.StatusReports[0].Status) - } else { - t.Fail() + if meta, ok := metadata[aaguid]; ok { + pass := true + + for _, report := range meta.StatusReports { + if IsUndesiredAuthenticatorStatus(report.Status) { + pass = false } } + + assert.Equal(t, tc.pass, pass, "One or more status reports had an undesired status but this was not expected.") + + _, err := meta.Parse() + assert.NoError(t, err, "Failed to parse metadata") } else { - if !tt.pass { - t.Logf("Metadata for aaguid %s not found in test metadata", statement.AaGUID) - } else { - t.Fail() - } + assert.False(t, tc.pass) } }) } @@ -192,18 +134,18 @@ const ( ) func TestExampleMetadataTOCParsing(t *testing.T) { - MDSRoot = ExampleMDSRoot + exampleMetadataBLOBBytes := bytes.NewBufferString(exampleMetadataBLOB) - httpClient := &http.Client{ - Timeout: time.Second * 30, - } + decoder, err := NewDecoder(WithIgnoreEntryParsingErrors(), WithRootCertificate(ExampleMDSRoot)) - exampleMetadataBLOBBytes := bytes.NewBufferString(exampleMetadataBLOB) + require.NoError(t, err) - _, err := unmarshalMDSBLOB(exampleMetadataBLOBBytes.Bytes(), *httpClient) - if err != nil { - t.Fail() - } + payload, err := decoder.DecodeBytes(exampleMetadataBLOBBytes.Bytes()) + require.NoError(t, err) + + _, err = decoder.Parse(payload) + + require.NoError(t, err) } func TestIsUndesiredAuthenticatorStatus(t *testing.T) { @@ -347,3 +289,69 @@ func TestAlgKeyMatch(t *testing.T) { }) } } + +func getEndpoints(c *http.Client) ([]string, error) { + jsonReq, err := json.Marshal(MDSGetEndpointsRequest{Endpoint: "https://webauthn.io"}) + if err != nil { + return nil, err + } + + req, err := c.Post("https://mds3.fido.tools/getEndpoints", "application/json", bytes.NewBuffer(jsonReq)) + if err != nil { + return nil, err + } + + defer req.Body.Close() + body, _ := io.ReadAll(req.Body) + + var resp MDSGetEndpointsResponse + + if err = json.Unmarshal(body, &resp); err != nil { + return nil, err + } + + return resp.Result, err +} + +func getTestMetadata(s string, c *http.Client) (StatementJSON, error) { + var statement StatementJSON + + // MDSGetEndpointsRequest is the request sent to the conformance metadata getEndpoints endpoint. + type MDSGetTestMetadata struct { + // The URL of the local server endpoint, e.g. https://webauthn.io/ + Endpoint string `json:"endpoint"` + TestCase string `json:"testcase"` + } + + jsonReq, err := json.Marshal(MDSGetTestMetadata{Endpoint: "https://webauthn.io", TestCase: s}) + if err != nil { + return statement, err + } + + req, err := c.Post("https://mds3.fido.tools/getTestMetadata", "application/json", bytes.NewBuffer(jsonReq)) + if err != nil { + return statement, err + } + + defer req.Body.Close() + + body, err := io.ReadAll(req.Body) + if err != nil { + return statement, err + } + + type ConformanceResponse struct { + Status string `json:"status"` + Result StatementJSON `json:"result"` + } + + var resp ConformanceResponse + + if err = json.Unmarshal(body, &resp); err != nil { + return statement, err + } + + statement = resp.Result + + return statement, err +} diff --git a/metadata/passkey_authenticator.go b/metadata/passkey_authenticator.go new file mode 100644 index 0000000..bd993d0 --- /dev/null +++ b/metadata/passkey_authenticator.go @@ -0,0 +1,16 @@ +package metadata + +// PasskeyAuthenticator is a type that represents the schema from the Passkey Developer AAGUID listing. +// +// See: https://github.com/passkeydeveloper/passkey-authenticator-aaguids +type PasskeyAuthenticator map[string]PassKeyAuthenticatorAAGUID + +// PassKeyAuthenticatorAAGUID is a type that represents the indivudal schema entry from the Passkey Developer AAGUID +// listing. Used with PasskeyAuthenticator. +// +// See: https://github.com/passkeydeveloper/passkey-authenticator-aaguids +type PassKeyAuthenticatorAAGUID struct { + Name string `json:"name"` + IconDark string `json:"icon_dark,omitempty"` + IconLight string `json:"icon_light,omitempty"` +} diff --git a/metadata/providers/cached/doc.go b/metadata/providers/cached/doc.go new file mode 100644 index 0000000..cb18e91 --- /dev/null +++ b/metadata/providers/cached/doc.go @@ -0,0 +1,8 @@ +// Package cached handles a metadata.Provider implementation that both downloads and caches the MDS3 blob. This +// effectively is the recommended provider in most instances as it's fairly robust. Alternatively we suggest +// implementing a similar provider that leverages the memory.Provider as an underlying element. +// +// This provider only specifically performs updates at the time it's initialized. It has no automatic update +// functionality. This may change in the future however if you want this functionality at this time we recommend making +// your own implementation. +package cached diff --git a/metadata/providers/cached/options.go b/metadata/providers/cached/options.go new file mode 100644 index 0000000..3abd763 --- /dev/null +++ b/metadata/providers/cached/options.go @@ -0,0 +1,92 @@ +package cached + +import ( + "net/http" + "net/url" + + "github.com/go-webauthn/webauthn/metadata" +) + +// Option describes an optional pattern for this provider. +type Option func(provider *Provider) (err error) + +// NewFunc describes the type used to create the underlying provider. +type NewFunc func(mds *metadata.Metadata) (provider metadata.Provider, err error) + +// WithPath sets the path name for the cached file. This option is REQUIRED. +func WithPath(name string) Option { + return func(provider *Provider) (err error) { + provider.name = name + + return nil + } +} + +// WithUpdate is used to enable or disable the update. By default it's set to true. +func WithUpdate(update bool) Option { + return func(provider *Provider) (err error) { + provider.update = update + + return nil + } +} + +// WithForceUpdate is used to force an update on creation. This will forcibly overwrite the file if possible. +func WithForceUpdate(force bool) Option { + return func(provider *Provider) (err error) { + provider.force = force + + return nil + } +} + +// WithNew customizes the NewFunc. By default we just create a fairly standard memory.Provider with strict defaults. +func WithNew(newup NewFunc) Option { + return func(provider *Provider) (err error) { + provider.newup = newup + + return nil + } +} + +// WithDecoder sets the decoder to be used for this provider. By default this is a decoder with the entry parsing errors +// configured to skip that entry. +func WithDecoder(decoder *metadata.Decoder) Option { + return func(provider *Provider) (err error) { + provider.decoder = decoder + + return nil + } +} + +// WithMetadataURL configures the URL to get the metadata from. This shouldn't be modified unless you know what you're +// doing as we use the metadata.ProductionMDSURL which is safe in most instances. +func WithMetadataURL(uri string) Option { + return func(provider *Provider) (err error) { + if _, err = url.ParseRequestURI(uri); err != nil { + return err + } + + provider.uri = uri + + return nil + } +} + +// WithClient configures the *http.Client used to get the MDS3 blob. +func WithClient(client *http.Client) Option { + return func(provider *Provider) (err error) { + provider.client = client + + return nil + } +} + +// WithClock allows injection of a metadata.Clock to check the up-to-date status of a blob. +func WithClock(clock metadata.Clock) Option { + return func(provider *Provider) (err error) { + provider.clock = clock + + return nil + } +} diff --git a/metadata/providers/cached/provider.go b/metadata/providers/cached/provider.go new file mode 100644 index 0000000..d8a0dd1 --- /dev/null +++ b/metadata/providers/cached/provider.go @@ -0,0 +1,146 @@ +package cached + +import ( + "fmt" + "io" + "net/http" + "os" + + "github.com/go-webauthn/webauthn/metadata" +) + +// New returns a new cached Provider given a set of functional Option's. This provider will download a new version and +// save it to the configured file path if it doesn't exist or if it's out of date by default. +func New(opts ...Option) (provider metadata.Provider, err error) { + p := &Provider{ + update: true, + uri: metadata.ProductionMDSURL, + } + + for _, opt := range opts { + if err = opt(p); err != nil { + return nil, err + } + } + + if p.name == "" { + return nil, fmt.Errorf("provider configured without setting a path for the cached file blob") + } + + if p.newup == nil { + p.newup = defaultNew + } + + if p.decoder == nil { + if p.decoder, err = metadata.NewDecoder(metadata.WithIgnoreEntryParsingErrors()); err != nil { + return nil, err + } + } + + if p.clock == nil { + p.clock = &metadata.RealClock{} + } + + if err = p.init(); err != nil { + return nil, err + } + + return p, nil +} + +// Provider implements a metadata.Provider with a file-based cache. +type Provider struct { + metadata.Provider + + name string + uri string + update bool + force bool + clock metadata.Clock + client *http.Client + decoder *metadata.Decoder + newup NewFunc +} + +func (p *Provider) init() (err error) { + var ( + f *os.File + rc io.ReadCloser + created bool + mds *metadata.Metadata + ) + + if f, created, err = doOpenOrCreate(p.name); err != nil { + return err + } + + defer f.Close() + + if created || p.force { + if rc, err = p.get(); err != nil { + return err + } + } else { + if mds, err = p.parse(f); err != nil { + return err + } + + if p.outdated(mds) { + if rc, err = p.get(); err != nil { + return err + } + } + } + + if rc != nil { + if err = doTruncateCopyAndSeekStart(f, rc); err != nil { + return err + } + + if mds, err = p.parse(f); err != nil { + return err + } + } + + var provider metadata.Provider + + if provider, err = p.newup(mds); err != nil { + return err + } + + p.Provider = provider + + return nil +} + +func (p *Provider) parse(rc io.ReadCloser) (data *metadata.Metadata, err error) { + var payload *metadata.PayloadJSON + + if payload, err = p.decoder.Decode(rc); err != nil { + return nil, err + } + + if data, err = p.decoder.Parse(payload); err != nil { + return nil, err + } + + return data, nil +} + +func (p *Provider) outdated(mds *metadata.Metadata) bool { + return p.update && p.clock.Now().After(mds.Parsed.NextUpdate) +} + +func (p *Provider) get() (f io.ReadCloser, err error) { + if p.client == nil { + p.client = &http.Client{} + } + + var res *http.Response + + if res, err = p.client.Get(p.uri); err != nil { + return nil, err + } + + return res.Body, nil +} diff --git a/metadata/providers/cached/util.go b/metadata/providers/cached/util.go new file mode 100644 index 0000000..98d7dad --- /dev/null +++ b/metadata/providers/cached/util.go @@ -0,0 +1,51 @@ +package cached + +import ( + "io" + "os" + + "github.com/go-webauthn/webauthn/metadata" + "github.com/go-webauthn/webauthn/metadata/providers/memory" +) + +func doTruncateCopyAndSeekStart(f *os.File, rc io.ReadCloser) (err error) { + if err = f.Truncate(0); err != nil { + return err + } + + if _, err = io.Copy(f, rc); err != nil { + return err + } + + if _, err = f.Seek(0, io.SeekStart); err != nil { + return err + } + + return rc.Close() +} + +func doOpenOrCreate(name string) (f *os.File, created bool, err error) { + if f, err = os.Open(name); err == nil { + return f, false, nil + } + + if os.IsNotExist(err) { + if f, err = os.Create(name); err != nil { + return nil, false, err + } + + return f, true, nil + } + + return nil, false, err +} + +func defaultNew(mds *metadata.Metadata) (provider metadata.Provider, err error) { + return memory.New( + memory.WithMetadata(mds.ToMap()), + memory.WithValidateEntry(true), + memory.WithValidateEntryPermitZeroAAGUID(false), + memory.WithValidateTrustAnchor(true), + memory.WithValidateStatus(true), + ) +} diff --git a/metadata/providers/memory/doc.go b/metadata/providers/memory/doc.go new file mode 100644 index 0000000..bc2679f --- /dev/null +++ b/metadata/providers/memory/doc.go @@ -0,0 +1,4 @@ +// Package memory handles a metadata.Provider implementation that solely exists in memory. It's intended as a basis +// for other providers and generally not recommended to use directly unless you're implementing your own logic to handle +// the download and potential caching of the MDS3 blob yourself. +package memory diff --git a/metadata/providers/memory/options.go b/metadata/providers/memory/options.go new file mode 100644 index 0000000..6b8c2a1 --- /dev/null +++ b/metadata/providers/memory/options.go @@ -0,0 +1,90 @@ +package memory + +import ( + "github.com/google/uuid" + + "github.com/go-webauthn/webauthn/metadata" +) + +// Option describes an optional pattern for this provider. +type Option func(provider *Provider) (err error) + +// WithMetadata provides the required metadata for the memory provider. +func WithMetadata(mds map[uuid.UUID]*metadata.Entry) Option { + return func(provider *Provider) (err error) { + provider.mds = mds + + return nil + } +} + +// WithValidateEntry requires that the provided metadata has an entry for the given authenticator to be considered +// valid. By default an AAGUID which has a zero value should fail validation if WithValidateEntryPermitZeroAAGUID is not +// provided with the value of true. Default is true. +func WithValidateEntry(require bool) Option { + return func(provider *Provider) (err error) { + provider.entry = require + + return nil + } +} + +// WithValidateEntryPermitZeroAAGUID is an option that permits a zero'd AAGUID from an attestation statement to +// automatically pass metadata validations. Generally helpful to use with WithValidateEntry. Default is false. +func WithValidateEntryPermitZeroAAGUID(permit bool) Option { + return func(provider *Provider) (err error) { + provider.entryPermitZero = permit + + return nil + } +} + +// WithValidateTrustAnchor when set to true enables the validation of the attestation statement against the trust anchor +// from the metadata. Default is true. +func WithValidateTrustAnchor(validate bool) Option { + return func(provider *Provider) (err error) { + provider.anchors = validate + + return nil + } +} + +// WithValidateStatus when set to true enables the validation of the attestation statements AAGUID against the desired +// and undesired metadata.AuthenticatorStatus lists. Default is true. +func WithValidateStatus(validate bool) Option { + return func(provider *Provider) (err error) { + provider.status = validate + + return nil + } +} + +// WithValidateAttestationTypes when set to true enables the validation of the attestation statements type against the +// known types the authenticator can produce. Default is true. +func WithValidateAttestationTypes(validate bool) Option { + return func(provider *Provider) (err error) { + provider.status = validate + + return nil + } +} + +// WithStatusUndesired provides the list of statuses which are considered undesirable for status report validation +// purposes. Should be used with WithValidateStatus set to true. +func WithStatusUndesired(statuses []metadata.AuthenticatorStatus) Option { + return func(provider *Provider) (err error) { + provider.undesired = statuses + + return nil + } +} + +// WithStatusDesired provides the list of statuses which are considered desired and will be required for status report +// validation purposes. Should be used with WithValidateStatus set to true. +func WithStatusDesired(statuses []metadata.AuthenticatorStatus) Option { + return func(provider *Provider) (err error) { + provider.desired = statuses + + return nil + } +} diff --git a/metadata/providers/memory/provider.go b/metadata/providers/memory/provider.go new file mode 100644 index 0000000..27a8443 --- /dev/null +++ b/metadata/providers/memory/provider.go @@ -0,0 +1,93 @@ +package memory + +import ( + "context" + "fmt" + + "github.com/google/uuid" + + "github.com/go-webauthn/webauthn/metadata" +) + +// New returns a new memory Provider given a set of functional Option's. +func New(opts ...Option) (provider metadata.Provider, err error) { + p := &Provider{ + undesired: metadata.DefaultUndesiredAuthenticatorStatuses(), + entry: true, + anchors: true, + status: true, + attestation: true, + } + + for _, opt := range opts { + if err = opt(p); err != nil { + return nil, err + } + } + + if p.mds == nil { + return nil, fmt.Errorf("memory metadata provider has not been initialized with metadata") + } + + return p, nil +} + +// Provider is a concrete implementation of the metadata.Provider that utilizes memory for validation. This provider is +// a simple one-shot that doesn't perform any locking, provide dynamic functionality, or download the metadata at any +// stage (it expects it's provided via one of the Option's). +type Provider struct { + mds map[uuid.UUID]*metadata.Entry + desired []metadata.AuthenticatorStatus + undesired []metadata.AuthenticatorStatus + entry bool + entryPermitZero bool + anchors bool + status bool + attestation bool +} + +func (p *Provider) GetEntry(ctx context.Context, aaguid uuid.UUID) (entry *metadata.Entry, err error) { + if p.mds == nil { + return nil, metadata.ErrNotInitialized + } + + var ok bool + + if entry, ok = p.mds[aaguid]; ok { + return entry, nil + } + + return nil, nil +} + +func (p *Provider) GetValidateEntry(ctx context.Context) (require bool) { + return p.entry +} + +func (p *Provider) GetValidateEntryPermitZeroAAGUID(ctx context.Context) (skip bool) { + return p.entryPermitZero +} + +func (p *Provider) GetValidateTrustAnchor(ctx context.Context) (validate bool) { + return p.anchors +} + +func (p *Provider) GetValidateStatus(ctx context.Context) (validate bool) { + return p.status +} + +func (p *Provider) GetValidateAttestationTypes(ctx context.Context) (validate bool) { + return p.attestation +} + +func (p *Provider) ValidateStatusReports(ctx context.Context, reports []metadata.StatusReport) (err error) { + if !p.status { + return nil + } + + return metadata.ValidateStatusReports(reports, p.desired, p.undesired) +} + +var ( + _ metadata.Provider = (*Provider)(nil) +) diff --git a/metadata/status.go b/metadata/status.go new file mode 100644 index 0000000..158e37c --- /dev/null +++ b/metadata/status.go @@ -0,0 +1,62 @@ +package metadata + +import ( + "fmt" + "strings" +) + +// ValidateStatusReports checks a list of StatusReport's against a list of desired and undesired AuthenticatorStatus +// values. If the reports contain all of the desired and none of the undesired status reports then no error is returned +// otherwise an error describing the issue is returned. +func ValidateStatusReports(reports []StatusReport, desired, undesired []AuthenticatorStatus) (err error) { + if len(desired) == 0 && (len(undesired) == 0 || len(reports) == 0) { + return nil + } + + var present, absent []string + + if len(undesired) != 0 { + for _, report := range reports { + for _, status := range undesired { + if report.Status == status { + present = append(present, string(status)) + + continue + } + } + } + } + + if len(desired) != 0 { + desired: + for _, status := range desired { + for _, report := range reports { + if report.Status == status { + continue desired + } + } + + absent = append(absent, string(status)) + } + } + + switch { + case len(present) == 0 && len(absent) == 0: + return nil + case len(present) != 0 && len(absent) == 0: + return &MetadataError{ + Type: "invalid_status", + Details: fmt.Sprintf("The following undesired status reports were present: %s", strings.Join(present, ", ")), + } + case len(present) == 0 && len(absent) != 0: + return &MetadataError{ + Type: "invalid_status", + Details: fmt.Sprintf("The following desired status reports were absent: %s", strings.Join(absent, ", ")), + } + default: + return &MetadataError{ + Type: "invalid_status", + Details: fmt.Sprintf("The following undesired status reports were present: %s; the following desired status reports were absent: %s", strings.Join(present, ", "), strings.Join(absent, ", ")), + } + } +} diff --git a/metadata/types.go b/metadata/types.go new file mode 100644 index 0000000..1562b63 --- /dev/null +++ b/metadata/types.go @@ -0,0 +1,329 @@ +package metadata + +import ( + "context" + "errors" + "reflect" + "time" + + "github.com/google/uuid" + + "github.com/go-webauthn/webauthn/protocol/webauthncose" +) + +// The Provider is an interface which describes the elements required to satisfy validation of metadata. +type Provider interface { + // GetEntry returns a MDS3 payload entry given a AAGUID. This + GetEntry(ctx context.Context, aaguid uuid.UUID) (entry *Entry, err error) + + // GetValidateEntry returns true if this provider requires an entry to exist with a AAGUID matching the attestation + // statement during registration. + GetValidateEntry(ctx context.Context) (validate bool) + + // GetValidateEntryPermitZeroAAGUID returns true if attestation statements with zerod AAGUID should be permitted + // when considering the result from GetValidateEntry. i.e. if the AAGUID is zeroed, and GetValidateEntry returns + // true, and this implementation returns true, the attestation statement will pass validation. + GetValidateEntryPermitZeroAAGUID(ctx context.Context) (skip bool) + + // GetValidateTrustAnchor returns true if trust anchor validation of attestation statements is enforced during + // registration. + GetValidateTrustAnchor(ctx context.Context) (validate bool) + + // GetValidateStatus returns true if the status reports for an authenticator should be validated against desired and + // undesired statuses. + GetValidateStatus(ctx context.Context) (validate bool) + + // GetValidateAttestationTypes if true will enforce checking that the provided attestation is possible with the + // given authenticator. + GetValidateAttestationTypes(ctx context.Context) (validate bool) + + // ValidateStatusReports returns nil if the provided authenticator status reports are desired. + ValidateStatusReports(ctx context.Context, reports []StatusReport) (err error) +} + +var ( + ErrNotInitialized = errors.New("metadata: not initialized") +) + +type PublicKeyCredentialParameters struct { + Type string `json:"type"` + Alg webauthncose.COSEAlgorithmIdentifier `json:"alg"` +} + +type AuthenticatorAttestationTypes []AuthenticatorAttestationType + +func (t AuthenticatorAttestationTypes) HasBasicFull() bool { + for _, a := range t { + if a == BasicFull || a == AttCA { + return true + } + } + + return false +} + +// AuthenticatorAttestationType - The ATTESTATION constants are 16 bit long integers indicating the specific attestation that authenticator supports. +// Each constant has a case-sensitive string representation (in quotes), which is used in the authoritative metadata for FIDO authenticators. +type AuthenticatorAttestationType string + +const ( + // BasicFull - Indicates full basic attestation, based on an attestation private key shared among a class of authenticators (e.g. same model). Authenticators must provide its attestation signature during the registration process for the same reason. The attestation trust anchor is shared with FIDO Servers out of band (as part of the Metadata). This sharing process should be done according to [UAFMetadataService]. + BasicFull AuthenticatorAttestationType = "basic_full" + + // BasicSurrogate - Just syntactically a Basic Attestation. The attestation object self-signed, i.e. it is signed using the UAuth.priv key, i.e. the key corresponding to the UAuth.pub key included in the attestation object. As a consequence it does not provide a cryptographic proof of the security characteristics. But it is the best thing we can do if the authenticator is not able to have an attestation private key. + BasicSurrogate AuthenticatorAttestationType = "basic_surrogate" + + // Ecdaa - Indicates use of elliptic curve based direct anonymous attestation as defined in [FIDOEcdaaAlgorithm]. Support for this attestation type is optional at this time. It might be required by FIDO Certification. + Ecdaa AuthenticatorAttestationType = "ecdaa" + + // AttCA - Indicates PrivacyCA attestation as defined in [TCG-CMCProfile-AIKCertEnroll]. Support for this attestation type is optional at this time. It might be required by FIDO Certification. + AttCA AuthenticatorAttestationType = "attca" + + // AnonCA In this case, the authenticator uses an Anonymization CA which dynamically generates per-credential attestation certificates such that the attestation statements presented to Relying Parties do not provide uniquely identifiable information, e.g., that might be used for tracking purposes. The applicable [WebAuthn] attestation formats "fmt" are Google SafetyNet Attestation "android-safetynet", Android Keystore Attestation "android-key", Apple Anonymous Attestation "apple", and Apple Application Attestation "apple-appattest". + AnonCA AuthenticatorAttestationType = "anonca" + + // None - Indicates absence of attestation + None AuthenticatorAttestationType = "none" +) + +// AuthenticatorStatus - This enumeration describes the status of an authenticator model as identified by its AAID and potentially some additional information (such as a specific attestation key). +// https://fidoalliance.org/specs/mds/fido-metadata-service-v3.0-ps-20210518.html#authenticatorstatus-enum +type AuthenticatorStatus string + +const ( + // NotFidoCertified - This authenticator is not FIDO certified. + NotFidoCertified AuthenticatorStatus = "NOT_FIDO_CERTIFIED" + // FidoCertified - This authenticator has passed FIDO functional certification. This certification scheme is phased out and will be replaced by FIDO_CERTIFIED_L1. + FidoCertified AuthenticatorStatus = "FIDO_CERTIFIED" + // UserVerificationBypass - Indicates that malware is able to bypass the user verification. This means that the authenticator could be used without the user's consent and potentially even without the user's knowledge. + UserVerificationBypass AuthenticatorStatus = "USER_VERIFICATION_BYPASS" + // AttestationKeyCompromise - Indicates that an attestation key for this authenticator is known to be compromised. Additional data should be supplied, including the key identifier and the date of compromise, if known. + AttestationKeyCompromise AuthenticatorStatus = "ATTESTATION_KEY_COMPROMISE" + // UserKeyRemoteCompromise - This authenticator has identified weaknesses that allow registered keys to be compromised and should not be trusted. This would include both, e.g. weak entropy that causes predictable keys to be generated or side channels that allow keys or signatures to be forged, guessed or extracted. + UserKeyRemoteCompromise AuthenticatorStatus = "USER_KEY_REMOTE_COMPROMISE" + // UserKeyPhysicalCompromise - This authenticator has known weaknesses in its key protection mechanism(s) that allow user keys to be extracted by an adversary in physical possession of the device. + UserKeyPhysicalCompromise AuthenticatorStatus = "USER_KEY_PHYSICAL_COMPROMISE" + // UpdateAvailable - A software or firmware update is available for the device. Additional data should be supplied including a URL where users can obtain an update and the date the update was published. + UpdateAvailable AuthenticatorStatus = "UPDATE_AVAILABLE" + // Revoked - The FIDO Alliance has determined that this authenticator should not be trusted for any reason, for example if it is known to be a fraudulent product or contain a deliberate backdoor. + Revoked AuthenticatorStatus = "REVOKED" + // SelfAssertionSubmitted - The authenticator vendor has completed and submitted the self-certification checklist to the FIDO Alliance. If this completed checklist is publicly available, the URL will be specified in StatusReportJSON.url. + SelfAssertionSubmitted AuthenticatorStatus = "SELF_ASSERTION_SUBMITTED" + // FidoCertifiedL1 - The authenticator has passed FIDO Authenticator certification at level 1. This level is the more strict successor of FIDO_CERTIFIED. + FidoCertifiedL1 AuthenticatorStatus = "FIDO_CERTIFIED_L1" + // FidoCertifiedL1plus - The authenticator has passed FIDO Authenticator certification at level 1+. This level is the more than level 1. + FidoCertifiedL1plus AuthenticatorStatus = "FIDO_CERTIFIED_L1plus" + // FidoCertifiedL2 - The authenticator has passed FIDO Authenticator certification at level 2. This level is more strict than level 1+. + FidoCertifiedL2 AuthenticatorStatus = "FIDO_CERTIFIED_L2" + // FidoCertifiedL2plus - The authenticator has passed FIDO Authenticator certification at level 2+. This level is more strict than level 2. + FidoCertifiedL2plus AuthenticatorStatus = "FIDO_CERTIFIED_L2plus" + // FidoCertifiedL3 - The authenticator has passed FIDO Authenticator certification at level 3. This level is more strict than level 2+. + FidoCertifiedL3 AuthenticatorStatus = "FIDO_CERTIFIED_L3" + // FidoCertifiedL3plus - The authenticator has passed FIDO Authenticator certification at level 3+. This level is more strict than level 3. + FidoCertifiedL3plus AuthenticatorStatus = "FIDO_CERTIFIED_L3plus" +) + +// defaultUndesiredAuthenticatorStatus is an array of undesirable authenticator statuses +var defaultUndesiredAuthenticatorStatus = [...]AuthenticatorStatus{ + AttestationKeyCompromise, + UserVerificationBypass, + UserKeyRemoteCompromise, + UserKeyPhysicalCompromise, + Revoked, +} + +// IsUndesiredAuthenticatorStatus returns whether the supplied authenticator status is desirable or not +func IsUndesiredAuthenticatorStatus(status AuthenticatorStatus) bool { + for _, s := range defaultUndesiredAuthenticatorStatus { + if s == status { + return true + } + } + + return false +} + +// IsUndesiredAuthenticatorStatusSlice returns whether the supplied authenticator status is desirable or not +func IsUndesiredAuthenticatorStatusSlice(status AuthenticatorStatus, values []AuthenticatorStatus) bool { + for _, s := range values { + if s == status { + return true + } + } + + return false +} + +// IsUndesiredAuthenticatorStatusMap returns whether the supplied authenticator status is desirable or not +func IsUndesiredAuthenticatorStatusMap(status AuthenticatorStatus, values map[AuthenticatorStatus]bool) bool { + _, ok := values[status] + + return ok +} + +type AuthenticationAlgorithm string + +const ( + // ALG_SIGN_SECP256R1_ECDSA_SHA256_RAW is an ECDSA signature on the NIST secp256r1 curve which must have raw R and + // S buffers, encoded in big-endian order. + ALG_SIGN_SECP256R1_ECDSA_SHA256_RAW AuthenticationAlgorithm = "secp256r1_ecdsa_sha256_raw" + + // ALG_SIGN_SECP256R1_ECDSA_SHA256_DER is a DER ITU-X690-2008 encoded ECDSA signature RFC5480 on the NIST secp256r1 + // curve. + ALG_SIGN_SECP256R1_ECDSA_SHA256_DER AuthenticationAlgorithm = "secp256r1_ecdsa_sha256_der" + + // ALG_SIGN_RSASSA_PSS_SHA256_RAW is a RSASSA-PSS RFC3447 signature must have raw S buffers, encoded in big-endian + // order RFC4055 RFC4056. + ALG_SIGN_RSASSA_PSS_SHA256_RAW AuthenticationAlgorithm = "rsassa_pss_sha256_raw" + + // ALG_SIGN_RSASSA_PSS_SHA256_DER is a DER ITU-X690-2008 encoded OCTET STRING (not BIT STRING!) containing the + // RSASSA-PSS RFC3447 signature RFC4055 RFC4056. + ALG_SIGN_RSASSA_PSS_SHA256_DER AuthenticationAlgorithm = "rsassa_pss_sha256_der" + + // ALG_SIGN_SECP256K1_ECDSA_SHA256_RAW is an ECDSA signature on the secp256k1 curve which must have raw R and S + // buffers, encoded in big-endian order. + ALG_SIGN_SECP256K1_ECDSA_SHA256_RAW AuthenticationAlgorithm = "secp256k1_ecdsa_sha256_raw" + + // ALG_SIGN_SECP256K1_ECDSA_SHA256_DER is a DER ITU-X690-2008 encoded ECDSA signature RFC5480 on the secp256k1 curve. + ALG_SIGN_SECP256K1_ECDSA_SHA256_DER AuthenticationAlgorithm = "secp256k1_ecdsa_sha256_der" + + // ALG_SIGN_SM2_SM3_RAW is a Chinese SM2 elliptic curve based signature algorithm combined with SM3 hash algorithm + // OSCCA-SM2 OSCCA-SM3. + ALG_SIGN_SM2_SM3_RAW AuthenticationAlgorithm = "sm2_sm3_raw" + + // ALG_SIGN_RSA_EMSA_PKCS1_SHA256_RAW is the EMSA-PKCS1-v1_5 signature as defined in RFC3447. + ALG_SIGN_RSA_EMSA_PKCS1_SHA256_RAW AuthenticationAlgorithm = "rsa_emsa_pkcs1_sha256_raw" + + // ALG_SIGN_RSA_EMSA_PKCS1_SHA256_DER is a DER ITU-X690-2008 encoded OCTET STRING (not BIT STRING!) containing the + // EMSA-PKCS1-v1_5 signature as defined in RFC3447. + ALG_SIGN_RSA_EMSA_PKCS1_SHA256_DER AuthenticationAlgorithm = "rsa_emsa_pkcs1_sha256_der" + + // ALG_SIGN_RSASSA_PSS_SHA384_RAW is a RSASSA-PSS RFC3447 signature must have raw S buffers, encoded in big-endian + // order RFC4055 RFC4056. + ALG_SIGN_RSASSA_PSS_SHA384_RAW AuthenticationAlgorithm = "rsassa_pss_sha384_raw" + + // ALG_SIGN_RSASSA_PSS_SHA512_RAW is a RSASSA-PSS RFC3447 signature must have raw S buffers, encoded in big-endian + // order RFC4055 RFC4056. + ALG_SIGN_RSASSA_PSS_SHA512_RAW AuthenticationAlgorithm = "rsassa_pss_sha512_raw" + + // ALG_SIGN_RSASSA_PKCSV15_SHA256_RAW is a RSASSA-PKCS1-v1_5 RFC3447 with SHA256(aka RS256) signature must have raw + // S buffers, encoded in big-endian order RFC8017 RFC4056 + ALG_SIGN_RSASSA_PKCSV15_SHA256_RAW AuthenticationAlgorithm = "rsassa_pkcsv15_sha256_raw" + + // RSASSA-PKCS1-v1_5 RFC3447 with SHA384(aka RS384) signature must have raw S buffers, encoded in big-endian order RFC8017 RFC4056 + ALG_SIGN_RSASSA_PKCSV15_SHA384_RAW AuthenticationAlgorithm = "rsassa_pkcsv15_sha384_raw" + + // ALG_SIGN_RSASSA_PKCSV15_SHA512_RAW is a RSASSA-PKCS1-v1_5 RFC3447 with SHA512(aka RS512) signature must have raw + // S buffers, encoded in big-endian order RFC8017 RFC4056 + ALG_SIGN_RSASSA_PKCSV15_SHA512_RAW AuthenticationAlgorithm = "rsassa_pkcsv15_sha512_raw" + + // ALG_SIGN_RSASSA_PKCSV15_SHA1_RAW is a RSASSA-PKCS1-v1_5 RFC3447 with SHA1(aka RS1) signature must have raw S + // buffers, encoded in big-endian order RFC8017 RFC4056 + ALG_SIGN_RSASSA_PKCSV15_SHA1_RAW AuthenticationAlgorithm = "rsassa_pkcsv15_sha1_raw" + + // ALG_SIGN_SECP384R1_ECDSA_SHA384_RAW is an ECDSA signature on the NIST secp384r1 curve with SHA384(aka: ES384) + // which must have raw R and S buffers, encoded in big-endian order. + ALG_SIGN_SECP384R1_ECDSA_SHA384_RAW AuthenticationAlgorithm = "secp384r1_ecdsa_sha384_raw" + + // ALG_SIGN_SECP521R1_ECDSA_SHA512_RAW is an ECDSA signature on the NIST secp512r1 curve with SHA512(aka: ES512) + // which must have raw R and S buffers, encoded in big-endian order. + ALG_SIGN_SECP521R1_ECDSA_SHA512_RAW AuthenticationAlgorithm = "secp521r1_ecdsa_sha512_raw" + + // ALG_SIGN_ED25519_EDDSA_SHA512_RAW is an EdDSA signature on the curve 25519, which must have raw R and S buffers, + // encoded in big-endian order. + ALG_SIGN_ED25519_EDDSA_SHA512_RAW AuthenticationAlgorithm = "ed25519_eddsa_sha512_raw" + + // ALG_SIGN_ED448_EDDSA_SHA512_RAW is an EdDSA signature on the curve Ed448, which must have raw R and S buffers, + // encoded in big-endian order. + ALG_SIGN_ED448_EDDSA_SHA512_RAW AuthenticationAlgorithm = "ed448_eddsa_sha512_raw" +) + +// TODO: this goes away after webauthncose.CredentialPublicKey gets implemented +type algKeyCose struct { + KeyType webauthncose.COSEKeyType + Algorithm webauthncose.COSEAlgorithmIdentifier + Curve webauthncose.COSEEllipticCurve +} + +func algKeyCoseDictionary() func(AuthenticationAlgorithm) algKeyCose { + mapping := map[AuthenticationAlgorithm]algKeyCose{ + ALG_SIGN_SECP256R1_ECDSA_SHA256_RAW: {KeyType: webauthncose.EllipticKey, Algorithm: webauthncose.AlgES256, Curve: webauthncose.P256}, + ALG_SIGN_SECP256R1_ECDSA_SHA256_DER: {KeyType: webauthncose.EllipticKey, Algorithm: webauthncose.AlgES256, Curve: webauthncose.P256}, + ALG_SIGN_RSASSA_PSS_SHA256_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgPS256}, + ALG_SIGN_RSASSA_PSS_SHA256_DER: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgPS256}, + ALG_SIGN_SECP256K1_ECDSA_SHA256_RAW: {KeyType: webauthncose.EllipticKey, Algorithm: webauthncose.AlgES256K, Curve: webauthncose.Secp256k1}, + ALG_SIGN_SECP256K1_ECDSA_SHA256_DER: {KeyType: webauthncose.EllipticKey, Algorithm: webauthncose.AlgES256K, Curve: webauthncose.Secp256k1}, + ALG_SIGN_RSASSA_PSS_SHA384_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgPS384}, + ALG_SIGN_RSASSA_PSS_SHA512_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgPS512}, + ALG_SIGN_RSASSA_PKCSV15_SHA256_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgRS256}, + ALG_SIGN_RSASSA_PKCSV15_SHA384_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgRS384}, + ALG_SIGN_RSASSA_PKCSV15_SHA512_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgRS512}, + ALG_SIGN_RSASSA_PKCSV15_SHA1_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgRS1}, + ALG_SIGN_SECP384R1_ECDSA_SHA384_RAW: {KeyType: webauthncose.EllipticKey, Algorithm: webauthncose.AlgES384, Curve: webauthncose.P384}, + ALG_SIGN_SECP521R1_ECDSA_SHA512_RAW: {KeyType: webauthncose.EllipticKey, Algorithm: webauthncose.AlgES512, Curve: webauthncose.P521}, + ALG_SIGN_ED25519_EDDSA_SHA512_RAW: {KeyType: webauthncose.OctetKey, Algorithm: webauthncose.AlgEdDSA, Curve: webauthncose.Ed25519}, + ALG_SIGN_ED448_EDDSA_SHA512_RAW: {KeyType: webauthncose.OctetKey, Algorithm: webauthncose.AlgEdDSA, Curve: webauthncose.Ed448}, + } + + return func(key AuthenticationAlgorithm) algKeyCose { + return mapping[key] + } +} + +func AlgKeyMatch(key algKeyCose, algs []AuthenticationAlgorithm) bool { + for _, alg := range algs { + if reflect.DeepEqual(algKeyCoseDictionary()(alg), key) { + return true + } + } + + return false +} + +type PublicKeyAlgAndEncoding string + +const ( + // ALG_KEY_ECC_X962_RAW is a raw ANSI X9.62 formatted Elliptic Curve public key. + ALG_KEY_ECC_X962_RAW PublicKeyAlgAndEncoding = "ecc_x962_raw" + + // ALG_KEY_ECC_X962_DER is a DER ITU-X690-2008 encoded ANSI X.9.62 formatted SubjectPublicKeyInfo RFC5480 specifying an elliptic curve public key. + ALG_KEY_ECC_X962_DER PublicKeyAlgAndEncoding = "ecc_x962_der" + + // ALG_KEY_RSA_2048_RAW is a raw encoded 2048-bit RSA public key RFC3447. + ALG_KEY_RSA_2048_RAW PublicKeyAlgAndEncoding = "rsa_2048_raw" + + // ALG_KEY_RSA_2048_DER is a ASN.1 DER [ITU-X690-2008] encoded 2048-bit RSA RFC3447 public key RFC4055. + ALG_KEY_RSA_2048_DER PublicKeyAlgAndEncoding = "rsa_2048_der" + + // ALG_KEY_COSE is a COSE_Key format, as defined in Section 7 of RFC8152. This encoding includes its own field for indicating the public key algorithm. + ALG_KEY_COSE PublicKeyAlgAndEncoding = "cose" +) + +type MetadataError struct { + // Short name for the type of error that has occurred. + Type string `json:"type"` + + // Additional details about the error. + Details string `json:"error"` + + // Information to help debug the error. + DevInfo string `json:"debug"` +} + +func (err *MetadataError) Error() string { + return err.Details +} + +// Clock is an interface used to implement clock functionality in various metadata areas. +type Clock interface { + // Now returns the current time. + Now() time.Time +} + +// RealClock is just a real clock. +type RealClock struct{} + +// Now returns the current time. +func (RealClock) Now() time.Time { + return time.Now() +} diff --git a/protocol/attestation.go b/protocol/attestation.go index 36a264a..adddce3 100644 --- a/protocol/attestation.go +++ b/protocol/attestation.go @@ -1,6 +1,7 @@ package protocol import ( + "context" "crypto/sha256" "crypto/x509" "encoding/json" @@ -66,15 +67,18 @@ type ParsedAttestationResponse struct { type AttestationObject struct { // The authenticator data, including the newly created public key. See AuthenticatorData for more info AuthData AuthenticatorData + // The byteform version of the authenticator data, used in part for signature validation RawAuthData []byte `json:"authData"` + // The format of the Attestation data. Format string `json:"fmt"` + // The attestation statement data sent back if attestation is requested. AttStatement map[string]any `json:"attStmt,omitempty"` } -type attestationFormatValidationHandler func(AttestationObject, []byte) (string, []any, error) +type attestationFormatValidationHandler func(AttestationObject, []byte, metadata.Provider) (string, []any, error) var attestationRegistry = make(map[AttestationFormat]attestationFormatValidationHandler) @@ -120,15 +124,20 @@ func (ccr *AuthenticatorAttestationResponse) Parse() (p *ParsedAttestationRespon // // Steps 9 through 12 are verified against the auth data. These steps are identical to 11 through 14 for assertion so we // handle them with AuthData. -func (attestationObject *AttestationObject) Verify(relyingPartyID string, clientDataHash []byte, verificationRequired bool) error { +func (a *AttestationObject) Verify(relyingPartyID string, clientDataHash []byte, userVerificationRequired bool, mds metadata.Provider) (err error) { rpIDHash := sha256.Sum256([]byte(relyingPartyID)) // Begin Step 9 through 12. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID expected by the RP. - authDataVerificationError := attestationObject.AuthData.Verify(rpIDHash[:], nil, verificationRequired) - if authDataVerificationError != nil { - return authDataVerificationError + if err = a.AuthData.Verify(rpIDHash[:], nil, userVerificationRequired); err != nil { + return err } + return a.VerifyAttestation(clientDataHash, mds) +} + +// VerifyAttestation only verifies the attestation object excluding the AuthData values. If you wish to also verify the +// AuthData values you should use Verify. +func (a *AttestationObject) VerifyAttestation(clientDataHash []byte, mds metadata.Provider) (err error) { // Step 13. Determine the attestation statement format by performing a // USASCII case-sensitive match on fmt against the set of supported // WebAuthn Attestation Statement Format Identifier values. The up-to-date @@ -141,61 +150,114 @@ func (attestationObject *AttestationObject) Verify(relyingPartyID string, client // But first let's make sure attestation is present. If it isn't, we don't need to handle // any of the following steps - if AttestationFormat(attestationObject.Format) == AttestationFormatNone { - if len(attestationObject.AttStatement) != 0 { + if AttestationFormat(a.Format) == AttestationFormatNone { + if len(a.AttStatement) != 0 { return ErrAttestationFormat.WithInfo("Attestation format none with attestation present") } return nil } - formatHandler, valid := attestationRegistry[AttestationFormat(attestationObject.Format)] + formatHandler, valid := attestationRegistry[AttestationFormat(a.Format)] if !valid { - return ErrAttestationFormat.WithInfo(fmt.Sprintf("Attestation format %s is unsupported", attestationObject.Format)) + return ErrAttestationFormat.WithInfo(fmt.Sprintf("Attestation format %s is unsupported", a.Format)) } // Step 14. Verify that attStmt is a correct attestation statement, conveying a valid attestation signature, by using // the attestation statement format fmt’s verification procedure given attStmt, authData and the hash of the serialized // client data computed in step 7. - attestationType, x5c, err := formatHandler(*attestationObject, clientDataHash) + attestationType, x5cs, err := formatHandler(*a, clientDataHash, mds) if err != nil { return err.(*Error).WithInfo(attestationType) } - aaguid, err := uuid.FromBytes(attestationObject.AuthData.AttData.AAGUID) - if err != nil { - return err + var ( + aaguid uuid.UUID + entry *metadata.Entry + ) + + if len(a.AuthData.AttData.AAGUID) != 0 { + if aaguid, err = uuid.FromBytes(a.AuthData.AttData.AAGUID); err != nil { + return ErrInvalidAttestation.WithInfo("Error occurred parsing AAGUID during attestation validation").WithDetails(err.Error()) + } } - if meta, ok := metadata.Metadata[aaguid]; ok { - for _, s := range meta.StatusReports { - if metadata.IsUndesiredAuthenticatorStatus(s.Status) { - return ErrInvalidAttestation.WithDetails("Authenticator with undesirable status encountered") - } + if mds == nil { + return nil + } + + ctx := context.Background() + + if entry, err = mds.GetEntry(ctx, aaguid); err != nil { + return ErrInvalidAttestation.WithInfo(fmt.Sprintf("Error occurred retrieving metadata entry during attestation validation: %+v", err)).WithDetails(fmt.Sprintf("Error occurred looking up entry for AAGUID %s", aaguid.String())) + } + + if entry == nil { + if aaguid == uuid.Nil && mds.GetValidateEntryPermitZeroAAGUID(ctx) { + return nil } - if x5c != nil { - x5cAtt, err := x509.ParseCertificate(x5c[0].([]byte)) - if err != nil { - return ErrInvalidAttestation.WithDetails("Unable to parse attestation certificate from x5c") + if mds.GetValidateEntry(ctx) { + return ErrInvalidAttestation.WithDetails(fmt.Sprintf("AAGUID %s not found in metadata during attestation validation", aaguid.String())) + } + + return nil + } + + if mds.GetValidateAttestationTypes(ctx) { + found := false + + for _, atype := range entry.MetadataStatement.AttestationTypes { + if string(atype) == attestationType { + found = true + + break } + } + + if !found { + return ErrInvalidAttestation.WithDetails(fmt.Sprintf("Authenticator with invalid attestation type encountered during attestation validation. The attestation type '%s' is not known to be used by AAGUID '%s'", attestationType, aaguid.String())) + } + } + + if mds.GetValidateStatus(ctx) { + if err = mds.ValidateStatusReports(ctx, entry.StatusReports); err != nil { + return ErrInvalidAttestation.WithDetails(fmt.Sprintf("Authenticator with invalid status encountered during attestation validation. %s", err.Error())) + } + } + + if mds.GetValidateTrustAnchor(ctx) { + if x5cs == nil { + return nil + } + + var ( + x5c *x509.Certificate + raw []byte + ok bool + ) + + if len(x5cs) == 0 { + return ErrInvalidAttestation.WithDetails("Unable to parse attestation certificate from x5c during attestation validation").WithInfo("The attestation had no certificates") + } - if x5cAtt.Subject.CommonName != x5cAtt.Issuer.CommonName { - var hasBasicFull = false + if raw, ok = x5cs[0].([]byte); !ok { + return ErrInvalidAttestation.WithDetails("Unable to parse attestation certificate from x5c during attestation validation").WithInfo(fmt.Sprintf("The first certificate in the attestation was type '%T' but '[]byte' was expected", x5cs[0])) + } - for _, a := range meta.MetadataStatement.AttestationTypes { - if a == metadata.BasicFull || a == metadata.AttCA { - hasBasicFull = true - } - } + if x5c, err = x509.ParseCertificate(raw); err != nil { + return ErrInvalidAttestation.WithDetails("Unable to parse attestation certificate from x5c during attestation validation").WithInfo(fmt.Sprintf("Error returned from x509.ParseCertificate: %+v", err)) + } + + if x5c.Subject.CommonName != x5c.Issuer.CommonName { + if !entry.MetadataStatement.AttestationTypes.HasBasicFull() { + return ErrInvalidAttestation.WithDetails("Unable to validate attestation statement signature during attestation validation: attestation with full attestation from authenticator that does not support full attestation") + } - if !hasBasicFull { - return ErrInvalidAttestation.WithDetails("Attestation with full attestation from authenticator that does not support full attestation") - } + if _, err = x5c.Verify(entry.MetadataStatement.Verifier()); err != nil { + return ErrInvalidAttestation.WithDetails(fmt.Sprintf("Unable to validate attestation signature statement during attestation validation: invalid certificate chain from MDS: %v", err)) } } - } else if metadata.Conformance { - return ErrInvalidAttestation.WithDetails(fmt.Sprintf("AAGUID %s not found in metadata during conformance testing", aaguid.String())) } return nil diff --git a/protocol/attestation_androidkey.go b/protocol/attestation_androidkey.go index b8551ca..2201303 100644 --- a/protocol/attestation_androidkey.go +++ b/protocol/attestation_androidkey.go @@ -29,26 +29,26 @@ func init() { // } // // Specification: §8.4. Android Key Attestation Statement Format (https://www.w3.org/TR/webauthn/#sctn-android-key-attestation) -func verifyAndroidKeyFormat(att AttestationObject, clientDataHash []byte) (string, []any, error) { +func verifyAndroidKeyFormat(att AttestationObject, clientDataHash []byte, _ metadata.Provider) (string, []any, error) { // Given the verification procedure inputs attStmt, authenticatorData and clientDataHash, the verification procedure is as follows: // §8.4.1. Verify that attStmt is valid CBOR conforming to the syntax defined above and perform CBOR decoding on it to extract // the contained fields. // Get the alg value - A COSEAlgorithmIdentifier containing the identifier of the algorithm // used to generate the attestation signature. - alg, present := att.AttStatement["alg"].(int64) + alg, present := att.AttStatement[stmtAlgorithm].(int64) if !present { return "", nil, ErrAttestationFormat.WithDetails("Error retrieving alg value") } // Get the sig value - A byte string containing the attestation signature. - sig, present := att.AttStatement["sig"].([]byte) + sig, present := att.AttStatement[stmtSignature].([]byte) if !present { return "", nil, ErrAttestationFormat.WithDetails("Error retrieving sig value") } // If x5c is not present, return an error - x5c, x509present := att.AttStatement["x5c"].([]any) + x5c, x509present := att.AttStatement[stmtX5C].([]any) if !x509present { // Handle Basic Attestation steps for the x509 Certificate return "", nil, ErrAttestationFormat.WithDetails("Error retrieving x5c value") diff --git a/protocol/attestation_androidkey_test.go b/protocol/attestation_androidkey_test.go index 73266c8..e6fd426 100644 --- a/protocol/attestation_androidkey_test.go +++ b/protocol/attestation_androidkey_test.go @@ -49,7 +49,7 @@ func TestVerifyAndroidKeyFormat(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, _, err := verifyAndroidKeyFormat(tt.args.att, tt.args.clientDataHash) + got, _, err := verifyAndroidKeyFormat(tt.args.att, tt.args.clientDataHash, nil) if (err != nil) != tt.wantErr { t.Errorf("verifyAndroidKeyFormat() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/protocol/attestation_apple.go b/protocol/attestation_apple.go index 0e7be7c..c828e7b 100644 --- a/protocol/attestation_apple.go +++ b/protocol/attestation_apple.go @@ -31,12 +31,12 @@ func init() { // } // // Specification: §8.8. Apple Anonymous Attestation Statement Format (https://www.w3.org/TR/webauthn/#sctn-apple-anonymous-attestation) -func verifyAppleFormat(att AttestationObject, clientDataHash []byte) (string, []any, error) { +func verifyAppleFormat(att AttestationObject, clientDataHash []byte, _ metadata.Provider) (string, []any, error) { // Step 1. Verify that attStmt is valid CBOR conforming to the syntax defined // above and perform CBOR decoding on it to extract the contained fields. // If x5c is not present, return an error - x5c, x509present := att.AttStatement["x5c"].([]any) + x5c, x509present := att.AttStatement[stmtX5C].([]any) if !x509present { // Handle Basic Attestation steps for the x509 Certificate return "", nil, ErrAttestationFormat.WithDetails("Error retrieving x5c value") diff --git a/protocol/attestation_apple_test.go b/protocol/attestation_apple_test.go index fc42ff6..d795f95 100644 --- a/protocol/attestation_apple_test.go +++ b/protocol/attestation_apple_test.go @@ -37,7 +37,7 @@ func Test_verifyAppleFormat(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, _, err := verifyAppleFormat(tt.args.att, tt.args.clientDataHash) + got, _, err := verifyAppleFormat(tt.args.att, tt.args.clientDataHash, nil) if (err != nil) != tt.wantErr { t.Errorf("verifyAppleFormat() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/protocol/attestation_packed.go b/protocol/attestation_packed.go index c33a904..7176947 100644 --- a/protocol/attestation_packed.go +++ b/protocol/attestation_packed.go @@ -34,26 +34,26 @@ func init() { // } // // Specification: §8.2. Packed Attestation Statement Format (https://www.w3.org/TR/webauthn/#sctn-packed-attestation) -func verifyPackedFormat(att AttestationObject, clientDataHash []byte) (string, []any, error) { +func verifyPackedFormat(att AttestationObject, clientDataHash []byte, _ metadata.Provider) (string, []any, error) { // Step 1. Verify that attStmt is valid CBOR conforming to the syntax defined // above and perform CBOR decoding on it to extract the contained fields. // Get the alg value - A COSEAlgorithmIdentifier containing the identifier of the algorithm // used to generate the attestation signature. - alg, present := att.AttStatement["alg"].(int64) + alg, present := att.AttStatement[stmtAlgorithm].(int64) if !present { return string(AttestationFormatPacked), nil, ErrAttestationFormat.WithDetails("Error retrieving alg value") } // Get the sig value - A byte string containing the attestation signature. - sig, present := att.AttStatement["sig"].([]byte) + sig, present := att.AttStatement[stmtSignature].([]byte) if !present { return string(AttestationFormatPacked), nil, ErrAttestationFormat.WithDetails("Error retrieving sig value") } // Step 2. If x5c is present, this indicates that the attestation type is not ECDAA. - x5c, x509present := att.AttStatement["x5c"].([]any) + x5c, x509present := att.AttStatement[stmtX5C].([]any) if x509present { // Handle Basic Attestation steps for the x509 Certificate return handleBasicAttestation(sig, clientDataHash, att.RawAuthData, att.AuthData.AttData.AAGUID, alg, x5c) @@ -61,7 +61,7 @@ func verifyPackedFormat(att AttestationObject, clientDataHash []byte) (string, [ // Step 3. If ecdaaKeyId is present, then the attestation type is ECDAA. // Also make sure the we did not have an x509 then - ecdaaKeyID, ecdaaKeyPresent := att.AttStatement["ecdaaKeyId"].([]byte) + ecdaaKeyID, ecdaaKeyPresent := att.AttStatement[stmtECDAAKID].([]byte) if ecdaaKeyPresent { // Handle ECDAA Attestation steps for the x509 Certificate return handleECDAAAttestation(sig, clientDataHash, ecdaaKeyID) diff --git a/protocol/attestation_packed_test.go b/protocol/attestation_packed_test.go index 66e8b6b..7f22266 100644 --- a/protocol/attestation_packed_test.go +++ b/protocol/attestation_packed_test.go @@ -61,7 +61,7 @@ func Test_verifyPackedFormat(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, _, err := verifyPackedFormat(tt.args.att, tt.args.clientDataHash) + got, _, err := verifyPackedFormat(tt.args.att, tt.args.clientDataHash, nil) if (err != nil) != tt.wantErr { t.Errorf("verifyPackedFormat() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/protocol/attestation_safetynet.go b/protocol/attestation_safetynet.go index 954541d..849cda1 100644 --- a/protocol/attestation_safetynet.go +++ b/protocol/attestation_safetynet.go @@ -2,6 +2,7 @@ package protocol import ( "bytes" + "context" "crypto/sha256" "crypto/x509" "encoding/base64" @@ -40,7 +41,7 @@ type SafetyNetResponse struct { // authenticators SHOULD make use of the Android Key Attestation when available, even if the SafetyNet API is also present. // // Specification: §8.5. Android SafetyNet Attestation Statement Format (https://www.w3.org/TR/webauthn/#sctn-android-safetynet-attestation) -func verifySafetyNetFormat(att AttestationObject, clientDataHash []byte) (string, []any, error) { +func verifySafetyNetFormat(att AttestationObject, clientDataHash []byte, mds metadata.Provider) (string, []any, error) { // The syntax of an Android Attestation statement is defined as follows: // $$attStmtType //= ( // fmt: "android-safetynet", @@ -57,7 +58,7 @@ func verifySafetyNetFormat(att AttestationObject, clientDataHash []byte) (string // We have done this // §8.5.2 Verify that response is a valid SafetyNet response of version ver. - version, present := att.AttStatement["ver"].(string) + version, present := att.AttStatement[stmtVersion].(string) if !present { return "", nil, ErrAttestationFormat.WithDetails("Unable to find the version of SafetyNet") } @@ -74,7 +75,7 @@ func verifySafetyNetFormat(att AttestationObject, clientDataHash []byte) (string } token, err := jwt.Parse(string(response), func(token *jwt.Token) (any, error) { - chain := token.Header["x5c"].([]any) + chain := token.Header[stmtX5C].([]any) o := make([]byte, base64.StdEncoding.DecodedLen(len(chain[0].(string)))) @@ -108,7 +109,7 @@ func verifySafetyNetFormat(att AttestationObject, clientDataHash []byte) (string } // §8.5.4 Let attestationCert be the attestation certificate (https://www.w3.org/TR/webauthn/#attestation-certificate) - certChain := token.Header["x5c"].([]any) + certChain := token.Header[stmtX5C].([]any) l := make([]byte, base64.StdEncoding.DecodedLen(len(certChain[0].(string)))) n, err := base64.StdEncoding.Decode(l, []byte(certChain[0].(string))) @@ -132,19 +133,13 @@ func verifySafetyNetFormat(att AttestationObject, clientDataHash []byte) (string return "", nil, ErrInvalidAttestation.WithDetails("ctsProfileMatch attribute of the JWT payload is false") } - // Verify sanity of timestamp in the payload - now := time.Now() - oneMinuteAgo := now.Add(-time.Minute) - - if t := time.Unix(safetyNetResponse.TimestampMs/1000, 0); t.After(now) { - // zero tolerance for post-dated timestamps + if t := time.Unix(safetyNetResponse.TimestampMs/1000, 0); t.After(time.Now()) { + // Zero tolerance for post-dated timestamps. return "", nil, ErrInvalidAttestation.WithDetails("SafetyNet response with timestamp after current time") - } else if t.Before(oneMinuteAgo) { - // allow old timestamp for testing purposes - // TODO: Make this user configurable - msg := "SafetyNet response with timestamp before one minute ago" - if metadata.Conformance { - return "", nil, ErrInvalidAttestation.WithDetails(msg) + } else if t.Before(time.Now().Add(-time.Minute)) { + // Small tolerance for pre-dated timestamps. + if mds != nil && mds.GetValidateEntry(context.Background()) { + return "", nil, ErrInvalidAttestation.WithDetails("SafetyNet response with timestamp before one minute ago") } } diff --git a/protocol/attestation_safetynet_test.go b/protocol/attestation_safetynet_test.go index d40ba30..0f4626b 100644 --- a/protocol/attestation_safetynet_test.go +++ b/protocol/attestation_safetynet_test.go @@ -38,7 +38,7 @@ func Test_verifySafetyNetFormat(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, got1, err := verifySafetyNetFormat(tt.args.att, tt.args.clientDataHash) + got, got1, err := verifySafetyNetFormat(tt.args.att, tt.args.clientDataHash, nil) if (err != nil) != tt.wantErr { t.Errorf("verifySafetyNetFormat() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/protocol/attestation_test.go b/protocol/attestation_test.go index 15831c4..86caaf4 100644 --- a/protocol/attestation_test.go +++ b/protocol/attestation_test.go @@ -6,70 +6,46 @@ import ( "fmt" "testing" - "github.com/go-webauthn/webauthn/metadata" + "github.com/stretchr/testify/require" ) func TestAttestationVerify(t *testing.T) { - if err := metadata.PopulateMetadata(metadata.ProductionMDSURL); err != nil { - t.Fatal(err) - } - for i := range testAttestationOptions { t.Run(fmt.Sprintf("Running test %d", i), func(t *testing.T) { options := CredentialCreation{} - if err := json.Unmarshal([]byte(testAttestationOptions[i]), &options); err != nil { - t.Fatal(err) - } + + require.NoError(t, json.Unmarshal([]byte(testAttestationOptions[i]), &options)) ccr := CredentialCreationResponse{} - if err := json.Unmarshal([]byte(testAttestationResponses[i]), &ccr); err != nil { - t.Fatal(err) - } + require.NoError(t, json.Unmarshal([]byte(testAttestationResponses[i]), &ccr)) var pcc ParsedCredentialCreationData pcc.ID, pcc.RawID, pcc.Type, pcc.ClientExtensionResults = ccr.ID, ccr.RawID, ccr.Type, ccr.ClientExtensionResults pcc.Raw = ccr parsedAttestationResponse, err := ccr.AttestationResponse.Parse() - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) pcc.Response = *parsedAttestationResponse - // Test Base Verification - err = pcc.Verify(options.Response.Challenge.String(), false, options.Response.RelyingParty.ID, []string{options.Response.RelyingParty.Name}, nil, TopOriginIgnoreVerificationMode) - if err != nil { - t.Fatalf("Not valid: %+v (%s)", err, err.(*Error).DevInfo) - } - }) - } -} - -func attestationTestUnpackRequest(t *testing.T, request string) CredentialCreation { - options := CredentialCreation{} + _, err = pcc.Verify(options.Response.Challenge.String(), false, options.Response.RelyingParty.ID, []string{options.Response.RelyingParty.Name}, nil, TopOriginIgnoreVerificationMode, nil) - if err := json.Unmarshal([]byte(request), &options); err != nil { - t.Fatal(err) + require.NoError(t, err) + }) } - - return options } func attestationTestUnpackResponse(t *testing.T, response string) (pcc ParsedCredentialCreationData) { ccr := CredentialCreationResponse{} - if err := json.Unmarshal([]byte(response), &ccr); err != nil { - t.Fatal(err) - } + + require.NoError(t, json.Unmarshal([]byte(response), &ccr)) pcc.ID, pcc.RawID, pcc.Type, pcc.ClientExtensionResults = ccr.ID, ccr.RawID, ccr.Type, ccr.ClientExtensionResults pcc.Raw = ccr parsedAttestationResponse, err := ccr.AttestationResponse.Parse() - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) pcc.Response = *parsedAttestationResponse @@ -77,17 +53,14 @@ func attestationTestUnpackResponse(t *testing.T, response string) (pcc ParsedCre } func TestPackedAttestationVerification(t *testing.T) { - t.Run("Testing Self Packed", func(t *testing.T) { pcc := attestationTestUnpackResponse(t, testAttestationResponses[0]) // Test Packed Verification. Unpack args. clientDataHash := sha256.Sum256(pcc.Raw.AttestationResponse.ClientDataJSON) - _, _, err := verifyPackedFormat(pcc.Response.AttestationObject, clientDataHash[:]) - if err != nil { - t.Fatalf("Not valid: %+v", err) - } + _, _, err := verifyPackedFormat(pcc.Response.AttestationObject, clientDataHash[:], nil) + require.NoError(t, err) }) } diff --git a/protocol/attestation_tpm.go b/protocol/attestation_tpm.go index e86881c..e077739 100644 --- a/protocol/attestation_tpm.go +++ b/protocol/attestation_tpm.go @@ -19,14 +19,14 @@ func init() { RegisterAttestationFormat(AttestationFormatTPM, verifyTPMFormat) } -func verifyTPMFormat(att AttestationObject, clientDataHash []byte) (string, []any, error) { +func verifyTPMFormat(att AttestationObject, clientDataHash []byte, _ metadata.Provider) (string, []any, error) { // Given the verification procedure inputs attStmt, authenticatorData // and clientDataHash, the verification procedure is as follows // Verify that attStmt is valid CBOR conforming to the syntax defined // above and perform CBOR decoding on it to extract the contained fields - ver, present := att.AttStatement["ver"].(string) + ver, present := att.AttStatement[stmtVersion].(string) if !present { return "", nil, ErrAttestationFormat.WithDetails("Error retrieving ver value") } @@ -35,35 +35,35 @@ func verifyTPMFormat(att AttestationObject, clientDataHash []byte) (string, []an return "", nil, ErrAttestationFormat.WithDetails("WebAuthn only supports TPM 2.0 currently") } - alg, present := att.AttStatement["alg"].(int64) + alg, present := att.AttStatement[stmtAlgorithm].(int64) if !present { return "", nil, ErrAttestationFormat.WithDetails("Error retrieving alg value") } coseAlg := webauthncose.COSEAlgorithmIdentifier(alg) - x5c, x509present := att.AttStatement["x5c"].([]any) + x5c, x509present := att.AttStatement[stmtX5C].([]any) if !x509present { // Handle Basic Attestation steps for the x509 Certificate return "", nil, ErrNotImplemented } - _, ecdaaKeyPresent := att.AttStatement["ecdaaKeyId"].([]byte) + _, ecdaaKeyPresent := att.AttStatement[stmtECDAAKID].([]byte) if ecdaaKeyPresent { return "", nil, ErrNotImplemented } - sigBytes, present := att.AttStatement["sig"].([]byte) + sigBytes, present := att.AttStatement[stmtSignature].([]byte) if !present { return "", nil, ErrAttestationFormat.WithDetails("Error retrieving sig value") } - certInfoBytes, present := att.AttStatement["certInfo"].([]byte) + certInfoBytes, present := att.AttStatement[stmtCertInfo].([]byte) if !present { return "", nil, ErrAttestationFormat.WithDetails("Error retrieving certInfo value") } - pubAreaBytes, present := att.AttStatement["pubArea"].([]byte) + pubAreaBytes, present := att.AttStatement[stmtPubArea].([]byte) if !present { return "", nil, ErrAttestationFormat.WithDetails("Error retrieving pubArea value") } diff --git a/protocol/attestation_tpm_test.go b/protocol/attestation_tpm_test.go index 412a74f..d8f6a4e 100644 --- a/protocol/attestation_tpm_test.go +++ b/protocol/attestation_tpm_test.go @@ -16,6 +16,7 @@ import ( "github.com/google/go-tpm/legacy/tpm2" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/go-webauthn/webauthn/protocol/webauthncbor" "github.com/go-webauthn/webauthn/protocol/webauthncose" @@ -27,7 +28,8 @@ func TestTPMAttestationVerificationSuccess(t *testing.T) { pcc := attestationTestUnpackResponse(t, testAttestationTPMResponses[i]) clientDataHash := sha256.Sum256(pcc.Raw.AttestationResponse.ClientDataJSON) - attestationType, _, err := verifyTPMFormat(pcc.Response.AttestationObject, clientDataHash[:]) + attestationType, _, err := verifyTPMFormat(pcc.Response.AttestationObject, clientDataHash[:], nil) + require.NoError(t, err) if err != nil { t.Fatalf("Not valid: %+v", err) } @@ -83,37 +85,37 @@ func TestTPMAttestationVerificationFailAttStatement(t *testing.T) { }, { "TPM Negative Test AttStatement Ver not 2.0", - AttestationObject{AttStatement: map[string]any{"ver": "foo.bar"}}, + AttestationObject{AttStatement: map[string]any{stmtVersion: "foo.bar"}}, "WebAuthn only supports TPM 2.0 currently", }, { "TPM Negative Test AttStatement Alg not present", - AttestationObject{AttStatement: map[string]any{"ver": "2.0"}}, + AttestationObject{AttStatement: map[string]any{stmtVersion: "2.0"}}, "Error retrieving alg value", }, { "TPM Negative Test AttStatement x5c not present", - AttestationObject{AttStatement: map[string]any{"ver": "2.0", "alg": int64(0)}}, + AttestationObject{AttStatement: map[string]any{stmtVersion: "2.0", stmtAlgorithm: int64(0)}}, ErrNotImplemented.Details, }, { "TPM Negative Test AttStatement ecdaaKeyId present", - AttestationObject{AttStatement: map[string]any{"ver": "2.0", "alg": int64(0), "x5c": []any{}, "ecdaaKeyId": []byte{}}}, + AttestationObject{AttStatement: map[string]any{stmtVersion: "2.0", stmtAlgorithm: int64(0), stmtX5C: []any{}, stmtECDAAKID: []byte{}}}, ErrNotImplemented.Details, }, { "TPM Negative Test AttStatement sig not present", - AttestationObject{AttStatement: map[string]any{"ver": "2.0", "alg": int64(0), "x5c": []any{}}}, + AttestationObject{AttStatement: map[string]any{stmtVersion: "2.0", stmtAlgorithm: int64(0), stmtX5C: []any{}}}, "Error retrieving sig value", }, { "TPM Negative Test AttStatement certInfo not present", - AttestationObject{AttStatement: map[string]any{"ver": "2.0", "alg": int64(0), "x5c": []any{}, "sig": []byte{}}}, + AttestationObject{AttStatement: map[string]any{stmtVersion: "2.0", stmtAlgorithm: int64(0), stmtX5C: []any{}, stmtSignature: []byte{}}}, "Error retrieving certInfo value", }, { "TPM Negative Test AttStatement pubArea not present", - AttestationObject{AttStatement: map[string]any{"ver": "2.0", "alg": int64(0), "x5c": []any{}, "sig": []byte{}, "certInfo": []byte{}}}, + AttestationObject{AttStatement: map[string]any{stmtVersion: "2.0", stmtAlgorithm: int64(0), stmtX5C: []any{}, stmtSignature: []byte{}, stmtCertInfo: []byte{}}}, "Error retrieving pubArea value", }, { @@ -123,12 +125,12 @@ func TestTPMAttestationVerificationFailAttStatement(t *testing.T) { }, { "TPM Negative Test Unsupported Public Key Type", - AttestationObject{AttStatement: map[string]any{"ver": "2.0", "alg": int64(0), "x5c": []any{}, "sig": []byte{}, "certInfo": []byte{}, "pubArea": makeDefaultRSAPublicBytes()}, AuthData: AuthenticatorData{AttData: AttestedCredentialData{CredentialPublicKey: []byte{}}}}, + AttestationObject{AttStatement: map[string]any{stmtVersion: "2.0", stmtAlgorithm: int64(0), stmtX5C: []any{}, stmtSignature: []byte{}, stmtCertInfo: []byte{}, stmtPubArea: makeDefaultRSAPublicBytes()}, AuthData: AuthenticatorData{AttData: AttestedCredentialData{CredentialPublicKey: []byte{}}}}, "Unsupported Public Key Type", }, } for _, tt := range tests { - attestationType, _, err := verifyTPMFormat(tt.att, nil) + attestationType, _, err := verifyTPMFormat(tt.att, nil, nil) if tt.wantErr != "" { assert.Contains(t, err.Error(), tt.wantErr) } else { @@ -170,7 +172,7 @@ var ( } ) -var defaultAttStatement = map[string]any{"ver": "2.0", "alg": int64(-257), "x5c": []any{}, "sig": []byte{}, "certInfo": []byte{}, "pubArea": []byte{}} +var defaultAttStatement = map[string]any{stmtVersion: "2.0", stmtAlgorithm: int64(-257), stmtX5C: []any{}, stmtSignature: []byte{}, stmtCertInfo: []byte{}, stmtPubArea: []byte{}} type CredentialPublicKey struct { KeyType int64 `cbor:"1,keyasint" json:"kty"` @@ -354,7 +356,7 @@ func TestTPMAttestationVerificationFailPubArea(t *testing.T) { public = defaultECCPublic } - attStmt["pubArea"], _ = public.Encode() + attStmt[stmtPubArea], _ = public.Encode() att := AttestationObject{ AttStatement: attStmt, AuthData: AuthenticatorData{ @@ -364,7 +366,7 @@ func TestTPMAttestationVerificationFailPubArea(t *testing.T) { }, } - attestationType, _, err := verifyTPMFormat(att, nil) + attestationType, _, err := verifyTPMFormat(att, nil, nil) if tt.wantErr != "" { assert.Contains(t, err.Error(), tt.wantErr) } else { @@ -394,7 +396,7 @@ func TestTPMAttestationVerificationFailCertInfo(t *testing.T) { public := defaultRSAPublic public.RSAParameters.ExponentRaw = uint32(rsaKey.E) public.RSAParameters.ModulusRaw = rsaKey.N.Bytes() - attStmt["pubArea"], _ = public.Encode() + attStmt[stmtPubArea], _ = public.Encode() rpk, _ := webauthncbor.Marshal(r) att := AttestationObject{ AttStatement: attStmt, @@ -452,8 +454,8 @@ func TestTPMAttestationVerificationFailCertInfo(t *testing.T) { t.Fatal(err) } - att.AttStatement["certInfo"] = certInfo - attestationType, _, err := verifyTPMFormat(att, nil) + att.AttStatement[stmtCertInfo] = certInfo + attestationType, _, err := verifyTPMFormat(att, nil, nil) if tt.wantErr != "" { assert.Contains(t, err.Error(), tt.wantErr) @@ -509,7 +511,7 @@ func TestTPMAttestationVerificationFailX5c(t *testing.T) { public.RSAParameters.ExponentRaw = uint32(rsaKey.E) public.RSAParameters.ModulusRaw = rsaKey.N.Bytes() pubBytes, _ := public.Encode() - attStmt["pubArea"] = pubBytes + attStmt[stmtPubArea] = pubBytes rpk, _ := webauthncbor.Marshal(r) att := AttestationObject{ AttStatement: attStmt, @@ -542,7 +544,7 @@ func TestTPMAttestationVerificationFailX5c(t *testing.T) { }, ExtraData: extraData, } - attStmt["certInfo"], _ = certInfo.Encode() + attStmt[stmtCertInfo], _ = certInfo.Encode() makeX5c := func(b []byte) []any { q := make([]any, 1) @@ -569,8 +571,8 @@ func TestTPMAttestationVerificationFailX5c(t *testing.T) { } for _, tt := range tests { - att.AttStatement["x5c"] = tt.x5c - attestationType, _, err := verifyTPMFormat(att, nil) + att.AttStatement[stmtX5C] = tt.x5c + attestationType, _, err := verifyTPMFormat(att, nil, nil) if tt.wantErr != "" { assert.Contains(t, err.Error(), tt.wantErr) diff --git a/protocol/attestation_u2f.go b/protocol/attestation_u2f.go index 0689d42..211aab7 100644 --- a/protocol/attestation_u2f.go +++ b/protocol/attestation_u2f.go @@ -16,7 +16,7 @@ func init() { } // verifyU2FFormat - Follows verification steps set out by https://www.w3.org/TR/webauthn/#fido-u2f-attestation -func verifyU2FFormat(att AttestationObject, clientDataHash []byte) (string, []any, error) { +func verifyU2FFormat(att AttestationObject, clientDataHash []byte, _ metadata.Provider) (string, []any, error) { if !bytes.Equal(att.AuthData.AttData.AAGUID, []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}) { return "", nil, ErrUnsupportedAlgorithm.WithDetails("U2F attestation format AAGUID not set to 0x00") } @@ -40,7 +40,7 @@ func verifyU2FFormat(att AttestationObject, clientDataHash []byte) (string, []an // } // Check for "x5c" which is a single element array containing the attestation certificate in X.509 format. - x5c, present := att.AttStatement["x5c"].([]any) + x5c, present := att.AttStatement[stmtX5C].([]any) if !present { return "", nil, ErrAttestationFormat.WithDetails("Missing properly formatted x5c data") } @@ -48,7 +48,7 @@ func verifyU2FFormat(att AttestationObject, clientDataHash []byte) (string, []an // Check for "sig" which is The attestation signature. The signature was calculated over the (raw) U2F // registration response message https://www.w3.org/TR/webauthn/#biblio-fido-u2f-message-formats] // received by the client from the authenticator. - signature, present := att.AttStatement["sig"].([]byte) + signature, present := att.AttStatement[stmtSignature].([]byte) if !present { return "", nil, ErrAttestationFormat.WithDetails("Missing sig data") } diff --git a/protocol/attestation_u2f_test.go b/protocol/attestation_u2f_test.go index 04dc618..b05bd23 100644 --- a/protocol/attestation_u2f_test.go +++ b/protocol/attestation_u2f_test.go @@ -37,7 +37,7 @@ func TestVerifyU2FFormat(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, _, err := verifyU2FFormat(tt.args.att, tt.args.clientDataHash) + got, _, err := verifyU2FFormat(tt.args.att, tt.args.clientDataHash, nil) if (err != nil) != tt.wantErr { t.Errorf("verifyU2FFormat() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/protocol/base64_test.go b/protocol/base64_test.go index 8b91897..6ec2132 100644 --- a/protocol/base64_test.go +++ b/protocol/base64_test.go @@ -1,12 +1,14 @@ package protocol import ( - "bytes" "encoding/base64" "encoding/json" "fmt" "strings" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestBase64UnmarshalJSON(t *testing.T) { @@ -41,17 +43,9 @@ func TestBase64UnmarshalJSON(t *testing.T) { t.Logf("%s\n", raw) - err := json.NewDecoder(strings.NewReader(raw)).Decode(&got) - if err != nil { - t.Fatalf("error decoding JSON: %v", err) - } - - if !bytes.Equal(test.expectedTestData.EncodedData, got.EncodedData) { - t.Fatalf("invalid URLEncodedBase64 data received: expected %s got %s", test.expectedTestData.EncodedData, got.EncodedData) - } + require.NoError(t, json.NewDecoder(strings.NewReader(raw)).Decode(&got)) - if test.expectedTestData.StringData != got.StringData { - t.Fatalf("invalid string data received: expected %s got %s", test.expectedTestData.StringData, got.StringData) - } + assert.Equal(t, test.expectedTestData.EncodedData, got.EncodedData) + assert.Equal(t, test.expectedTestData.StringData, got.StringData) } } diff --git a/protocol/client_test.go b/protocol/client_test.go index 1a2df9e..5ab5049 100644 --- a/protocol/client_test.go +++ b/protocol/client_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func setupCollectedClientData(challenge URLEncodedBase64, origin string) *CollectedClientData { @@ -18,17 +19,13 @@ func setupCollectedClientData(challenge URLEncodedBase64, origin string) *Collec func TestVerifyCollectedClientData(t *testing.T) { newChallenge, err := CreateChallenge() - if err != nil { - t.Fatalf("error creating challenge: %s", err) - } + require.NoError(t, err) ccd := setupCollectedClientData(newChallenge, "http://example.com") var storedChallenge = newChallenge - if err = ccd.Verify(storedChallenge.String(), ccd.Type, []string{ccd.Origin}, nil, TopOriginIgnoreVerificationMode); err != nil { - t.Fatalf("error verifying challenge: expected %#v got %#v", ccd.Challenge, storedChallenge) - } + require.NoError(t, ccd.Verify(storedChallenge.String(), ccd.Type, []string{ccd.Origin}, nil, TopOriginIgnoreVerificationMode)) } func TestVerifyCollectedClientDataIncorrectChallenge(t *testing.T) { @@ -40,13 +37,9 @@ func TestVerifyCollectedClientDataIncorrectChallenge(t *testing.T) { ccd := setupCollectedClientData(newChallenge, "http://example.com") bogusChallenge, err := CreateChallenge() - if err != nil { - t.Fatalf("error creating challenge: %s", err) - } + require.NoError(t, err) - if err = ccd.Verify(bogusChallenge.String(), ccd.Type, []string{ccd.Origin}, nil, TopOriginIgnoreVerificationMode); err == nil { - t.Fatalf("error expected but not received. expected %#v got %#v", ccd.Challenge, bogusChallenge) - } + assert.EqualError(t, ccd.Verify(bogusChallenge.String(), ccd.Type, []string{ccd.Origin}, nil, TopOriginIgnoreVerificationMode), "Error validating challenge") } func TestVerifyCollectedClientDataUnexpectedOrigin(t *testing.T) { diff --git a/protocol/const.go b/protocol/const.go new file mode 100644 index 0000000..a1560f2 --- /dev/null +++ b/protocol/const.go @@ -0,0 +1,11 @@ +package protocol + +const ( + stmtX5C = "x5c" + stmtSignature = "sig" + stmtAlgorithm = "alg" + stmtVersion = "ver" + stmtECDAAKID = "ecdaaKeyId" + stmtCertInfo = "certInfo" + stmtPubArea = "pubArea" +) diff --git a/protocol/credential.go b/protocol/credential.go index d532e2b..057d321 100644 --- a/protocol/credential.go +++ b/protocol/credential.go @@ -5,6 +5,8 @@ import ( "encoding/base64" "io" "net/http" + + "github.com/go-webauthn/webauthn/metadata" ) // Credential is the basic credential type from the Credential Management specification that is inherited by WebAuthn's @@ -131,15 +133,15 @@ func (ccr CredentialCreationResponse) Parse() (pcc *ParsedCredentialCreationData // Verify the Client and Attestation data. // // Specification: §7.1. Registering a New Credential (https://www.w3.org/TR/webauthn/#sctn-registering-a-new-credential) -func (pcc *ParsedCredentialCreationData) Verify(storedChallenge string, verifyUser bool, relyingPartyID string, rpOrigins, rpTopOrigins []string, rpTopOriginsVerify TopOriginVerificationMode) error { +func (pcc *ParsedCredentialCreationData) Verify(storedChallenge string, verifyUser bool, relyingPartyID string, rpOrigins, rpTopOrigins []string, rpTopOriginsVerify TopOriginVerificationMode, mds metadata.Provider) (clientDataHash []byte, err error) { // Handles steps 3 through 6 - Verifying the Client Data against the Relying Party's stored data - verifyError := pcc.Response.CollectedClientData.Verify(storedChallenge, CreateCeremony, rpOrigins, rpTopOrigins, rpTopOriginsVerify) - if verifyError != nil { - return verifyError + if err = pcc.Response.CollectedClientData.Verify(storedChallenge, CreateCeremony, rpOrigins, rpTopOrigins, rpTopOriginsVerify); err != nil { + return nil, err } // Step 7. Compute the hash of response.clientDataJSON using SHA-256. - clientDataHash := sha256.Sum256(pcc.Raw.AttestationResponse.ClientDataJSON) + sum := sha256.Sum256(pcc.Raw.AttestationResponse.ClientDataJSON) + clientDataHash = sum[:] // Step 8. Perform CBOR decoding on the attestationObject field of the AuthenticatorAttestationResponse // structure to obtain the attestation statement format fmt, the authenticator data authData, and the @@ -147,9 +149,8 @@ func (pcc *ParsedCredentialCreationData) Verify(storedChallenge string, verifyUs // We do the above step while parsing and decoding the CredentialCreationResponse // Handle steps 9 through 14 - This verifies the attestation object. - verifyError = pcc.Response.AttestationObject.Verify(relyingPartyID, clientDataHash[:], verifyUser) - if verifyError != nil { - return verifyError + if err = pcc.Response.AttestationObject.Verify(relyingPartyID, clientDataHash, verifyUser, mds); err != nil { + return clientDataHash, err } // Step 15. If validation is successful, obtain a list of acceptable trust anchors (attestation root @@ -188,7 +189,7 @@ func (pcc *ParsedCredentialCreationData) Verify(storedChallenge string, verifyUs // TODO: Not implemented for the reasons mentioned under Step 16 - return nil + return clientDataHash, nil } // GetAppID takes a AuthenticationExtensions object or nil. It then performs the following checks in order: diff --git a/protocol/credential_test.go b/protocol/credential_test.go index 26d0256..b4e7ba8 100644 --- a/protocol/credential_test.go +++ b/protocol/credential_test.go @@ -240,7 +240,7 @@ func TestParsedCredentialCreationData_Verify(t *testing.T) { Response: tt.fields.Response, Raw: tt.fields.Raw, } - if err := pcc.Verify(tt.args.storedChallenge.String(), tt.args.verifyUser, tt.args.relyingPartyID, tt.args.relyingPartyOrigin, nil, TopOriginIgnoreVerificationMode); (err != nil) != tt.wantErr { + if _, err := pcc.Verify(tt.args.storedChallenge.String(), tt.args.verifyUser, tt.args.relyingPartyID, tt.args.relyingPartyOrigin, nil, TopOriginIgnoreVerificationMode, nil); (err != nil) != tt.wantErr { t.Errorf("ParsedCredentialCreationData.Verify() error = %+v, wantErr %v", err, tt.wantErr) } }) diff --git a/protocol/metadata.go b/protocol/metadata.go new file mode 100644 index 0000000..a7e6651 --- /dev/null +++ b/protocol/metadata.go @@ -0,0 +1,44 @@ +package protocol + +import ( + "context" + "fmt" + + "github.com/google/uuid" + + "github.com/go-webauthn/webauthn/metadata" +) + +func ValidateMetadata(ctx context.Context, aaguid uuid.UUID, mds metadata.Provider) (err error) { + if mds == nil { + return nil + } + + var ( + entry *metadata.Entry + ) + + if entry, err = mds.GetEntry(ctx, aaguid); err != nil { + return err + } + + if entry == nil { + if aaguid == uuid.Nil && mds.GetValidateEntryPermitZeroAAGUID(ctx) { + return nil + } + + if mds.GetValidateEntry(ctx) { + return fmt.Errorf("error occurred performing authenticator entry validation: AAGUID entry has not been registered with the metadata service") + } + + return nil + } + + if mds.GetValidateStatus(ctx) { + if err = mds.ValidateStatusReports(ctx, entry.StatusReports); err != nil { + return fmt.Errorf("error occurred performing authenticator status validation: %w", err) + } + } + + return nil +} diff --git a/protocol/webauthncose/webauthncose.go b/protocol/webauthncose/webauthncose.go index 76a0aad..eb1f0d7 100644 --- a/protocol/webauthncose/webauthncose.go +++ b/protocol/webauthncose/webauthncose.go @@ -35,6 +35,7 @@ type PublicKeyData struct { // A COSEAlgorithmIdentifier for the algorithm used to derive the key signature. Algorithm int64 `cbor:"3,keyasint" json:"alg"` } + type EC2PublicKeyData struct { PublicKeyData diff --git a/webauthn/credential.go b/webauthn/credential.go index 81bcc9a..19e45f9 100644 --- a/webauthn/credential.go +++ b/webauthn/credential.go @@ -1,18 +1,22 @@ package webauthn import ( + "crypto/sha256" + "fmt" + + "github.com/go-webauthn/webauthn/metadata" "github.com/go-webauthn/webauthn/protocol" ) -// Credential contains all needed information about a WebAuthn credential for storage. +// Credential contains all needed information about a WebAuthn credential for storage. This struct is effectively the +// Credential Record as described in the specification. +// +// See: §4. Terminology: Credential Record (https://www.w3.org/TR/webauthn-3/#credential-record) type Credential struct { - // A probabilistically-unique byte sequence identifying a public key credential source and its authentication assertions. + // The Credential ID of the public key credential source. Described by the Credential Record 'id' field. ID []byte `json:"id"` - // The public key portion of a Relying Party-specific credential key pair, generated by an authenticator and returned to - // a Relying Party at registration time (see also public key credential). The private key portion of the credential key - // pair is known as the credential private key. Note that in the case of self attestation, the credential key pair is also - // used as the attestation key pair, see self attestation for details. + // The credential public key of the public key credential source. Described by the Credential Record 'publicKey field. PublicKey []byte `json:"publicKey"` // The attestation format used (if any) by the authenticator when creating the credential. @@ -26,6 +30,9 @@ type Credential struct { // The Authenticator information for a given certificate. Authenticator Authenticator `json:"authenticator"` + + // The attestation values that can be used to validate this credential via the MDS3 at a later date. + Attestation CredentialAttestation `json:"attestation"` } type CredentialFlags struct { @@ -43,6 +50,14 @@ type CredentialFlags struct { BackupState bool `json:"backupState"` } +type CredentialAttestation struct { + ClientDataJSON []byte `json:"clientDataJSON"` + ClientDataHash []byte `json:"clientDataHash"` + AuthenticatorData []byte `json:"authenticatorData"` + PublicKeyAlgorithm int64 `json:"publicKeyAlgorithm"` + Object []byte `json:"object"` +} + // Descriptor converts a Credential into a protocol.CredentialDescriptor. func (c Credential) Descriptor() (descriptor protocol.CredentialDescriptor) { return protocol.CredentialDescriptor{ @@ -53,9 +68,9 @@ func (c Credential) Descriptor() (descriptor protocol.CredentialDescriptor) { } } -// MakeNewCredential will return a credential pointer on successful validation of a registration response. -func MakeNewCredential(c *protocol.ParsedCredentialCreationData) (*Credential, error) { - newCredential := &Credential{ +// NewCredential will return a credential pointer on successful validation of a registration response. +func NewCredential(clientDataHash []byte, c *protocol.ParsedCredentialCreationData) (credential *Credential, err error) { + credential = &Credential{ ID: c.Response.AttestationObject.AuthData.AttData.CredentialID, PublicKey: c.Response.AttestationObject.AuthData.AttData.CredentialPublicKey, AttestationType: c.Response.AttestationObject.Format, @@ -71,7 +86,56 @@ func MakeNewCredential(c *protocol.ParsedCredentialCreationData) (*Credential, e SignCount: c.Response.AttestationObject.AuthData.Counter, Attachment: c.AuthenticatorAttachment, }, + Attestation: CredentialAttestation{ + ClientDataJSON: c.Raw.AttestationResponse.ClientDataJSON, + ClientDataHash: clientDataHash, + AuthenticatorData: c.Raw.AttestationResponse.AuthenticatorData, + PublicKeyAlgorithm: c.Raw.AttestationResponse.PublicKeyAlgorithm, + Object: c.Raw.AttestationResponse.AttestationObject, + }, + } + + return credential, nil +} + +// Verify this credentials against the metadata.Provider given. +func (c Credential) Verify(mds metadata.Provider) (err error) { + if mds == nil { + return fmt.Errorf("error verifying credential: the metadata provider must be provided but it's nil") + } + + raw := &protocol.AuthenticatorAttestationResponse{ + AuthenticatorResponse: protocol.AuthenticatorResponse{ + ClientDataJSON: c.Attestation.ClientDataJSON, + }, + Transports: make([]string, len(c.Transport)), + AuthenticatorData: c.Attestation.AuthenticatorData, + PublicKey: c.PublicKey, + PublicKeyAlgorithm: c.Attestation.PublicKeyAlgorithm, + AttestationObject: c.Attestation.Object, + } + + for i, transport := range c.Transport { + raw.Transports[i] = string(transport) + } + + var attestation *protocol.ParsedAttestationResponse + + if attestation, err = raw.Parse(); err != nil { + return fmt.Errorf("error verifying credential: error parsing attestation: %w", err) + } + + clientDataHash := c.Attestation.ClientDataHash + + if len(clientDataHash) == 0 { + sum := sha256.Sum256(c.Attestation.ClientDataJSON) + + clientDataHash = sum[:] + } + + if err = attestation.AttestationObject.VerifyAttestation(clientDataHash, mds); err != nil { + return fmt.Errorf("error verifying credential: error verifying attestation: %w", err) } - return newCredential, nil + return nil } diff --git a/webauthn/credential_test.go b/webauthn/credential_test.go index 8deae1b..d70b705 100644 --- a/webauthn/credential_test.go +++ b/webauthn/credential_test.go @@ -1,9 +1,11 @@ package webauthn import ( - "reflect" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/go-webauthn/webauthn/protocol" ) @@ -12,22 +14,22 @@ func TestMakeNewCredential(t *testing.T) { c *protocol.ParsedCredentialCreationData } - var tests []struct { - name string - args args - want *Credential - wantErr bool + var testCases []struct { + name string + args args + expected *Credential + err string } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := MakeNewCredential(tt.args.c) - if (err != nil) != tt.wantErr { - t.Errorf("MakeNewCredential() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("MakeNewCredential() = %v, want %v", got, tt.want) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + actual, err := NewCredential(nil, tc.args.c) + if len(tc.err) > 0 { + assert.EqualError(t, err, tc.err) + } else { + require.NoError(t, err) + + assert.EqualValues(t, tc.expected, actual) } }) } diff --git a/webauthn/login.go b/webauthn/login.go index 391f35b..89ff5f8 100644 --- a/webauthn/login.go +++ b/webauthn/login.go @@ -2,11 +2,14 @@ package webauthn import ( "bytes" + "context" "fmt" "net/http" "net/url" "time" + "github.com/google/uuid" + "github.com/go-webauthn/webauthn/protocol" ) @@ -88,6 +91,7 @@ func (webauthn *WebAuthn) beginLogin(userID []byte, allowedCredentials []protoco session = &SessionData{ Challenge: challenge.String(), + RelyingPartyID: assertion.Response.RelyingPartyID, UserID: userID, AllowedCredentialIDs: assertion.Response.GetAllowedCredentialIDs(), UserVerification: assertion.Response.UserVerification, @@ -197,21 +201,33 @@ func (webauthn *WebAuthn) ValidateLogin(user User, session SessionData, parsedRe } // ValidateDiscoverableLogin is an overloaded version of ValidateLogin that allows for discoverable credentials. -func (webauthn *WebAuthn) ValidateDiscoverableLogin(handler DiscoverableUserHandler, session SessionData, parsedResponse *protocol.ParsedCredentialAssertionData) (*Credential, error) { +// +// Note: this is just a backwards compatibility layer over ValidatePasskeyLogin which returns more information. +func (webauthn *WebAuthn) ValidateDiscoverableLogin(handler DiscoverableUserHandler, session SessionData, parsedResponse *protocol.ParsedCredentialAssertionData) (credential *Credential, err error) { + _, credential, err = webauthn.ValidatePasskeyLogin(handler, session, parsedResponse) + + return credential, err +} + +// ValidatePasskeyLogin is an overloaded version of ValidateLogin that allows for passkey credentials. +func (webauthn *WebAuthn) ValidatePasskeyLogin(handler DiscoverableUserHandler, session SessionData, parsedResponse *protocol.ParsedCredentialAssertionData) (user User, credential *Credential, err error) { if session.UserID != nil { - return nil, protocol.ErrBadRequest.WithDetails("Session was not initiated as a client-side discoverable login") + return nil, nil, protocol.ErrBadRequest.WithDetails("Session was not initiated as a client-side discoverable login") } if parsedResponse.Response.UserHandle == nil { - return nil, protocol.ErrBadRequest.WithDetails("Client-side Discoverable Assertion was attempted with a blank User Handle") + return nil, nil, protocol.ErrBadRequest.WithDetails("Client-side Discoverable Assertion was attempted with a blank User Handle") } - user, err := handler(parsedResponse.RawID, parsedResponse.Response.UserHandle) - if err != nil { - return nil, protocol.ErrBadRequest.WithDetails(fmt.Sprintf("Failed to lookup Client-side Discoverable Credential: %s", err)) + if user, err = handler(parsedResponse.RawID, parsedResponse.Response.UserHandle); err != nil { + return nil, nil, protocol.ErrBadRequest.WithDetails(fmt.Sprintf("Failed to lookup Client-side Discoverable Credential: %s", err)) } - return webauthn.validateLogin(user, session, parsedResponse) + if credential, err = webauthn.validateLogin(user, session, parsedResponse); err != nil { + return nil, nil, err + } + + return user, credential, nil } // ValidateLogin takes a parsed response and validates it against the user credentials and session data. @@ -221,16 +237,19 @@ func (webauthn *WebAuthn) validateLogin(user User, session SessionData, parsedRe // allowCredentials. // NON-NORMATIVE Prior Step: Verify that the allowCredentials for the session are owned by the user provided. - userCredentials := user.WebAuthnCredentials() + credentials := user.WebAuthnCredentials() - var credentialFound bool + var ( + found bool + credential Credential + ) if len(session.AllowedCredentialIDs) > 0 { var credentialsOwned bool for _, allowedCredentialID := range session.AllowedCredentialIDs { - for _, userCredential := range userCredentials { - if bytes.Equal(userCredential.ID, allowedCredentialID) { + for _, credential = range credentials { + if bytes.Equal(credential.ID, allowedCredentialID) { credentialsOwned = true break @@ -246,13 +265,13 @@ func (webauthn *WebAuthn) validateLogin(user User, session SessionData, parsedRe for _, allowedCredentialID := range session.AllowedCredentialIDs { if bytes.Equal(parsedResponse.RawID, allowedCredentialID) { - credentialFound = true + found = true break } } - if !credentialFound { + if !found { return nil, protocol.ErrBadRequest.WithDetails("User does not own the credential returned") } } @@ -271,44 +290,57 @@ func (webauthn *WebAuthn) validateLogin(user User, session SessionData, parsedRe // Step 3. Using credential’s id attribute (or the corresponding rawId, if base64url encoding is inappropriate // for your use case), look up the corresponding credential public key. - var loginCredential Credential - - for _, cred := range userCredentials { - if bytes.Equal(cred.ID, parsedResponse.RawID) { - loginCredential = cred - credentialFound = true + for _, credential = range credentials { + if bytes.Equal(credential.ID, parsedResponse.RawID) { + found = true break } - credentialFound = false + found = false } - if !credentialFound { + if !found { return nil, protocol.ErrBadRequest.WithDetails("Unable to find the credential for the returned credential ID") } + var ( + appID string + err error + ) + + // Ensure authenticators with a bad status is not used. + if webauthn.Config.MDS != nil { + var aaguid uuid.UUID + + if aaguid, err = uuid.FromBytes(credential.Authenticator.AAGUID); err != nil { + return nil, protocol.ErrBadRequest.WithDetails("Failed to decode AAGUID").WithInfo(fmt.Sprintf("Error occurred decoding AAGUID from the credential record: %s", err)) + } + + if err = protocol.ValidateMetadata(context.Background(), aaguid, webauthn.Config.MDS); err != nil { + return nil, protocol.ErrBadRequest.WithDetails("Failed to validate credential record metadata").WithInfo(fmt.Sprintf("Error occurred validating authenticator metadata from the credential record: %s", err)) + } + } + shouldVerifyUser := session.UserVerification == protocol.VerificationRequired rpID := webauthn.Config.RPID rpOrigins := webauthn.Config.RPOrigins rpTopOrigins := webauthn.Config.RPTopOrigins - appID, err := parsedResponse.GetAppID(session.Extensions, loginCredential.AttestationType) - if err != nil { + if appID, err = parsedResponse.GetAppID(session.Extensions, credential.AttestationType); err != nil { return nil, err } // Handle steps 4 through 16. - validError := parsedResponse.Verify(session.Challenge, rpID, rpOrigins, rpTopOrigins, webauthn.Config.RPTopOriginVerificationMode, appID, shouldVerifyUser, loginCredential.PublicKey) - if validError != nil { - return nil, validError + if err = parsedResponse.Verify(session.Challenge, rpID, rpOrigins, rpTopOrigins, webauthn.Config.RPTopOriginVerificationMode, appID, shouldVerifyUser, credential.PublicKey); err != nil { + return nil, err } // Handle step 17. - loginCredential.Authenticator.UpdateCounter(parsedResponse.Response.AuthenticatorData.Counter) + credential.Authenticator.UpdateCounter(parsedResponse.Response.AuthenticatorData.Counter) // Check if the BackupEligible flag has changed. - if loginCredential.Flags.BackupEligible != parsedResponse.Response.AuthenticatorData.Flags.HasBackupEligible() { + if credential.Flags.BackupEligible != parsedResponse.Response.AuthenticatorData.Flags.HasBackupEligible() { return nil, protocol.ErrBadRequest.WithDetails("BackupEligible flag inconsistency detected during login validation") } @@ -318,10 +350,10 @@ func (webauthn *WebAuthn) validateLogin(user User, session SessionData, parsedRe } // Update flags from response data. - loginCredential.Flags.UserPresent = parsedResponse.Response.AuthenticatorData.Flags.HasUserPresent() - loginCredential.Flags.UserVerified = parsedResponse.Response.AuthenticatorData.Flags.HasUserVerified() - loginCredential.Flags.BackupEligible = parsedResponse.Response.AuthenticatorData.Flags.HasBackupEligible() - loginCredential.Flags.BackupState = parsedResponse.Response.AuthenticatorData.Flags.HasBackupState() + credential.Flags.UserPresent = parsedResponse.Response.AuthenticatorData.Flags.HasUserPresent() + credential.Flags.UserVerified = parsedResponse.Response.AuthenticatorData.Flags.HasUserVerified() + credential.Flags.BackupEligible = parsedResponse.Response.AuthenticatorData.Flags.HasBackupEligible() + credential.Flags.BackupState = parsedResponse.Response.AuthenticatorData.Flags.HasBackupState() - return &loginCredential, nil + return &credential, nil } diff --git a/webauthn/registration.go b/webauthn/registration.go index a0d6e3a..79590da 100644 --- a/webauthn/registration.go +++ b/webauthn/registration.go @@ -91,6 +91,7 @@ func (webauthn *WebAuthn) BeginRegistration(user User, opts ...RegistrationOptio session = &SessionData{ Challenge: challenge.String(), + RelyingPartyID: creation.Response.RelyingParty.ID, UserID: user.WebAuthnID(), UserVerification: creation.Response.AuthenticatorSelection.UserVerification, } @@ -213,7 +214,7 @@ func (webauthn *WebAuthn) FinishRegistration(user User, session SessionData, res } // CreateCredential verifies a parsed response against the user's credentials and session data. -func (webauthn *WebAuthn) CreateCredential(user User, session SessionData, parsedResponse *protocol.ParsedCredentialCreationData) (*Credential, error) { +func (webauthn *WebAuthn) CreateCredential(user User, session SessionData, parsedResponse *protocol.ParsedCredentialCreationData) (credential *Credential, err error) { if !bytes.Equal(user.WebAuthnID(), session.UserID) { return nil, protocol.ErrBadRequest.WithDetails("ID mismatch for User and Session") } @@ -224,12 +225,13 @@ func (webauthn *WebAuthn) CreateCredential(user User, session SessionData, parse shouldVerifyUser := session.UserVerification == protocol.VerificationRequired - invalidErr := parsedResponse.Verify(session.Challenge, shouldVerifyUser, webauthn.Config.RPID, webauthn.Config.RPOrigins, webauthn.Config.RPTopOrigins, webauthn.Config.RPTopOriginVerificationMode) - if invalidErr != nil { - return nil, invalidErr + var clientDataHash []byte + + if clientDataHash, err = parsedResponse.Verify(session.Challenge, shouldVerifyUser, webauthn.Config.RPID, webauthn.Config.RPOrigins, webauthn.Config.RPTopOrigins, webauthn.Config.RPTopOriginVerificationMode, webauthn.Config.MDS); err != nil { + return nil, err } - return MakeNewCredential(parsedResponse) + return NewCredential(clientDataHash, parsedResponse) } func defaultRegistrationCredentialParameters() []protocol.CredentialParameter { diff --git a/webauthn/types.go b/webauthn/types.go index 41e4999..5ce2f93 100644 --- a/webauthn/types.go +++ b/webauthn/types.go @@ -5,6 +5,7 @@ import ( "net/url" "time" + "github.com/go-webauthn/webauthn/metadata" "github.com/go-webauthn/webauthn/protocol" ) @@ -62,6 +63,9 @@ type Config struct { // Timeouts configures various timeouts. Timeouts TimeoutsConfig + // MDS is a metadata.Provider and enables various metadata validations if configured. + MDS metadata.Provider + validated bool } @@ -136,6 +140,34 @@ func (config *Config) validate() error { return nil } +func (c *Config) GetRPID() string { + return c.RPID +} + +func (c *Config) GetOrigins() []string { + return c.RPOrigins +} + +func (c *Config) GetTopOrigins() []string { + return c.RPTopOrigins +} + +func (c *Config) GetTopOriginVerificationMode() protocol.TopOriginVerificationMode { + return c.RPTopOriginVerificationMode +} + +func (c *Config) GetMetaDataProvider() metadata.Provider { + return c.MDS +} + +type ConfigProvider interface { + GetRPID() string + GetOrigins() []string + GetTopOrigins() []string + GetTopOriginVerificationMode() protocol.TopOriginVerificationMode + GetMetaDataProvider() metadata.Provider +} + // User is an interface with the Relying Party's User entry and provides the fields and methods needed for WebAuthn // registration operations. type User interface { @@ -172,6 +204,7 @@ type User interface { // ceremony. type SessionData struct { Challenge string `json:"challenge"` + RelyingPartyID string `json:"rpId"` UserID []byte `json:"user_id"` AllowedCredentialIDs [][]byte `json:"allowed_credentials,omitempty"` Expires time.Time `json:"expires"`