Open nsatragno opened 1 month ago
And I assume the challengeURL
would return a application/octet-stream
right?
Given I have this boilerplate code in my repo I like this change.
async function newChallenge() {
const challengeResponse = await fetch("/challenge", {
method: "POST",
headers: { "Accept": "application/octet-stream" }
});
if (!challengeResponse.ok) {
throw new Error("Failed to fetch challenge");
}
const challenge = await challengeResponse.arrayBuffer();
return challenge;
}
async function getPasskey() {
return await navigator.credentials.get({
publicKey: {
challenge: (await newChallenge()),
allowCredentials: [],
userVerification: "required",
}
});
}
Another option could be that we change the type of challenge
parameter to BufferSource | Promise<BufferSource>
.
The promise then gets executed asynchronously with the UI showing. This solves some issues with having to pull in HTTP semantics into the API.
Promise<BufferSource>
, what I'm going to say is an alternative way of pronouncing "challenge callback", which was discussed at pretty extensive length over here:
https://github.com/w3c/webauthn/issues/1856
The issue has all but been closed since then.
In the WAWG discussion at TPAC yesterday the challenge URL was understood to be a better pattern for mobile use cases as well, so that whether you're in a browser or a native app an RP could allow for parallelizable credential discovery and challenge requesting to significantly improve some at-scale passkey auth scenarios (the idea is rpId
could be specified earlier in page/app initialization, then while the platform/browser is discovering available credentials from available providers using the RP ID, a separate request for the challenge could occur in parallel to then pass in as client data to whatever authenticator/provider the user ultimately chooses to sign in with.)
Why do challenges need to be fetched from the server? Couldn't the challenge also be generated client-side? This would reduce latency even further. Is the idea that mobile devices are not powerful enough to generate 16 bytes of entropy for the challenge?
The server wouldn't be able to trust a challenge that was generated on the client. If the assertion was being replayed, the client would just replay the same challenge.
You can get away with generating client-side challenges if they're based on a timestamp. Then, they could only be replayed within the period that they're valid. If you can accept that, then it's a great option for UX.
You can get away with generating client-side challenges if they're based on a timestamp.
Each step away from "randomly generated at the server" costs some bit of security:
Method | Characteristics |
---|---|
Randomly generated at the server | Best |
Server-encrypted timestamp | Assertions can be replayed within the accepted time window |
Client-generated timestamp | Same reply is possible but also attackers with transient access can generate assertions that will be valid in the future. (This also depends on client–server clock sync.) |
Fixed challenge | Degrades to being a bearer token, like a password |
In my implementation experience, challenges typically need to be associated with a particular session so that the server can verify that the assertion is signed over the expected challenge for that session. How would this association be expressed in a challengeUrl
? I'm guessing you'd have to use either query parameters or a session cookie?
In my implementation experience, challenges typically need to be associated with a particular session so that the server can verify that the assertion is signed over the expected challenge for that session. How would this association be expressed in a
challengeUrl
? I'm guessing you'd have to use either query parameters or a session cookie?
If the challenge is at least 16 bytes of random data as you recommend, then shouldn't that be enough to associate with a particular session since it's functionally globally unique? As long as the challenge is removed from memory of course.
For example in my implementation, I have a hash table keyed by the 16-byte random challenges. This hash table contains the expiration of the challenge/ceremony. When a client sends a response, either the challenge is part of the hash table or not. If it is, it is removed from the hash table and the rest of the ceremony is completed. There is no "session id" involved.
I have uploaded an explainer to the wiki with a set of proposed details on how this would work.
I don't think a GET request is a good idea. As the request is not idempotent. It should be a POST. We might also want to think of adding Cache-Control: no-store
Agreed @arianvp. HTTP semantics aside, there are countless situations where the proposal might not work for a given RP - including political/bureaucratic, non-technical reasons - and I'd be disappointed if only a subset could benefit from the improvements this change could yield. Any application today that a) DOES[^1] need any kind of authn to get their challenge and b) uses anything other than cookies to perform that authn (e.g. Authorization header) couldn't use this as proposed.
In the WAWG discussion at TPAC yesterday the challenge URL was understood to be a better pattern for mobile use cases as well, so that whether you're in a browser or a native app an RP could allow for parallelizable credential discovery and challenge requesting to significantly improve some at-scale passkey auth scenarios
@MasterKale are you or others able to provide a bit more background here, especially for those of us that were not at TPAC? From the rest of your comment, I'm interpreting "better pattern" to mean "better pattern than challengeCallback: () => Promise<BufferSource>
" rather than "better pattern than the status quo of inlined BufferSource
", so please correct me if I'm misinterpreting!
I'd especially like help understanding how this impacts native apps at all, since they have their own platform-native APIs (e.g. the ASAuthorization
family in Apple's ecosystem) and are, at most, only really impacted by the definition of the signing process.
Implementation aside, I think it would also be worth adding another value into getClientCapabilities()
to indicate the availability of wherever this lands.
If possible, I think it's also worth adjusting the "fetch behavior" section to replace the create/get split with conditional/non-conditional mediation. This would allow conditional create to benefit in the same ways (reduced data loading when not needed, being able to have short challenge TTLs, etc) as conditional get.
[^1]: this isn't commentary on whether that should be the case, as has already been discussed at length in the thread.
@arianvp's suggestion of using a POST makes sense, and I can update the explainer with that change.
HTTP semantics aside, there are countless situations where the proposal might not work for a given RP - including political/bureaucratic, non-technical reasons - and I'd be disappointed if only a subset could benefit from the improvements this change could yield.
That's true about Authorization headers but, generalizing a bit, I don't see a version of this where the request is as flexible as using the Fetch API directly, and sites still have the option of using that as they might be doing today. If there is a specific problem that RPs are going to often run into then we should probably try to accommodate that. Setting up an HTTP endpoint to serve random bytes and cache them in a session-keyed map doesn't seem like a terribly complicated thing to do, although perhaps there are constraints I'm not aware of.
If there is a specific problem that RPs are going to often run into then we should probably try to accommodate that.
The challenge I see here is that any given backend stack tends to have its own unique requirements (this has certainly been the case at every company I've worked at), such that I expect relatively few would be able to benefit from this enhancement. Beyond what's already been discussed with sessions and authn, automatic CSRF mitigation rules applied by frameworks come to mind.
As an alternative, I wonder if instead of accepting only a string, accepting any fetch resource parameter (i.e. string
or Request
) could work instead. It would provide most of the flexibility of the challengeCallback
approach while still offering a simple/recommended default path with very little additional complexity in the browser-side implementation.
If a string is passed, use that as you've already described with some default semantics; if you provide a Request
then it would be used exactly as-is with the general caveats already applied (the response must be ok
, have a content-type
of application/octet-stream
, and a content-length
of >=16 bytes)
Internally something like this:
let request
if (typeof challengeUrl === "string") {
request = new Request(challengeUrl, {
headers: { Expect: 'application/octet-stream' },
method: 'POST',
// ... (others as specified)
})
} else {
request = challengeUrl
}
const response = await fetch(request)
if (!response.ok) {
// fail the webauthn process
}
const challenge = await response.arrayBuffer()
// continue as if a challenge had been provided directly
Thoughts?
That sounds like a reasonable thing that a browser could do, but someone in an offline discussion pointed out to me that this all has to be possible for the underlying platforms to implement as well. In cases where browsers pass requests through to platform WebAuthn APIs, it will be they who are fetching the challenge, not the browsers. This causes some problems, including for the explainer as currently written.
For one, it means the request should not be credentialed. It is undesirable for browsers to be passing user session cookies, for example, to passkey providers.
Also, while it might be possible to specify a set of arguments that RPs can add for certain special handling of the request (such as additional HTTP headers), passkey providers in general shouldn't be expected to have up-to-date implementations of the Fetch API, which would be implied if we allowed a resource Request
as a parameter.
I've somewhat expanded and modified the explainer, fleshing out some of the concerns I mentioned above. Unfortunately for this to be viable I think we have to make it considerably more restrictive for RPs, rather than less, for reasons that are now in the security section.
I understand this might make it more difficult for RPs to deploy.
The proposed constraints are:
https:
scheme.RPs can use a query string in the URL to convey information to the challengeURL endpoint.
@kenrb, is there a reason HTTP POST is not used? Perhaps there is something I misunderstand in the security section, but I don't see how any of the constraints preclude the more appropriate method since as stated, the operation is not idempotent. You said you would update the explainer, so I'm unsure if this is an oversight or a change in stance.
WebAuthn challenges usually need to be fetched from the server. This introduces extra latency, especially in cases where the page is loaded from offline storage and apps. This extra latency delays when WebAuthn credentials can be shown to the user in an empty allow-list request.
Proposed Change
Add a
challengeUrl
parameter that lets authenticators (or user agents) asynchronously fetch the challenge. This would let browsers render the list of credentials before the challenge comes back, improving the user experience. Add feature detection for it.This obsoletes issue #1856.