Files
web/third_party/go-webauthn/protocol/client.go
T
gamertan bfe6cfd29e
verify / verify (push) Successful in 3m40s
auth: publish passkey foundations preview
2026-08-21 17:33:00 -04:00

322 lines
12 KiB
Go
Vendored
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package protocol
import (
"crypto/subtle"
"fmt"
"net/url"
"strings"
)
// CollectedClientData represents the contextual bindings of both the WebAuthn Relying Party
// and the client. It is a key-value mapping whose keys are strings. Values can be any type
// that has a valid encoding in JSON. Its structure is defined by the following Web IDL.
//
// Specification: §5.8.1. Client Data Used in WebAuthn Signatures (https://www.w3.org/TR/webauthn/#dictdef-collectedclientdata)
type CollectedClientData struct {
// Type contains the string "webauthn.create" when creating new credentials, and "webauthn.get" when getting an
// assertion from an existing credential. The purpose of this member is to prevent certain types of signature
// confusion attacks (where an attacker substitutes one legitimate signature for another).
Type CeremonyType `json:"type"`
// Challenge contains the base64url encoding of the challenge provided by the Relying Party.
Challenge string `json:"challenge"`
// Origin contains the fully qualified origin of the requester, as provided to the authenticator by the client.
Origin string `json:"origin"`
// TopOrigin contains the fully qualified top-level origin of the requester when the client is cross-origin.
// This is only present when CrossOrigin is true.
//
// WebAuthn Level 3.
TopOrigin string `json:"topOrigin,omitempty"`
// CrossOrigin indicates whether the calling context is an iframe that is not same-origin with its ancestor.
//
// WebAuthn Level 3.
CrossOrigin bool `json:"crossOrigin,omitempty"`
// TokenBinding contains information about the state of the Token Binding protocol.
TokenBinding *TokenBinding `json:"tokenBinding,omitempty"`
// Hint is an opaque field that may be added by the client. Chromium-based browsers include this field to remind
// implementers not to perform string comparison on the clientDataJSON.
Hint string `json:"new_keys_may_be_added_here,omitempty"`
}
// CeremonyType represents the type of WebAuthn ceremony being performed.
//
// Specification: §5.8.1. Client Data Used in WebAuthn Signatures (https://www.w3.org/TR/webauthn/#dom-collectedclientdata-type)
type CeremonyType string
const (
// CreateCeremony is the ceremony type for credential registration ("webauthn.create").
CreateCeremony CeremonyType = "webauthn.create"
// AssertCeremony is the ceremony type for authentication assertion ("webauthn.get").
AssertCeremony CeremonyType = "webauthn.get"
)
// TokenBinding contains information about the state of the Token Binding protocol used when communicating with the
// Relying Party. Its absence indicates that the client doesn't support token binding.
//
// Specification: §5.8.1. Client Data Used in WebAuthn Signatures (https://www.w3.org/TR/webauthn/#dom-collectedclientdata-tokenbinding)
type TokenBinding struct {
Status TokenBindingStatus `json:"status"`
ID string `json:"id,omitempty"`
}
// TokenBindingStatus represents the state of Token Binding between the client and the Relying Party.
type TokenBindingStatus string
const (
// Present indicates token binding was used when communicating with the
// Relying Party. In this case, the id member MUST be present.
Present TokenBindingStatus = "present"
// Supported indicates the client supports token binding, but it was not
// negotiated when communicating with the Relying Party.
Supported TokenBindingStatus = "supported"
// NotSupported indicates token binding not supported
// when communicating with the Relying Party.
NotSupported TokenBindingStatus = "not-supported"
)
// FullyQualifiedOrigin returns the origin per the HTML spec: (scheme)://(host)[:(port)].
func FullyQualifiedOrigin(rawOrigin string) (fqOrigin string, err error) {
if strings.HasPrefix(rawOrigin, "android:apk-key-hash:") {
return rawOrigin, nil
}
var origin *url.URL
if origin, err = url.ParseRequestURI(rawOrigin); err != nil {
return "", err
}
if origin.Host == "" {
return "", fmt.Errorf("url '%s' does not have a host", rawOrigin)
}
origin.Path, origin.RawPath, origin.RawQuery, origin.User = "", "", "", nil
return origin.String(), nil
}
// Verify handles steps 3 through 6 of verifying the registering client data of a
// new credential and steps 7 through 10 of verifying an authentication assertion
// See https://www.w3.org/TR/webauthn/#registering-a-new-credential
// and https://www.w3.org/TR/webauthn/#verifying-assertion
//
// Note: the rpTopOriginsVerify parameter does not accept the TopOriginVerificationMode value of
// TopOriginDefaultVerificationMode as it's expected this value is updated by the config validation process.
//
//nolint:gocyclo
func (c *CollectedClientData) Verify(storedChallenge string, ceremony CeremonyType, rpOrigins, rpTopOrigins []string, rpTopOriginsVerify TopOriginVerificationMode, allowCrossOrigin bool) (err error) {
// Registration Step 3. Verify that the value of C.type is webauthn.create.
// Assertion Step 7. Verify that the value of C.type is the string webauthn.get.
if c.Type != ceremony {
return ErrVerification.WithDetails("Error validating ceremony type").WithInfo(fmt.Sprintf("Expected Value: %s, Received: %s", ceremony, c.Type))
}
// Registration Step 4. Verify that the value of C.challenge matches the challenge
// that was sent to the authenticator in the create() call.
// Assertion Step 8. Verify that the value of C.challenge matches the challenge
// that was sent to the authenticator in the PublicKeyCredentialRequestOptions
// passed to the get() call.
challenge := c.Challenge
if subtle.ConstantTimeCompare([]byte(storedChallenge), []byte(challenge)) != 1 {
return ErrVerification.
WithDetails("Error validating challenge").
WithInfo(fmt.Sprintf("Expected b Value: %#v\nReceived b: %#v\n", storedChallenge, challenge))
}
// Registration Step 5 & Assertion Step 9. Verify that the value of C.origin matches
// the Relying Party's origin.
if !IsOriginInHaystack(c.Origin, rpOrigins) {
return ErrVerification.
WithDetails("Error validating origin").
WithInfo(fmt.Sprintf("Expected Values: %s, Received: %s", rpOrigins, c.Origin))
}
if !allowCrossOrigin && c.CrossOrigin {
return ErrVerification.
WithDetails("Error validating cross origin flag").
WithInfo("The cross origin flag is invalid due to the configuration.")
}
switch len(c.TopOrigin) {
case 0:
break
default:
if !c.CrossOrigin {
return ErrVerification.
WithDetails("Error validating topOrigin").
WithInfo("The topOrigin can't have values unless crossOrigin is true.")
}
var possibleTopOrigins []string
switch rpTopOriginsVerify {
case TopOriginExplicitVerificationMode:
possibleTopOrigins = rpTopOrigins
case TopOriginAutoVerificationMode:
possibleTopOrigins = make([]string, 0, len(rpTopOrigins)+len(rpOrigins))
possibleTopOrigins = append(possibleTopOrigins, rpTopOrigins...)
possibleTopOrigins = append(possibleTopOrigins, rpOrigins...)
case TopOriginImplicitVerificationMode:
possibleTopOrigins = rpOrigins
default:
return ErrNotImplemented.WithDetails("Error handling unknown Top Origin verification mode")
}
if !IsOriginInHaystack(c.TopOrigin, possibleTopOrigins) {
return ErrVerification.
WithDetails("Error validating top origin").
WithInfo(fmt.Sprintf("Expected Values: %s, Received: %s", possibleTopOrigins, c.TopOrigin))
}
}
// Registration Step 6 and Assertion Step 10. Verify that the value of C.tokenBinding.status
// matches the state of Token Binding for the TLS connection over which the assertion was
// obtained. If Token Binding was used on that TLS connection, also verify that C.tokenBinding.id
// matches the base64url encoding of the Token Binding ID for the connection.
if c.TokenBinding != nil {
if c.TokenBinding.Status == "" {
return ErrParsingData.WithDetails("Error decoding clientData, token binding present without status")
}
if c.TokenBinding.Status != Present && c.TokenBinding.Status != Supported && c.TokenBinding.Status != NotSupported {
return ErrParsingData.
WithDetails("Error decoding clientData, token binding present with invalid status").
WithInfo(fmt.Sprintf("Got: %s", c.TokenBinding.Status))
}
}
// Not yet fully implemented by the spec, browsers, and me.
return nil
}
// TopOriginVerificationMode determines how the Relying Party validates the topOrigin field in
// [CollectedClientData]. This is relevant for cross-origin iframe scenarios where the top-level browsing context's
// origin differs from the embedded origin making the WebAuthn API call.
//
// WebAuthn Level 3.
type TopOriginVerificationMode int
const (
// TopOriginDefaultVerificationMode is the zero value of [TopOriginVerificationMode] and has no matching rule in
// the verifier; passing it directly to [CollectedClientData.Verify] returns an "unknown Top Origin verification
// mode" error. High-level callers using [webauthn.Config] have this value coerced to
// [TopOriginExplicitVerificationMode] by config validation, which is the recommended default.
TopOriginDefaultVerificationMode TopOriginVerificationMode = iota
// TopOriginAutoVerificationMode accepts the Top Origin if it matches any entry in either the allowed Top Origins
// list or the allowed Origins list. The two lists are unioned (RPTopOrigins RPOrigins). This is the most
// permissive of the three active modes and should only be used when an RP deliberately wants cross-origin and
// same-origin embeddings to share an allow-list.
TopOriginAutoVerificationMode
// TopOriginImplicitVerificationMode accepts the Top Origin only if it matches an entry in the allowed Origins
// list (RPOrigins). The RPTopOrigins list is ignored in this mode.
TopOriginImplicitVerificationMode
// TopOriginExplicitVerificationMode accepts the Top Origin only if it matches an entry in the allowed Top Origins
// list (RPTopOrigins). The RPOrigins list is ignored in this mode. This is the strictest mode and the one
// [webauthn.Config] coerces the zero value to.
TopOriginExplicitVerificationMode
)
// IsOriginInHaystack checks if the needle is in the haystack using the mechanism to determine origin equality defined
// in HTML5 Section 5.3 and RFC3986 Section 6.2.1.
//
// Specifically if the needle value has the 'http://' or 'https://' prefix (case-insensitive) and can be parsed as a
// URL; we check each item in the haystack to see if it matches the same rules, and then if the scheme and host (with
// a normalized port) components match case-insensitively then they're considered a match.
//
// If the needle value does not have the 'http://' or 'https://' prefix (case-insensitive) or can't be parsed as a URL
// equality is determined using simple string comparison.
//
// It is important to note that this function completely ignores Apple Associated Domains entirely as Apple is using
// an unassigned Well-Known URI in breech of Well-Known Uniform Resource Identifiers (RFC8615).
//
// See (Origin Definition): https://www.w3.org/TR/2011/WD-html5-20110525/origin-0.html
//
// See (Simple String Comparison Definition): https://datatracker.ietf.org/doc/html/rfc3986#section-6.2.1
//
// See (Apple Associated Domains): https://developer.apple.com/documentation/xcode/supporting-associated-domains
//
// See (IANA Well Known URI Assignments): https://www.iana.org/assignments/well-known-uris/well-known-uris.xhtml
//
// See (Well-Known Uniform Resource Identifiers): https://datatracker.ietf.org/doc/html/rfc8615
func IsOriginInHaystack(needle string, haystack []string) bool {
needleURI := parseOriginURI(needle)
if needleURI != nil {
for _, hay := range haystack {
if hayURI := parseOriginURI(hay); hayURI != nil {
if isOriginEqual(needleURI, hayURI) {
return true
}
}
}
} else {
for _, hay := range haystack {
if needle == hay {
return true
}
}
}
return false
}
func isOriginEqual(a *url.URL, b *url.URL) bool {
if !strings.EqualFold(a.Scheme, b.Scheme) {
return false
}
if !strings.EqualFold(a.Host, b.Host) {
return false
}
return true
}
func parseOriginURI(raw string) *url.URL {
if !isPossibleFQDN(raw) {
return nil
}
// We can ignore the error here because it's effectively not a FQDN if this fails.
uri, _ := url.Parse(raw)
if uri == nil {
return nil
}
// Normalize the port if necessary.
switch uri.Scheme {
case "http":
if uri.Port() == "80" {
uri.Host = uri.Hostname()
}
case "https":
if uri.Port() == "443" {
uri.Host = uri.Hostname()
}
}
return uri
}
func isPossibleFQDN(raw string) bool {
normalized := strings.ToLower(raw)
return strings.HasPrefix(normalized, "http://") || strings.HasPrefix(normalized, "https://")
}