Repository: go-webauthn/webauthn (go)

Issue:
Verify topOrigin
### Description

The latest draft discusses verifying `topOrigin` along with `origin`. Right now this library only validates `origin`. Maybe an `RPTopOrigins` field could be added to `Config`, though this means the default would be to not allow any `topOrigin` value which might not be backwards-compatible. It also wouldn't be easy for an RP to allow any `topOrigin` value. Maybe instead it could be some sort of enum like `AllowAll`, `AllowRPOrigins`, `AllowNone`, but then you couldn't specify third-party ones.

### Use Case

Our particular use-case is that we don't allow any `topOrigin` value, but longer-term we might want to allow our top-level domain as a valid `topOrigin`.

### Documentation

See https://github.com/w3c/webauthn/pull/1891 and https://w3c.github.io/webauthn/#sctn-validating-origin.

Pull request description:
feat: top origin verification
This adds top origin verification options to the library.

Closes #205

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
	- Enhanced the verification process for credential assertion and creation by incorporating additional parameters for relying party information verification.
	- Introduced new fields and verification modes to handle top origin and cross-origin verification in client data, improving security measures.
- **Bug Fixes**
	- Adjusted test cases to align with the updated verification processes, ensuring robustness and reliability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Patch:
diff --git a/protocol/assertion.go b/protocol/assertion.go
index 897a56c..48dfb62 100644
--- a/protocol/assertion.go
+++ b/protocol/assertion.go
@@ -124,14 +124,14 @@ func (car CredentialAssertionResponse) Parse() (par *ParsedCredentialAssertionDa
 // documentation.
 //
 // Specification: §7.2 Verifying an Authentication Assertion (https://www.w3.org/TR/webauthn/#sctn-verifying-assertion)
-func (p *ParsedCredentialAssertionData) Verify(storedChallenge string, relyingPartyID string, relyingPartyOrigins []string, appID string, verifyUser bool, credentialBytes []byte) error {
+func (p *ParsedCredentialAssertionData) Verify(storedChallenge string, relyingPartyID string, rpOrigins, rpTopOrigins []string, rpTopOriginsVerify TopOriginVerificationMode, appID string, verifyUser bool, credentialBytes []byte) error {
 	// Steps 4 through 6 in verifying the assertion data (https://www.w3.org/TR/webauthn/#verifying-assertion) are
 	// "assertive" steps, i.e "Let JSONtext be the result of running UTF-8 decode on the value of cData."
 	// We handle these steps in part as we verify but also beforehand
 
 	// Handle steps 7 through 10 of assertion by verifying stored data against the Collected Client Data
 	// returned by the authenticator
-	validError := p.Response.CollectedClientData.Verify(storedChallenge, AssertCeremony, relyingPartyOrigins)
+	validError := p.Response.CollectedClientData.Verify(storedChallenge, AssertCeremony, rpOrigins, rpTopOrigins, rpTopOriginsVerify)
 	if validError != nil {
 		return validError
 	}
diff --git a/protocol/client.go b/protocol/client.go
index c98577a..02402f7 100644
--- a/protocol/client.go
+++ b/protocol/client.go
@@ -20,6 +20,8 @@ type CollectedClientData struct {
 	Type         CeremonyType  `json:"type"`
 	Challenge    string        `json:"challenge"`
 	Origin       string        `json:"origin"`
+	TopOrigin    string        `json:"topOrigin"`
+	CrossOrigin  bool          `json:"crossOrigin,omitempty"`
 	TokenBinding *TokenBinding `json:"tokenBinding,omitempty"`
 
 	// Chromium (Chrome) returns a hint sometimes about how to handle clientDataJSON in a safe manner.
@@ -77,7 +79,10 @@ func FullyQualifiedOrigin(rawOrigin string) (fqOrigin string, err error) {
 // 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
-func (c *CollectedClientData) Verify(storedChallenge string, ceremony CeremonyType, rpOrigins []string) error {
+//
+// 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.
+func (c *CollectedClientData) Verify(storedChallenge string, ceremony CeremonyType, rpOrigins, rpTopOrigins []string, rpTopOriginsVerify TopOriginVerificationMode) (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.
@@ -101,8 +106,9 @@ func (c *CollectedClientData) Verify(storedChallenge string, ceremony CeremonyTy
 
 	// Registration Step 5 & Assertion Step 9. Verify that the value of C.origin matches
 	// the Relying Party's origin.
-	fqOrigin, err := FullyQualifiedOrigin(c.Origin)
-	if err != nil {
+	var fqOrigin string
+
+	if fqOrigin, err = FullyQualifiedOrigin(c.Origin); err != nil {
 		return ErrParsingData.WithDetails("Error decoding clientData origin as URL")
 	}
 
@@ -121,6 +127,54 @@ func (c *CollectedClientData) Verify(storedChallenge string, ceremony CeremonyTy
 			WithInfo(fmt.Sprintf("Expected Values: %s, Received: %s", rpOrigins, fqOrigin))
 	}
 
+	if rpTopOriginsVerify != TopOriginIgnoreVerificationMode {
+		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 (
+				fqTopOrigin        string
+				possibleTopOrigins []string
+			)
+
+			if fqTopOrigin, err = FullyQualifiedOrigin(c.TopOrigin); err != nil {
+				return ErrParsingData.WithDetails("Error decoding clientData topOrigin as URL")
+			}
+
+			switch rpTopOriginsVerify {
+			case TopOriginExplicitVerificationMode:
+				possibleTopOrigins = rpTopOrigins
+			case TopOriginAutoVerificationMode:
+				possibleTopOrigins = append(rpTopOrigins, rpOrigins...)
+			case TopOriginImplicitVerificationMode:
+				possibleTopOrigins = rpOrigins
+			default:
+				return ErrNotImplemented.WithDetails("Error handling unknown Top Origin verification mode")
+			}
+
+			found = false
+
+			for _, origin := range possibleTopOrigins {
+				if strings.EqualFold(fqTopOrigin, origin) {
+					found = true
+					break
+				}
+			}
+
+			if !found {
+				return ErrVerification.
+					WithDetails("Error validating top origin").
+					WithInfo(fmt.Sprintf("Expected Values: %s, Received: %s", possibleTopOrigins, fqTopOrigin))
+			}
+		}
+	}
+
 	// 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
@@ -140,3 +194,28 @@ func (c *CollectedClientData) Verify(storedChallenge string, ceremony CeremonyTy
 
 	return nil
 }
+
+type TopOriginVerificationMode int
+
+const (
+	// TopOriginDefaultVerificationMode represents the default verification mode for the Top Origin. At this time this
+	// mode is the same as TopOriginIgnoreVerificationMode until such a time as the specification becomes stable. This
+	// value is intended as a fallback value and implementers should very intentionally pick another option if they want
+	// stability.
+	TopOriginDefaultVerificationMode TopOriginVerificationMode = iota
+
+	// TopOriginIgnoreVerificationMode ignores verification entirely.
+	TopOriginIgnoreVerificationMode
+
+	// TopOriginAutoVerificationMode represents the automatic verification mode for the Top Origin. In this mode the
+	// If the Top Origins parameter has values it checks against this, otherwise it checks against the Origins parameter.
+	TopOriginAutoVerificationMode
+
+	// TopOriginImplicitVerificationMode represents the implicit verification mode for the Top Origin. In this mode the
+	// Top Origin is verified against the allowed Origins values.
+	TopOriginImplicitVerificationMode
+
+	// TopOriginExplicitVerificationMode represents the explicit verification mode for the Top Origin. In this mode the
+	// Top Origin is verified against the allowed Top Origins values.
+	TopOriginExplicitVerificationMode
+)
diff --git a/protocol/credential.go b/protocol/credential.go
index bb9782b..7067402 100644
--- a/protocol/credential.go
+++ b/protocol/credential.go
@@ -140,9 +140,9 @@ 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, relyingPartyOrigins []string) error {
+func (pcc *ParsedCredentialCreationData) Verify(storedChallenge string, verifyUser bool, relyingPartyID string, rpOrigins, rpTopOrigins []string, rpTopOriginsVerify TopOriginVerificationMode) error {
 	// Handles steps 3 through 6 - Verifying the Client Data against the Relying Party's stored data
-	verifyError := pcc.Response.CollectedClientData.Verify(storedChallenge, CreateCeremony, relyingPartyOrigins)
+	verifyError := pcc.Response.CollectedClientData.Verify(storedChallenge, CreateCeremony, rpOrigins, rpTopOrigins, rpTopOriginsVerify)
 	if verifyError != nil {
 		return verifyError
 	}
diff --git a/webauthn/login.go b/webauthn/login.go
index ca981d5..ff1c5c2 100644
--- a/webauthn/login.go
+++ b/webauthn/login.go
@@ -285,6 +285,7 @@ func (webauthn *WebAuthn) validateLogin(user User, session SessionData, parsedRe
 
 	rpID := webauthn.Config.RPID
 	rpOrigins := webauthn.Config.RPOrigins
+	rpTopOrigins := webauthn.Config.RPTopOrigins
 
 	appID, err := parsedResponse.GetAppID(session.Extensions, loginCredential.AttestationType)
 	if err != nil {
@@ -292,7 +293,7 @@ func (webauthn *WebAuthn) validateLogin(user User, session SessionData, parsedRe
 	}
 
 	// Handle steps 4 through 16.
-	validError := parsedResponse.Verify(session.Challenge, rpID, rpOrigins, appID, shouldVerifyUser, loginCredential.PublicKey)
+	validError := parsedResponse.Verify(session.Challenge, rpID, rpOrigins, rpTopOrigins, webauthn.Config.RPTopOriginVerificationMode, appID, shouldVerifyUser, loginCredential.PublicKey)
 	if validError != nil {
 		return nil, validError
 	}
diff --git a/webauthn/registration.go b/webauthn/registration.go
index cb9d234..e24e2b1 100644
--- a/webauthn/registration.go
+++ b/webauthn/registration.go
@@ -205,7 +205,7 @@ 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)
+	invalidErr := parsedResponse.Verify(session.Challenge, shouldVerifyUser, webauthn.Config.RPID, webauthn.Config.RPOrigins, webauthn.Config.RPTopOrigins, webauthn.Config.RPTopOriginVerificationMode)
 	if invalidErr != nil {
 		return nil, invalidErr
 	}
diff --git a/webauthn/types.go b/webauthn/types.go
index bb93f31..79b6db1 100644
--- a/webauthn/types.go
+++ b/webauthn/types.go
@@ -36,6 +36,15 @@ type Config struct {
 	// qualified origins.
 	RPOrigins []string
 
+	// RPTopOrigins configures the list of Relying Party Server Top Origins that are permitted. These should be fully
+	// qualified origins.
+	RPTopOrigins []string
+
+	// RPTopOriginVerificationMode determines the verification mode for the Top Origin value. By default the
+	// TopOriginIgnoreVerificationMode is used however this is going to change at such a time as WebAuthn Level 3
+	// becomes recommended, implementers should explicitly set this value if they want stability.
+	RPTopOriginVerificationMode protocol.TopOriginVerificationMode
+
 	// AttestationPreference sets the default attestation conveyance preferences.
 	AttestationPreference protocol.ConveyancePreference
 
@@ -153,6 +162,15 @@ func (config *Config) validate() error {
 		return fmt.Errorf("must provide at least one value to the 'RPOrigins' field")
 	}
 
+	switch config.RPTopOriginVerificationMode {
+	case protocol.TopOriginDefaultVerificationMode:
+		config.RPTopOriginVerificationMode = protocol.TopOriginIgnoreVerificationMode
+	case protocol.TopOriginImplicitVerificationMode:
+		if len(config.RPTopOrigins) == 0 {
+			return fmt.Errorf("must provide at least one value to the 'RPTopOrigins' field when 'RPTopOriginVerificationMode' field is set to protocol.TopOriginImplicitVerificationMode")
+		}
+	}
+
 	if config.AuthenticatorSelection.RequireResidentKey == nil {
 		config.AuthenticatorSelection.RequireResidentKey = protocol.ResidentKeyNotRequired()
 	}


Test patch:
diff --git a/protocol/assertion_test.go b/protocol/assertion_test.go
index 335f9a1..ab62239 100644
--- a/protocol/assertion_test.go
+++ b/protocol/assertion_test.go
@@ -180,7 +180,7 @@ func TestParsedCredentialAssertionData_Verify(t *testing.T) {
 				Raw:                       tt.fields.Raw,
 			}
 
-			if err := p.Verify(tt.args.storedChallenge.String(), tt.args.relyingPartyID, tt.args.relyingPartyOrigin, "", tt.args.verifyUser, tt.args.credentialBytes); (err != nil) != tt.wantErr {
+			if err := p.Verify(tt.args.storedChallenge.String(), tt.args.relyingPartyID, tt.args.relyingPartyOrigin, nil, TopOriginIgnoreVerificationMode, "", tt.args.verifyUser, tt.args.credentialBytes); (err != nil) != tt.wantErr {
 				t.Errorf("ParsedCredentialAssertionData.Verify() error = %v, wantErr %v", err, tt.wantErr)
 			}
 		})
diff --git a/protocol/attestation_test.go b/protocol/attestation_test.go
index f714429..60a6bf6 100644
--- a/protocol/attestation_test.go
+++ b/protocol/attestation_test.go
@@ -36,7 +36,7 @@ func TestAttestationVerify(t *testing.T) {
 			pcc.Response = *parsedAttestationResponse
 
 			// Test Base Verification
-			err = pcc.Verify(options.Response.Challenge.String(), false, options.Response.RelyingParty.ID, []string{options.Response.RelyingParty.Name})
+			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)
 			}
diff --git a/protocol/client_test.go b/protocol/client_test.go
index d83a598..1a2df9e 100644
--- a/protocol/client_test.go
+++ b/protocol/client_test.go
@@ -26,7 +26,7 @@ func TestVerifyCollectedClientData(t *testing.T) {
 
 	var storedChallenge = newChallenge
 
-	if err = ccd.Verify(storedChallenge.String(), ccd.Type, []string{ccd.Origin}); err != nil {
+	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)
 	}
 }
@@ -44,7 +44,7 @@ func TestVerifyCollectedClientDataIncorrectChallenge(t *testing.T) {
 		t.Fatalf("error creating challenge: %s", err)
 	}
 
-	if err = ccd.Verify(bogusChallenge.String(), ccd.Type, []string{ccd.Origin}); err == nil {
+	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)
 	}
 }
@@ -59,7 +59,7 @@ func TestVerifyCollectedClientDataUnexpectedOrigin(t *testing.T) {
 	storedChallenge := newChallenge
 	expectedOrigins := []string{"http://different.com"}
 
-	if err = ccd.Verify(storedChallenge.String(), ccd.Type, expectedOrigins); err == nil {
+	if err = ccd.Verify(storedChallenge.String(), ccd.Type, expectedOrigins, nil, TopOriginIgnoreVerificationMode); err == nil {
 		t.Fatalf("error expected but not received. expected %#v got %#v", expectedOrigins, ccd.Origin)
 	}
 }
@@ -76,7 +76,7 @@ func TestVerifyCollectedClientDataWithMultipleExpectedOrigins(t *testing.T) {
 
 	expectedOrigins := []string{"https://exmaple.com", "9C:B4:AE:EF:05:53:6E:73:0E:C4:B8:02:E7:67:F6:7D:A4:E7:BC:26:D7:42:B5:27:FF:01:7D:68:2A:EB:FA:1D", ccd.Origin}
 
-	if err = ccd.Verify(storedChallenge.String(), ccd.Type, expectedOrigins); err != nil {
+	if err = ccd.Verify(storedChallenge.String(), ccd.Type, expectedOrigins, nil, TopOriginIgnoreVerificationMode); err != nil {
 		t.Fatalf("error verifying challenge: expected %#v got %#v", expectedOrigins, ccd.Origin)
 	}
 }
diff --git a/protocol/credential_test.go b/protocol/credential_test.go
index 7c0b819..201c45f 100644
--- a/protocol/credential_test.go
+++ b/protocol/credential_test.go
@@ -364,7 +364,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); (err != nil) != tt.wantErr {
+			if err := pcc.Verify(tt.args.storedChallenge.String(), tt.args.verifyUser, tt.args.relyingPartyID, tt.args.relyingPartyOrigin, nil, TopOriginIgnoreVerificationMode); (err != nil) != tt.wantErr {
 				t.Errorf("ParsedCredentialCreationData.Verify() error = %+v, wantErr %v", err, tt.wantErr)
 			}
 		})