Skip to content

Commit 9e7c7d2

Browse files
committed
fix: validate resident key capability before credential creation
On Linux, check if the connected FIDO2 key supports resident/discoverable credentials before attempting to create one. Keys like ZUKEY 2 FIDO that don't support resident credentials would silently create non-resident credentials, causing registration to appear successful but login to fail. On Windows, stop hardcoding bRequireResidentKey=TRUE and instead read the authenticatorSelection.residentKey option from the server response.
1 parent 95f6ea0 commit 9e7c7d2

3 files changed

Lines changed: 295 additions & 4 deletions

File tree

packages/auth/amplify_auth_cognito/lib/src/linux/linux_webauthn_platform.dart

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -420,8 +420,25 @@ class LinuxWebAuthnPlatform implements WebAuthnCredentialPlatform {
420420
true,
421421
);
422422

423-
// Set resident key.
423+
// Set resident key — but first verify the device actually supports
424+
// discoverable credentials. Keys like the ZUKEY 2 FIDO silently
425+
// accept the rk flag but create a non-resident credential, causing
426+
// registration to appear successful while login later fails.
424427
if (residentKey == 'required' || residentKey == 'preferred') {
428+
final supportsRk = _checkDeviceSupportsResidentKey(b, dev);
429+
if (!supportsRk) {
430+
_logger.error(
431+
'Security key does not support resident/discoverable '
432+
'credentials (rk option missing or false in CBOR info). '
433+
'residentKey=$residentKey. A non-resident credential would '
434+
'be useless for passwordless sign-in.',
435+
);
436+
throw const PasskeyRegistrationFailedException(
437+
'This security key does not support passkeys (discoverable '
438+
'credentials). Please use a compatible key such as a '
439+
'YubiKey 5 or similar FIDO2 key with resident key support.',
440+
);
441+
}
425442
_checkFido(b.fidoCredSetRk(cred, fidoOptTrue), 'set rk', true);
426443
}
427444

@@ -1019,6 +1036,61 @@ class LinuxWebAuthnPlatform implements WebAuthnCredentialPlatform {
10191036
}
10201037
}
10211038

1039+
/// Queries the device's CBOR info to check if it supports resident
1040+
/// (discoverable) credentials via the `rk` option.
1041+
///
1042+
/// Returns `true` if the device advertises `rk: true` in its CTAP2 CBOR
1043+
/// info options, meaning it can store discoverable credentials on-device.
1044+
/// Returns `false` if the `rk` option is absent, explicitly `false`, or
1045+
/// if CBOR info cannot be retrieved.
1046+
///
1047+
/// This is critical for passwordless flows: keys like the ZUKEY 2 FIDO
1048+
/// that don't support resident credentials will silently create a
1049+
/// non-resident credential when `rk` is requested, causing registration
1050+
/// to appear successful but login to fail later because the passkey
1051+
/// isn't discoverable.
1052+
bool _checkDeviceSupportsResidentKey(LibFido2Bindings b, Pointer dev) {
1053+
final ci = b.fidoCborInfoNew();
1054+
final ciPtr = calloc<Pointer>();
1055+
ciPtr.value = ci;
1056+
1057+
try {
1058+
final savedMask = _blockProfilerSignal();
1059+
final rc = b.fidoDevGetCborInfo(dev, ci);
1060+
_restoreSignals(savedMask);
1061+
1062+
if (rc != fidoOk) {
1063+
_logger.warn(
1064+
'Could not retrieve CBOR info from device (error: $rc). '
1065+
'Cannot determine resident key support.',
1066+
);
1067+
return false;
1068+
}
1069+
1070+
final optCount = b.fidoCborInfoOptionsLen(ci);
1071+
final namesPtr = b.fidoCborInfoOptionsNamePtr(ci);
1072+
final valuesPtr = b.fidoCborInfoOptionsValuePtr(ci);
1073+
1074+
for (var i = 0; i < optCount; i++) {
1075+
final name = namesPtr[i].toDartString();
1076+
final value = valuesPtr[i];
1077+
if (name == 'rk') {
1078+
_logger.debug('Device CBOR info: rk=$value');
1079+
return value;
1080+
}
1081+
}
1082+
1083+
_logger.debug(
1084+
'Device CBOR info does not include "rk" option — '
1085+
'resident credentials are not supported.',
1086+
);
1087+
return false;
1088+
} finally {
1089+
b.fidoCborInfoFree(ciPtr);
1090+
calloc.free(ciPtr);
1091+
}
1092+
}
1093+
10221094
/// Returns the number of PIN retries remaining on the device.
10231095
/// Returns 8 (default) if the query fails.
10241096
int _getRetryCount(LibFido2Bindings b, Pointer dev) {

packages/auth/amplify_auth_cognito/lib/src/windows/windows_webauthn_platform.dart

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,11 @@ class WindowsWebAuthnPlatform implements WebAuthnCredentialPlatform {
115115

116116
_logger.debug('createCredential: rpId="$rpId", userName="$userName"');
117117

118+
// Extract authenticatorSelection options from the server response.
119+
final authSelection =
120+
optionsMap['authenticatorSelection'] as Map<String, dynamic>? ?? {};
121+
final residentKey = authSelection['residentKey'] as String?;
122+
118123
// Extract the challenge for building proper clientDataJSON.
119124
final challenge = optionsMap['challenge'] as String? ?? '';
120125

@@ -235,12 +240,32 @@ class WindowsWebAuthnPlatform implements WebAuthnCredentialPlatform {
235240
120000,
236241
);
237242

238-
// Require a resident/discoverable credential so the passkey is stored
239-
// on the device and can be used for sign-in later.
243+
// Set resident/discoverable credential requirement based on the
244+
// server's authenticatorSelection.residentKey option. For passwordless
245+
// flows, Cognito sends 'required' or 'preferred'. The Windows API
246+
// will show an error dialog to the user if the security key can't
247+
// create a resident credential when required.
248+
final int requireResidentKey;
249+
switch (residentKey) {
250+
case 'required':
251+
case 'preferred':
252+
// For passwordless, we need a discoverable credential even when
253+
// the server says 'preferred' — a non-resident credential would
254+
// register successfully but be unusable for sign-in.
255+
requireResidentKey = 1; // TRUE
256+
case 'discouraged':
257+
requireResidentKey = 0; // FALSE
258+
default:
259+
requireResidentKey = 0; // FALSE — safe default
260+
}
261+
_logger.debug(
262+
'createCredential: residentKey=$residentKey → '
263+
'bRequireResidentKey=$requireResidentKey',
264+
);
240265
_writeUint32At(
241266
options,
242267
MakeCredentialOptionsOffsets.bRequireResidentKey,
243-
1, // TRUE
268+
requireResidentKey,
244269
);
245270

246271
// Require user verification (PIN, biometric, etc.)

packages/auth/amplify_auth_cognito/test/linux_webauthn_platform_test.dart

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,78 @@ class MockLibFido2Bindings extends LibFido2Bindings {
334334
(_) => fidoOk;
335335
}
336336

337+
/// Mock bindings that report `rk: true` in CBOR info options,
338+
/// simulating a key that supports resident/discoverable credentials
339+
/// (e.g. YubiKey 5).
340+
class MockLibFido2BindingsWithRk extends MockLibFido2Bindings {
341+
MockLibFido2BindingsWithRk({
342+
super.mockDeviceCount,
343+
super.mockManifestResult,
344+
super.mockOpenResult,
345+
super.mockMakeCredResult,
346+
super.mockGetAssertResult,
347+
});
348+
349+
late Pointer<Pointer<Utf8>> _optionNames;
350+
late Pointer<Bool> _optionValues;
351+
352+
@override
353+
int Function(Pointer ci) get fidoCborInfoOptionsLen =>
354+
(_) => 2;
355+
356+
@override
357+
Pointer<Pointer<Utf8>> Function(Pointer ci) get fidoCborInfoOptionsNamePtr =>
358+
(_) {
359+
_optionNames = calloc<Pointer<Utf8>>(2);
360+
_optionNames[0] = 'rk'.toNativeUtf8();
361+
_optionNames[1] = 'clientPin'.toNativeUtf8();
362+
return _optionNames;
363+
};
364+
365+
@override
366+
Pointer<Bool> Function(Pointer ci) get fidoCborInfoOptionsValuePtr => (_) {
367+
_optionValues = calloc<Bool>(2);
368+
_optionValues[0] = true; // rk = true
369+
_optionValues[1] = false; // clientPin = false
370+
return _optionValues;
371+
};
372+
}
373+
374+
/// Mock bindings that report `rk: false` in CBOR info options,
375+
/// simulating a key that does NOT support resident/discoverable
376+
/// credentials (e.g. ZUKEY 2 FIDO).
377+
class MockLibFido2BindingsWithoutRk extends MockLibFido2Bindings {
378+
MockLibFido2BindingsWithoutRk({
379+
super.mockDeviceCount,
380+
super.mockManifestResult,
381+
super.mockOpenResult,
382+
super.mockMakeCredResult,
383+
super.mockGetAssertResult,
384+
});
385+
386+
late Pointer<Pointer<Utf8>> _optionNames;
387+
late Pointer<Bool> _optionValues;
388+
389+
@override
390+
int Function(Pointer ci) get fidoCborInfoOptionsLen =>
391+
(_) => 1;
392+
393+
@override
394+
Pointer<Pointer<Utf8>> Function(Pointer ci) get fidoCborInfoOptionsNamePtr =>
395+
(_) {
396+
_optionNames = calloc<Pointer<Utf8>>(1);
397+
_optionNames[0] = 'clientPin'.toNativeUtf8();
398+
return _optionNames;
399+
};
400+
401+
@override
402+
Pointer<Bool> Function(Pointer ci) get fidoCborInfoOptionsValuePtr => (_) {
403+
_optionValues = calloc<Bool>(1);
404+
_optionValues[0] = false;
405+
return _optionValues;
406+
};
407+
}
408+
337409
void main() {
338410
TestWidgetsFlutterBinding.ensureInitialized();
339411

@@ -513,6 +585,128 @@ void main() {
513585
);
514586
});
515587

588+
group('resident key capability check', () {
589+
test(
590+
'succeeds when residentKey=required and device supports rk',
591+
() async {
592+
final bindings = MockLibFido2BindingsWithRk();
593+
final platform = LinuxWebAuthnPlatform(bindings: bindings);
594+
595+
const optionsJson = '''
596+
{
597+
"rp": {"id": "example.com", "name": "Example"},
598+
"user": {"id": "dXNlcjEyMw", "name": "testuser", "displayName": "Test User"},
599+
"challenge": "Y2hhbGxlbmdl",
600+
"pubKeyCredParams": [{"type": "public-key", "alg": -7}],
601+
"authenticatorSelection": {"residentKey": "required", "userVerification": "preferred"}
602+
}
603+
''';
604+
605+
final result = await platform.createCredential(optionsJson);
606+
final decoded = jsonDecode(result) as Map<String, dynamic>;
607+
expect(decoded['type'], 'public-key');
608+
expect(decoded['id'], isNotEmpty);
609+
},
610+
);
611+
612+
test(
613+
'throws PasskeyRegistrationFailedException when residentKey=required '
614+
'and device does NOT support rk',
615+
() async {
616+
final bindings = MockLibFido2BindingsWithoutRk();
617+
final platform = LinuxWebAuthnPlatform(bindings: bindings);
618+
619+
const optionsJson = '''
620+
{
621+
"rp": {"id": "example.com", "name": "Example"},
622+
"user": {"id": "dXNlcjEyMw", "name": "testuser", "displayName": "Test User"},
623+
"challenge": "Y2hhbGxlbmdl",
624+
"pubKeyCredParams": [{"type": "public-key", "alg": -7}],
625+
"authenticatorSelection": {"residentKey": "required", "userVerification": "preferred"}
626+
}
627+
''';
628+
629+
expect(
630+
() => platform.createCredential(optionsJson),
631+
throwsA(
632+
isA<PasskeyRegistrationFailedException>().having(
633+
(e) => e.message,
634+
'message',
635+
contains('does not support passkeys'),
636+
),
637+
),
638+
);
639+
},
640+
);
641+
642+
test(
643+
'throws PasskeyRegistrationFailedException when residentKey=preferred '
644+
'and device does NOT support rk',
645+
() async {
646+
final bindings = MockLibFido2BindingsWithoutRk();
647+
final platform = LinuxWebAuthnPlatform(bindings: bindings);
648+
649+
const optionsJson = '''
650+
{
651+
"rp": {"id": "example.com", "name": "Example"},
652+
"user": {"id": "dXNlcjEyMw", "name": "testuser", "displayName": "Test User"},
653+
"challenge": "Y2hhbGxlbmdl",
654+
"pubKeyCredParams": [{"type": "public-key", "alg": -7}],
655+
"authenticatorSelection": {"residentKey": "preferred", "userVerification": "preferred"}
656+
}
657+
''';
658+
659+
expect(
660+
() => platform.createCredential(optionsJson),
661+
throwsA(isA<PasskeyRegistrationFailedException>()),
662+
);
663+
},
664+
);
665+
666+
test(
667+
'succeeds without authenticatorSelection (no rk check needed)',
668+
() async {
669+
final bindings = MockLibFido2Bindings();
670+
final platform = LinuxWebAuthnPlatform(bindings: bindings);
671+
672+
const optionsJson = '''
673+
{
674+
"rp": {"id": "example.com", "name": "Example"},
675+
"user": {"id": "dXNlcjEyMw", "name": "testuser", "displayName": "Test User"},
676+
"challenge": "Y2hhbGxlbmdl",
677+
"pubKeyCredParams": [{"type": "public-key", "alg": -7}]
678+
}
679+
''';
680+
681+
final result = await platform.createCredential(optionsJson);
682+
final decoded = jsonDecode(result) as Map<String, dynamic>;
683+
expect(decoded['type'], 'public-key');
684+
},
685+
);
686+
687+
test(
688+
'succeeds when residentKey=discouraged even without rk support',
689+
() async {
690+
final bindings = MockLibFido2BindingsWithoutRk();
691+
final platform = LinuxWebAuthnPlatform(bindings: bindings);
692+
693+
const optionsJson = '''
694+
{
695+
"rp": {"id": "example.com", "name": "Example"},
696+
"user": {"id": "dXNlcjEyMw", "name": "testuser", "displayName": "Test User"},
697+
"challenge": "Y2hhbGxlbmdl",
698+
"pubKeyCredParams": [{"type": "public-key", "alg": -7}],
699+
"authenticatorSelection": {"residentKey": "discouraged"}
700+
}
701+
''';
702+
703+
final result = await platform.createCredential(optionsJson);
704+
final decoded = jsonDecode(result) as Map<String, dynamic>;
705+
expect(decoded['type'], 'public-key');
706+
},
707+
);
708+
});
709+
516710
group('getCredential', () {
517711
test('returns JSON response on success', () async {
518712
final bindings = MockLibFido2Bindings();

0 commit comments

Comments
 (0)