Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: passkey strategy with resident keys #3656

Closed
wants to merge 43 commits into from

Conversation

hperl
Copy link
Contributor

@hperl hperl commented Dec 11, 2023

TODO

  • Two distinct strategies, how to prevent passkey + webauthn passkey with too high AAL
    • Passkey is currently always AAL1 and not a second factor.
    • We could disallow using the same authenticator ID (AAGUID) for both webauthn and passkey. However, this could hinder migration from webauthn+passwordless to passkey.
  • PassKeys what happens to non-resident keys? For example when migrating WebAuthn passwordless credentials to passkey?
    • This work does not yet consider converting passwordless credentials to a passkey. I think the best way would be to do this with user interaction and insert a "nag screen" after login to prompt the user to create a passkey (and optionally delete/deactivate the webauth passwordless credential). The same technique could be used to onboard users to passkeys regardless of their primary login strategy.
  • 2x PassKeys if submission fails
    • In some cases it can be that the passkey is created in the browser but not stored in the database. Ultimately, there is nothing that we can do to completely prevent this, but is should be uncommon, because we store and validate the traits first before we create the passkey.
  • WebAuthn.js - use onload for autocomplete
    • Done
  • How will the deprecation work of the webauthn strategy?
    • This will need further design and code.
  • Clean up webauthnIdentifierNode

Related issue(s)

Docs PR: ory/docs#1626

Checklist

  • I have read the contributing guidelines.
  • I have referenced an issue containing the design document if my change
    introduces a new feature.
  • I am following the
    contributing code guidelines.
  • I have read the security policy.
  • I confirm that this pull request does not address a security
    vulnerability. If this pull request addresses a security vulnerability, I
    confirm that I got the approval (please contact
    [email protected]) from the maintainers to push
    the changes.
  • I have added tests that prove my fix is effective or that my feature
    works.
  • I have added or changed the documentation.

Further Comments

@hperl hperl marked this pull request as ready for review December 12, 2023 08:33
@hperl hperl requested a review from jonas-jonas December 12, 2023 08:39
@hperl hperl self-assigned this Dec 12, 2023
Copy link

codecov bot commented Dec 12, 2023

Codecov Report

Attention: 192 lines in your changes are missing coverage. Please review.

Comparison is base (21ab031) 79.47% compared to head (3c8fd05) 78.35%.
Report is 41 commits behind head on master.

❗ Current head 3c8fd05 differs from pull request most recent head 5f58a20. Consider uploading reports for the commit 5f58a20 to get more accurate results

Files Patch % Lines
selfservice/strategy/passkey/passkey_settings.go 68.83% 36 Missing and 31 partials ⚠️
selfservice/strategy/passkey/passkey_login.go 74.00% 32 Missing and 27 partials ⚠️
...lfservice/strategy/passkey/passkey_registration.go 73.44% 26 Missing and 21 partials ⚠️
selfservice/strategy/webauthn/registration.go 76.08% 9 Missing and 2 partials ⚠️
x/webauthnx/aaguid/aaguid.go 75.00% 2 Missing and 2 partials ⚠️
selfservice/strategy/webauthn/login.go 76.92% 0 Missing and 3 partials ⚠️
persistence/sql/identity/persister_identity.go 75.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #3656      +/-   ##
==========================================
- Coverage   79.47%   78.35%   -1.13%     
==========================================
  Files         356      352       -4     
  Lines       25999    24312    -1687     
==========================================
- Hits        20662    19049    -1613     
+ Misses       3826     3794      -32     
+ Partials     1511     1469      -42     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

embedx/config.schema.json Outdated Show resolved Hide resolved
identity/extension_credentials.go Outdated Show resolved Hide resolved
Co-authored-by: Jonas Hungershausen <[email protected]>
Copy link
Member

@aeneasr aeneasr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Preliminary review - also pushing some code changes now. Can you please add e2e tests for the various flows?

selfservice/strategy/passkey/passkey_registration.go Outdated Show resolved Hide resolved
if err != nil {
return s.handleRegistrationError(w, r, regFlow, params, err)
}
if params.Register == "" && params.Method != "passkey" {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible that Register is set to some value, but the method is actually not passkey but e.g. password, and then we use the wrong strategy here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not in a benign case, because the register param is set right before submission.

As for an attacker, what would the gain be?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the concern is, that params.Register == "values" but params.Method != "passkey".

Which would lead to this strategy being executed, even though the method specifies something else. IMO, we should always return ErrStrategyNotResponsible if params.Method != "passkey"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requiring the method to be set will further complicate submitting the passkey, which is done through a form submit in JS, see:

document.querySelector(triggerQuerySelector).closest("form").submit()

We don't require the method to be set for webauthn register requests either, btw:

func (s *Strategy) Register(w http.ResponseWriter, r *http.Request, regFlow *registration.Flow, i *identity.Identity) (err error) {
ctx := r.Context()
if regFlow.Type != flow.TypeBrowser || !s.d.Config().WebAuthnForPasswordless(r.Context()) {
return flow.ErrStrategyNotResponsible
}
var p updateRegistrationFlowWithWebAuthnMethod
if err := s.decode(&p, r); err != nil {
return s.handleRegistrationError(w, r, regFlow, &p, err)
}
regFlow.TransientPayload = p.TransientPayload
if err := flow.EnsureCSRF(s.d, r, regFlow.Type, s.d.Config().DisableAPIFlowEnforcement(ctx), s.d.GenerateCSRFToken, p.CSRFToken); err != nil {
return s.handleRegistrationError(w, r, regFlow, &p, err)
}
if len(p.Register) == 0 {
return flow.ErrStrategyNotResponsible
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Specifically, I could add a hidden input with name=method and value=passkey in JS. Is it worth it?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd say it's worth the effort also submitting the method, since we do have the (sometimes loose) convention to submit the method as well. Since this would just be another edge case, potentially causing issues down the road.

But, in the interest of velocity, I'll leave the decision up to you.

credentialWebAuthn := identity.CredentialFromWebAuthn(credential, true)
credentialsConfig, err := json.Marshal(identity.CredentialsWebAuthnConfig{
Credentials: identity.CredentialsWebAuthn{*credentialWebAuthn},
UserHandle: webAuthnSess.UserID,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about the display name? Do we not need it because it is a PassKey?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The display name of the key will be derived from the AAGUID, see here:

id := aaguid.Lookup(credential.Authenticator.AAGUID)
if id != nil {
cred.DisplayName = id.Name
}


ident.UpsertCredentialsConfig(s.ID(), credentialsConfig, 1)
passkeyCred, _ := ident.GetCredentials(s.ID())
passkeyCred.Identifiers = []string{string(webAuthnSess.UserID)}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should return an easy to understand error if webAuthnSess.UserID is empty. If it is empty, the current validation will pass it as valid

	c := i.GetCredentialsOr(identity.CredentialsTypePasskey, &identity.Credentials{})
	if len(c.Identifiers) == 0 {
		return schema.NewMissingIdentifierError()
	}

and will lead to an empty string identifier. We had this problem once before in the WebAuthn code.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case, webAuthnSess.UserID is not user-supplied. It comes from the internal context, which is set here:

user := &webauthnx.User{
Name: identifier,
ID: []byte(randx.MustString(64, randx.AlphaNum)),
Config: s.d.Config().PasskeyConfig(ctx),
}
option, sessionData, err := webAuthn.BeginRegistration(user)
if err != nil {
return errors.WithStack(err)
}

I can add another check to make sure it really is not empty, but it would be an internal server error, as the user can't do anything against it.

selfservice/strategy/passkey/passkey_login.go Show resolved Hide resolved
selfservice/strategy/passkey/passkey_login.go Outdated Show resolved Hide resolved
selfservice/strategy/passkey/passkey_login.go Outdated Show resolved Hide resolved
@aeneasr aeneasr changed the title feat: passwordless strategy feat: PassKey strategy with resident keys Jan 3, 2024
@hperl hperl requested a review from aeneasr January 10, 2024 12:56
Copy link
Member

@jonas-jonas jonas-jonas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, just a few minor remarks.

embedx/config.schema.json Show resolved Hide resolved
if err != nil {
return s.handleRegistrationError(w, r, regFlow, params, err)
}
if params.Register == "" && params.Method != "passkey" {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the concern is, that params.Register == "values" but params.Method != "passkey".

Which would lead to this strategy being executed, even though the method specifies something else. IMO, we should always return ErrStrategyNotResponsible if params.Method != "passkey"

aeneasr
aeneasr previously approved these changes Jan 12, 2024
x/webauthnx/js/webauthn.js Outdated Show resolved Hide resolved
x/webauthnx/js/webauthn.js Outdated Show resolved Hide resolved
@aeneasr
Copy link
Member

aeneasr commented Jan 12, 2024

@jonas-jonas are you good with the current state?

@jonas-jonas
Copy link
Member

@aeneasr I'll re-review now.

jonas-jonas
jonas-jonas previously approved these changes Jan 12, 2024
Copy link
Member

@jonas-jonas jonas-jonas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Except for #3656 (comment) this LGTM.

@hperl hperl dismissed stale reviews from jonas-jonas and aeneasr via 4321e75 January 15, 2024 09:24
@hperl
Copy link
Contributor Author

hperl commented Jan 15, 2024

@jonas-jonas, @aeneasr, two points remain open:

  • As I started writing docs, I stumbled over the fact that users need to modify the identity schema to add a passkey: { identifier: true } to their schema, most likely to the same identifier as used for webauthn. Do you think there is even a use case for using different identifiers for webauthn and passkey?
    • Resolved, I now also accept webauthn: { identifier: true }, which makes the whole thing backwards-compatible. Thanks @zepatrik for the discussion.
  • I can't get the coverage up further. Some errors only trigger when the database misbehaves, which would require extensive mocking. How should I proceed? The remaining tests would look more and more artifical.
    • Resolved, we'll merge anyways.

jonas-jonas
jonas-jonas previously approved these changes Jan 19, 2024
@hperl
Copy link
Contributor Author

hperl commented Feb 1, 2024

I'd like to have one more pair of eyes on the security side of this before merging. @zepatrik maybe?

@aeneasr
Copy link
Member

aeneasr commented Feb 2, 2024

I found a few bugs when enabling both webauthn and passkey strategies, probably the triggers get confused. Wrote those things in Slack - would be good to get those fixed in my view :)

@hperl
Copy link
Contributor Author

hperl commented Feb 8, 2024

included in #3748

@hperl hperl closed this Feb 8, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants