commit bd2ee9467d71dcede62051cb3819e1bea90ff301
parent 5298097d24bec811fbe949217f2865d45c42d117
Author: John M. Schanck <jschanck@mozilla.com>
Date: Mon, 27 Oct 2025 21:38:42 +0000
Bug 1992469 - support for WebAuthn PublicKeyCredentialHint. r=keeler,webidl,tschuster
Differential Revision: https://phabricator.services.mozilla.com/D270060
Diffstat:
9 files changed, 112 insertions(+), 18 deletions(-)
diff --git a/dom/webauthn/MacOSWebAuthnService.mm b/dom/webauthn/MacOSWebAuthnService.mm
@@ -823,6 +823,20 @@ MacOSWebAuthnService::MakeCredential(uint64_t aTransactionId,
*userVerificationPreference;
}
+ if (__builtin_available(macos 13.5, *)) {
+ // Show the hybrid transport unless we have a non-empty hint list and
+ // none of the hints are for the hybrid transport.
+ bool hasHybridHint = false;
+ nsTArray<nsString> hints;
+ (void)aArgs->GetHints(hints);
+ for (nsString& hint : hints) {
+ if (hint.Equals(u"hybrid"_ns)) {
+ hasHybridHint = true;
+ }
+ }
+ platformRegistrationRequest.shouldShowHybridTransport =
+ hints.Length() == 0 || hasHybridHint;
+ }
if (__builtin_available(macos 14.0, *)) {
bool largeBlobSupportRequired;
nsresult rv =
@@ -1173,11 +1187,19 @@ void MacOSWebAuthnService::DoGetAssertion(
*userVerificationPreference;
}
if (__builtin_available(macos 13.5, *)) {
- // Show the hybrid transport option if (1) we have no transport hints
- // or (2) at least one allow list entry lists the hybrid transport.
+ // Show the hybrid transport option if (1) none of the allowlist
+ // credentials list transports, or (2) at least one allow list entry
+ // lists the hybrid transport, or (3) the request has the hybrid hint.
bool shouldShowHybridTransport =
!transports ||
(transports & MOZ_WEBAUTHN_AUTHENTICATOR_TRANSPORT_ID_HYBRID);
+ nsTArray<nsString> hints;
+ (void)aArgs->GetHints(hints);
+ for (nsString& hint : hints) {
+ if (hint.Equals(u"hybrid"_ns)) {
+ shouldShowHybridTransport = true;
+ }
+ }
platformAssertionRequest.shouldShowHybridTransport =
shouldShowHybridTransport;
}
diff --git a/dom/webauthn/PWebAuthnTransaction.ipdl b/dom/webauthn/PWebAuthnTransaction.ipdl
@@ -143,6 +143,7 @@ struct WebAuthnMakeCredentialInfo {
WebAuthnExtension[] Extensions;
WebAuthnAuthenticatorSelection AuthenticatorSelection;
nsString attestationConveyancePreference;
+ nsString[] Hints;
};
struct WebAuthnMakeCredentialResult {
@@ -168,6 +169,7 @@ struct WebAuthnGetAssertionInfo {
WebAuthnExtension[] Extensions;
nsString userVerificationRequirement;
bool ConditionallyMediated;
+ nsString[] Hints;
};
struct WebAuthnGetAssertionResult {
diff --git a/dom/webauthn/PublicKeyCredential.cpp b/dom/webauthn/PublicKeyCredential.cpp
@@ -660,6 +660,8 @@ void PublicKeyCredential::ParseCreationOptionsFromJSON(
aResult.mAuthenticatorSelection = aOptions.mAuthenticatorSelection.Value();
}
+ aResult.mHints = aOptions.mHints;
+
aResult.mAttestation = aOptions.mAttestation;
if (aOptions.mExtensions.WasPassed()) {
@@ -753,6 +755,8 @@ void PublicKeyCredential::ParseRequestOptionsFromJSON(
aResult.mUserVerification = aOptions.mUserVerification;
+ aResult.mHints = aOptions.mHints;
+
if (aOptions.mExtensions.WasPassed()) {
if (aOptions.mExtensions.Value().mAppid.WasPassed()) {
aResult.mExtensions.mAppid.Construct(
diff --git a/dom/webauthn/WebAuthnArgs.cpp b/dom/webauthn/WebAuthnArgs.cpp
@@ -246,6 +246,12 @@ WebAuthnRegisterArgs::GetLargeBlobSupportRequired(
return NS_ERROR_NOT_AVAILABLE;
}
+NS_IMETHODIMP
+WebAuthnRegisterArgs::GetHints(nsTArray<nsString>& aHints) {
+ aHints.Assign(mInfo.Hints());
+ return NS_OK;
+}
+
NS_IMPL_ISUPPORTS(WebAuthnSignArgs, nsIWebAuthnSignArgs)
NS_IMETHODIMP
@@ -485,4 +491,10 @@ WebAuthnSignArgs::GetLargeBlobWrite(nsTArray<uint8_t>& aLargeBlobWrite) {
return NS_ERROR_NOT_AVAILABLE;
}
+NS_IMETHODIMP
+WebAuthnSignArgs::GetHints(nsTArray<nsString>& aHints) {
+ aHints.Assign(mInfo.Hints());
+ return NS_OK;
+}
+
} // namespace mozilla::dom
diff --git a/dom/webauthn/WebAuthnHandler.cpp b/dom/webauthn/WebAuthnHandler.cpp
@@ -393,7 +393,7 @@ already_AddRefed<Promise> WebAuthnHandler::MakeCredential(
WebAuthnMakeCredentialInfo info(rpId, challenge, adjustedTimeout, excludeList,
rpInfo, userInfo, coseAlgos, extensions,
- authSelection, attestation);
+ authSelection, attestation, aOptions.mHints);
// Set up the transaction state. Fallible operations should not be performed
// below this line, as we must not leave the transaction state partially
@@ -667,7 +667,7 @@ already_AddRefed<Promise> WebAuthnHandler::GetAssertion(
WebAuthnGetAssertionInfo info(
rpId, maybeAppId, challenge, adjustedTimeout, allowList, extensions,
- aOptions.mUserVerification, aConditionallyMediated);
+ aOptions.mUserVerification, aConditionallyMediated, aOptions.mHints);
// Set up the transaction state. Fallible operations should not be performed
// below this line, as we must not leave the transaction state partially
diff --git a/dom/webauthn/WinWebAuthnService.cpp b/dom/webauthn/WinWebAuthnService.cpp
@@ -29,6 +29,10 @@ StaticRWLock gWinWebAuthnModuleLock;
static bool gWinWebAuthnModuleUnusable = false;
static HMODULE gWinWebAuthnModule = 0;
+static const LPCWSTR gWebAuthnHintStrings[3] = {
+ WEBAUTHN_CREDENTIAL_HINT_SECURITY_KEY,
+ WEBAUTHN_CREDENTIAL_HINT_CLIENT_DEVICE, WEBAUTHN_CREDENTIAL_HINT_HYBRID};
+
static decltype(WebAuthNIsUserVerifyingPlatformAuthenticatorAvailable)*
gWinWebauthnIsUVPAA = nullptr;
static decltype(WebAuthNAuthenticatorMakeCredential)*
@@ -49,7 +53,6 @@ static decltype(WebAuthNGetPlatformCredentialList)*
gWinWebauthnGetPlatformCredentialList = nullptr;
static decltype(WebAuthNFreePlatformCredentialList)*
gWinWebauthnFreePlatformCredentialList = nullptr;
-
} // namespace
/***********************************************************************
@@ -176,6 +179,18 @@ WinWebAuthnService::~WinWebAuthnService() {
}
// static
+void PrunePublicKeyCredentialHints(const nsTArray<nsString>& aInHints,
+ /* out */ nsTArray<LPCWSTR>& aOutHints) {
+ for (const nsString& inputHint : aInHints) {
+ for (const LPCWSTR knownHint : gWebAuthnHintStrings) {
+ if (inputHint.Equals(knownHint)) {
+ aOutHints.AppendElement(knownHint);
+ }
+ }
+ }
+}
+
+// static
bool WinWebAuthnService::AreWebAuthNApisAvailable() {
nsresult rv = EnsureWinWebAuthnModuleLoaded();
NS_ENSURE_SUCCESS(rv, false);
@@ -593,10 +608,16 @@ WinWebAuthnService::MakeCredential(uint64_t aTransactionId,
winPrivateBrowsing = TRUE;
}
+ nsTArray<nsString> inputHints;
+ (void)aArgs->GetHints(inputHints);
+
+ nsTArray<LPCWSTR> hints;
+ PrunePublicKeyCredentialHints(inputHints, hints);
+
// MakeCredentialOptions
WEBAUTHN_AUTHENTICATOR_MAKE_CREDENTIAL_OPTIONS
WebAuthNCredentialOptions = {
- WEBAUTHN_AUTHENTICATOR_MAKE_CREDENTIAL_OPTIONS_VERSION_7,
+ WEBAUTHN_AUTHENTICATOR_MAKE_CREDENTIAL_OPTIONS_VERSION_8,
timeout,
{0, NULL},
{0, NULL},
@@ -608,13 +629,16 @@ WinWebAuthnService::MakeCredential(uint64_t aTransactionId,
&cancellationId, // CancellationId
pExcludeCredentialList,
WEBAUTHN_ENTERPRISE_ATTESTATION_NONE,
- largeBlobSupport, // LargeBlobSupport
- winPreferResidentKey, // PreferResidentKey
- winPrivateBrowsing, // BrowserInPrivateMode
- winEnablePrf, // EnablePrf
- NULL, // LinkedDevice
- 0, // size of JsonExt
- NULL, // JsonExt
+ largeBlobSupport, // LargeBlobSupport
+ winPreferResidentKey, // PreferResidentKey
+ winPrivateBrowsing, // BrowserInPrivateMode
+ winEnablePrf, // EnablePrf
+ NULL, // LinkedDevice
+ 0, // size of JsonExt
+ NULL, // JsonExt
+ NULL, // PRFGlobalEval
+ (DWORD)hints.Length(), // Size of CredentialHints
+ hints.Elements(), // CredentialHints
};
if (rgExtension.Length() != 0) {
@@ -967,6 +991,12 @@ void WinWebAuthnService::DoGetAssertion(
pAllowCredentialList = &allowCredentialList;
}
+ nsTArray<nsString> inputHints;
+ (void)aArgs->GetHints(inputHints);
+
+ nsTArray<LPCWSTR> hints;
+ PrunePublicKeyCredentialHints(inputHints, hints);
+
uint32_t timeout_u32;
(void)aArgs->GetTimeoutMS(&timeout_u32);
DWORD timeout = timeout_u32;
@@ -980,7 +1010,7 @@ void WinWebAuthnService::DoGetAssertion(
WEBAUTHN_AUTHENTICATOR_GET_ASSERTION_OPTIONS WebAuthNAssertionOptions =
{
- WEBAUTHN_AUTHENTICATOR_GET_ASSERTION_OPTIONS_VERSION_7,
+ WEBAUTHN_AUTHENTICATOR_GET_ASSERTION_OPTIONS_VERSION_8,
timeout,
{0, NULL},
{0, NULL},
@@ -1000,6 +1030,8 @@ void WinWebAuthnService::DoGetAssertion(
FALSE, // AutoFill
0, // Size of JsonExt
NULL, // JsonExt
+ (DWORD)hints.Length(), // Size of CredentialHints
+ hints.Elements(), // CredentialHints
};
PWEBAUTHN_ASSERTION pWebAuthNAssertion = nullptr;
diff --git a/dom/webauthn/nsIWebAuthnArgs.idl b/dom/webauthn/nsIWebAuthnArgs.idl
@@ -65,6 +65,8 @@ interface nsIWebAuthnRegisterArgs : nsISupports {
// consent popup.
[must_use] readonly attribute AString attestationConveyancePreference;
+ readonly attribute Array<AString> hints;
+
readonly attribute boolean privateBrowsing;
};
@@ -111,6 +113,8 @@ interface nsIWebAuthnSignArgs : nsISupports {
// cancel transactions.
readonly attribute unsigned long timeoutMS;
+ readonly attribute Array<AString> hints;
+
readonly attribute boolean conditionallyMediated;
readonly attribute boolean privateBrowsing;
diff --git a/dom/webauthn/tests/test_webauthn_serialization.html b/dom/webauthn/tests/test_webauthn_serialization.html
@@ -36,6 +36,18 @@
is(arr.length, 0, `${description} (array should be empty)`);
}
+ function stringArrayEquals(actual, expected, description) {
+ is(actual.length, expected.length, `${description} (actual and expected should have the same length)`);
+ for (let i = 0; i < actual.length; i++) {
+ if (actual[i] instanceof String) {
+ throw new Error(`actual[${i}] is not a string` + typeof actual[i]);
+ }
+ if (actual[i] !== expected[i]) {
+ throw new Error(`actual and expected differ in position ${i}: ${actual[i]} vs ${expected[i]}`);
+ }
+ }
+ }
+
function shouldThrow(func, expectedError, description) {
let threw = false;
try {
@@ -55,7 +67,7 @@
pubKeyCredParams: [],
};
let creationOptions = PublicKeyCredential.parseCreationOptionsFromJSON(creationOptionsJSON);
- is(Object.getOwnPropertyNames(creationOptions).length, 8, "creation options should have 8 properties");
+ is(Object.getOwnPropertyNames(creationOptions).length, 9, "creation options should have 9 properties");
is(creationOptions.rp.id, undefined, "rp.id should be undefined");
is(creationOptions.rp.name, "Example", "rp.name should be Example");
arrayBufferEqualsArray(creationOptions.user.id, [ 250, 93, 234, 52, 180, 202, 38, 120 ], "user.id should be as expected");
@@ -70,6 +82,7 @@
is(creationOptions.authenticatorSelection.requireResidentKey, false, "creationOptions.authenticatorSelection.requireResidentKey should be false");
is(creationOptions.authenticatorSelection.userVerification, "preferred", "creationOptions.authenticatorSelection.userVerification should be preferred");
is(creationOptions.attestation, "none", "attestation should be none");
+ stringArrayEquals(creationOptions.hints, [], "hints should be an empty array");
is(Object.getOwnPropertyNames(creationOptions.extensions).length, 0, "extensions should be an empty object");
});
@@ -105,7 +118,7 @@
},
};
let creationOptions = PublicKeyCredential.parseCreationOptionsFromJSON(creationOptionsJSON);
- is(Object.getOwnPropertyNames(creationOptions).length, 9, "creation options should have 9 properties");
+ is(Object.getOwnPropertyNames(creationOptions).length, 10, "creation options should have 10 properties");
is(creationOptions.rp.name, "Example", "rp.name should be Example");
is(creationOptions.rp.id, "example.com", "rp.id should be example.com");
arrayBufferEqualsArray(creationOptions.user.id, [ 215, 212, 213, 166, 160, 65, 56, 3 ], "user.id should be as expected");
@@ -125,6 +138,7 @@
is(creationOptions.authenticatorSelection.residentKey, "required", "creationOptions.authenticatorSelection.residentKey should be required");
is(creationOptions.authenticatorSelection.requireResidentKey, true, "creationOptions.authenticatorSelection.requireResidentKey should be true");
is(creationOptions.authenticatorSelection.userVerification, "discouraged", "creationOptions.authenticatorSelection.userVerification should be discouraged");
+ stringArrayEquals(creationOptions.hints, creationOptionsJSON.hints, "creationOptions.hints should be as expected");
is(creationOptions.attestation, "indirect", "attestation should be indirect");
is(creationOptions.extensions.appid, "https://www.example.com/appID", "extensions.appid should be https://www.example.com/appID");
is(creationOptions.extensions.credProps, true, "extensions.credProps should be true");
@@ -186,12 +200,13 @@
challenge: "3yW2WHD_jbU",
};
let requestOptions = PublicKeyCredential.parseRequestOptionsFromJSON(requestOptionsJSON);
- is(Object.getOwnPropertyNames(requestOptions).length, 4, "request options should have 4 properties");
+ is(Object.getOwnPropertyNames(requestOptions).length, 5, "request options should have 5 properties");
arrayBufferEqualsArray(requestOptions.challenge, [ 223, 37, 182, 88, 112, 255, 141, 181 ], "challenge should be as expected");
is(requestOptions.timeout, undefined, "timeout should be undefined");
is(requestOptions.rpId, undefined, "rpId should be undefined");
isEmptyArray(requestOptions.allowCredentials, "allowCredentials should be an empty array");
is(requestOptions.userVerification, "preferred", "userVerification should be preferred");
+ stringArrayEquals(requestOptions.hints, [], "hints should be an empty array");
is(Object.getOwnPropertyNames(requestOptions.extensions).length, 0, "extensions should be an empty object");
});
@@ -222,7 +237,7 @@
},
};
let requestOptions = PublicKeyCredential.parseRequestOptionsFromJSON(requestOptionsJSON);
- is(Object.getOwnPropertyNames(requestOptions).length, 6, "request options should have 6 properties");
+ is(Object.getOwnPropertyNames(requestOptions).length, 7, "request options should have 7 properties");
arrayBufferEqualsArray(requestOptions.challenge, [ 64, 7, 218, 103, 1, 16, 10, 68 ], "challenge should be as expected");
is(requestOptions.timeout, 25000, "timeout should be 25000");
is(requestOptions.rpId, "example.com", "rpId should be example.com");
@@ -232,6 +247,7 @@
is(requestOptions.allowCredentials[0].transports.length, 1, "allowCredentials[0].transports should have one element");
is(requestOptions.allowCredentials[0].transports[0], "smart-card", "allowCredentials[0].transports[0] should be usb");
is(requestOptions.userVerification, "discouraged", "userVerification should be discouraged");
+ stringArrayEquals(requestOptions.hints, requestOptionsJSON.hints, "requestOptions.hints should be as expected");
is(requestOptions.extensions.appid, "https://www.example.com/anotherAppID", "extensions.appid should be https://www.example.com/anotherAppID");
arrayBufferEqualsArray(requestOptions.extensions.prf.eval.first, [102, 105, 114, 115, 116], "extensions.prf.eval.first should be 'first'");
arrayBufferEqualsArray(requestOptions.extensions.prf.eval.second, [115, 101, 99, 111, 110, 100], "extensions.prf.eval.second should be 'second'");
diff --git a/dom/webidl/WebAuthentication.webidl b/dom/webidl/WebAuthentication.webidl
@@ -177,6 +177,7 @@ dictionary PublicKeyCredentialCreationOptions {
sequence<PublicKeyCredentialDescriptor> excludeCredentials = [];
// FIXME: bug 1493860: should this "= {}" be here?
AuthenticatorSelectionCriteria authenticatorSelection = {};
+ sequence<DOMString> hints = [];
DOMString attestation = "none";
// FIXME: bug 1493860: should this "= {}" be here?
AuthenticationExtensionsClientInputs extensions = {};
@@ -208,6 +209,7 @@ dictionary PublicKeyCredentialRequestOptions {
USVString rpId;
sequence<PublicKeyCredentialDescriptor> allowCredentials = [];
DOMString userVerification = "preferred";
+ sequence<DOMString> hints = [];
// FIXME: bug 1493860: should this "= {}" be here?
AuthenticationExtensionsClientInputs extensions = {};
};