From 8f8b988c78719a3d5208c26ccc7a42417de40e1a Mon Sep 17 00:00:00 2001 From: Robert Schaffar-Taurok Date: Thu, 13 Aug 2026 11:16:09 +0200 Subject: [PATCH] feat: download resident keys from FIDO2 security keys Adds an ssh-keygen -K equivalent to the Add hardware key sheet: a new "From key" action reads resident (discoverable) OpenSSH credentials straight off a FIDO2 authenticator over USB or NFC and turns them into the usual *_sk stubs, so a lost stub no longer requires a desktop to recover access. - New FidoResidentKeyDownloader speaks CTAP2 authenticatorCredential- Management, including the pre-2.1 credentialMgmtPreview command byte that most deployed YubiKeys expose, enumerates RPs filtered to the ssh: application prefix, and rebuilds ed25519-sk / ecdsa-sk stubs (mirroring OpenSSH flag semantics, incl. credProtect=3 -> UV flag). - PIN collected up front so iOS NFC sessions survive; incorrect PINs re-prompt with remaining retries, mirroring the signing flow. - Sheet shows live status while talking to the key and offers a picker when several resident SSH keys are found; label is pre-filled from the application suffix. Closes #154 --- .../presentation/add_hardware_key_sheet.dart | 157 +++++- .../widgets/key_source_actions.dart | 12 + .../data/fido_resident_key_downloader.dart | 402 +++++++++++++ .../fido_resident_key_downloader_test.dart | 526 ++++++++++++++++++ test/support/test_doubles.dart | 7 +- 5 files changed, 1100 insertions(+), 4 deletions(-) create mode 100644 lib/features/terminal/data/fido_resident_key_downloader.dart create mode 100644 test/features/terminal/fido_resident_key_downloader_test.dart diff --git a/lib/features/hosts/presentation/add_hardware_key_sheet.dart b/lib/features/hosts/presentation/add_hardware_key_sheet.dart index 65058db..6a9a690 100644 --- a/lib/features/hosts/presentation/add_hardware_key_sheet.dart +++ b/lib/features/hosts/presentation/add_hardware_key_sheet.dart @@ -8,6 +8,11 @@ import 'package:conduit/features/hosts/domain/ssh_key.dart'; import 'package:conduit/features/hosts/presentation/public_key_sheet.dart'; import 'package:conduit/features/hosts/presentation/widgets/key_source_actions.dart'; import 'package:conduit/features/hosts/presentation/widgets/ssh_key_summary.dart'; +import 'package:conduit/features/terminal/data/fido_hardware_key_ctap_device.dart'; +import 'package:conduit/features/terminal/data/fido_resident_key_downloader.dart'; +import 'package:conduit/features/terminal/data/ssh_error_formatter.dart'; +import 'package:conduit/features/terminal/domain/security_key_interaction.dart'; +import 'package:conduit/features/terminal/presentation/security_key_pin_dialog.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -69,6 +74,8 @@ class _AddHardwareKeySheetState extends State<_AddHardwareKeySheet> { String? _blockingError; bool _showPassphrase = false; bool _labelEdited = false; + bool _downloading = false; + String? _downloadStatus; Timer? _verifyTimer; int _verifyToken = 0; @@ -197,6 +204,83 @@ class _AddHardwareKeySheetState extends State<_AddHardwareKeySheet> { _setStub(text); } + Future _downloadFromKey() async { + if (_downloading) return; + setState(() { + _downloading = true; + _downloadStatus = 'Waiting for hardware key over USB or NFC...'; + }); + try { + final downloader = FidoResidentKeyDownloader( + openDevice: FidoHardwareKeyCtapDevice.open, + closeDevice: FidoHardwareKeyCtapDevice.close, + onStatus: (message) { + if (mounted) setState(() => _downloadStatus = message); + }, + onPinRequest: ({int? retriesRemaining}) => showSecurityKeyPinDialog( + context, + SecurityKeyPinRequest(retriesRemaining: retriesRemaining), + ), + ); + final keys = await downloader.download(); + if (!mounted) return; + if (keys.isEmpty) { + setState( + () => _downloadStatus = + 'No resident SSH keys were found on this security key.', + ); + return; + } + final key = keys.length == 1 ? keys.first : await _pickResidentKey(keys); + if (key == null || !mounted) return; + _setStub(key.toPem()); + final suggestedLabel = key.suggestedLabel; + if (!_labelEdited && suggestedLabel.isNotEmpty) { + _labelController.text = suggestedLabel; + } + setState(() => _downloadStatus = null); + } on ResidentKeyDownloadCancelled { + if (mounted) setState(() => _downloadStatus = null); + } catch (error) { + if (mounted) { + setState(() => _downloadStatus = describeSshConnectionError(error)); + } + } finally { + if (mounted) setState(() => _downloading = false); + } + } + + Future _pickResidentKey( + List keys, + ) { + return showDialog( + context: context, + builder: (context) => SimpleDialog( + title: const Text('Choose a resident key'), + children: [ + for (final key in keys) + SimpleDialogOption( + onPressed: () => Navigator.of(context).pop(key), + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.vpn_key_outlined), + title: Text( + key.suggestedLabel.isEmpty + ? key.algorithm + : key.suggestedLabel, + ), + subtitle: Text( + '${key.algorithm} ยท ${key.application}\n' + '${key.fingerprintSha256}', + ), + isThreeLine: true, + ), + ), + ], + ), + ); + } + void _showSnack(String message) { if (!mounted) return; ScaffoldMessenger.of( @@ -247,15 +331,27 @@ class _AddHardwareKeySheetState extends State<_AddHardwareKeySheet> { const SizedBox(height: 6), Text( 'Import or paste the OpenSSH *_sk stub that ssh-keygen created ' - 'for this security key. The stub only points to the key; the ' - 'private part never leaves the hardware.', + 'for this security key, or download a resident key straight ' + 'from the key. The stub only points to the key; the private ' + 'part never leaves the hardware.', style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, height: 1.3, ), ), const SizedBox(height: 16), - KeySourceActions(onImportFile: _importFile, onPaste: _paste), + KeySourceActions( + onImportFile: _importFile, + onPaste: _paste, + onDownloadFromKey: _downloadFromKey, + ), + if (_downloadStatus != null) ...[ + const SizedBox(height: 14), + _DownloadStatusNotice( + message: _downloadStatus!, + inProgress: _downloading, + ), + ], const SizedBox(height: 14), TextField( controller: _stubController, @@ -339,6 +435,61 @@ class _AddHardwareKeySheetState extends State<_AddHardwareKeySheet> { } } +class _DownloadStatusNotice extends StatelessWidget { + const _DownloadStatusNotice({ + required this.message, + required this.inProgress, + }); + + final String message; + final bool inProgress; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Color.alphaBlend( + colorScheme.primary.withValues(alpha: 0.06), + colorScheme.surface, + ), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (inProgress) + const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + Icon( + Icons.info_outline_rounded, + size: 18, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface, + height: 1.25, + ), + ), + ), + ], + ), + ); + } +} + class _ErrorNotice extends StatelessWidget { const _ErrorNotice({required this.message}); diff --git a/lib/features/hosts/presentation/widgets/key_source_actions.dart b/lib/features/hosts/presentation/widgets/key_source_actions.dart index 8ba9664..ea84660 100644 --- a/lib/features/hosts/presentation/widgets/key_source_actions.dart +++ b/lib/features/hosts/presentation/widgets/key_source_actions.dart @@ -5,12 +5,14 @@ class KeySourceActions extends StatelessWidget { required this.onImportFile, required this.onPaste, this.onGenerate, + this.onDownloadFromKey, super.key, }); final VoidCallback onImportFile; final VoidCallback onPaste; final VoidCallback? onGenerate; + final VoidCallback? onDownloadFromKey; @override Widget build(BuildContext context) { @@ -41,6 +43,16 @@ class KeySourceActions extends StatelessWidget { ), ), ], + if (onDownloadFromKey != null) ...[ + const SizedBox(width: 8), + Expanded( + child: _SourceButton( + icon: Icons.sim_card_download_rounded, + label: 'From key', + onPressed: onDownloadFromKey!, + ), + ), + ], ], ); } diff --git a/lib/features/terminal/data/fido_resident_key_downloader.dart b/lib/features/terminal/data/fido_resident_key_downloader.dart new file mode 100644 index 0000000..fcf4d7f --- /dev/null +++ b/lib/features/terminal/data/fido_resident_key_downloader.dart @@ -0,0 +1,402 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:cbor/cbor.dart'; +import 'package:crypto/crypto.dart'; +import 'package:dartssh2/dartssh2.dart'; +import 'package:fido2/fido2_client.dart'; + +import 'openssh_security_key_signer.dart'; +import 'ssh_error_formatter.dart'; + +/// A resident (discoverable) OpenSSH credential read off a FIDO2 +/// authenticator, equivalent to one entry of `ssh-keygen -K`. +class ResidentSecurityKey { + const ResidentSecurityKey({required this.keyPair, this.userName}); + + final OpenSSHSecurityKeyPair keyPair; + final String? userName; + + String get application => keyPair.application; + + String get algorithm => + keyPair is OpenSSHSecurityKeyEd25519KeyPair ? 'ed25519-sk' : 'ecdsa-sk'; + + bool get requiresUserVerification => keyPair.flags & 0x04 != 0; + + String get fingerprintSha256 { + final digest = sha256.convert(keyPair.toPublicKey().encode()).bytes; + return 'SHA256:${base64.encode(digest).replaceAll('=', '')}'; + } + + /// A label suggestion for the imported key: the application suffix after + /// `ssh:` when present, otherwise the (non-default) user name. + String get suggestedLabel { + final app = application.startsWith('ssh:') + ? application.substring(4).trim() + : application.trim(); + if (app.isNotEmpty) { + return app; + } + final name = userName?.trim() ?? ''; + // ssh-keygen enrolls resident keys with the fixed user name "openssh", + // which carries no information worth showing. + if (name.isNotEmpty && name != 'openssh') { + return name; + } + return ''; + } + + String toPem() => keyPair.toPem(); +} + +class ResidentKeyDownloadCancelled implements Exception { + const ResidentKeyDownloadCancelled(); +} + +class ResidentKeyDownloadError implements Exception { + const ResidentKeyDownloadError(this.message); + + final String message; + + @override + String toString() => message; +} + +/// Downloads resident OpenSSH credentials from a FIDO2 authenticator using +/// CTAP2 credential management, like `ssh-keygen -K` does on desktop. +/// +/// The private part never leaves the authenticator: the produced stubs only +/// contain the public key, application and credential handle. +class FidoResidentKeyDownloader { + const FidoResidentKeyDownloader({ + required this.openDevice, + this.closeDevice, + this.onStatus, + this.onPinRequest, + }); + + final CtapDeviceOpener openDevice; + final CtapDeviceCloser? closeDevice; + final SecurityKeyStatusHandler? onStatus; + final SecurityKeyPinRequester? onPinRequest; + + static const _credentialManagementPreviewCommand = 0x41; + static const _sshApplicationPrefix = 'ssh:'; + static const _maxPinAttempts = 3; + + Future> download() async { + String? pin; + int? pinRetriesRemaining; + var pinAttempts = 0; + + while (true) { + // NFC sessions cannot survive a PIN dialog on iOS, so the PIN has to + // be collected before the session opens. Reading resident keys always + // needs the PIN, so collect it up front everywhere for one less + // round-trip on rejection. + pin ??= await _promptForPin(retriesRemaining: pinRetriesRemaining); + + onStatus?.call('Waiting for hardware key over USB or NFC...'); + final device = await openDevice(); + var ok = false; + try { + final ctap = await Ctap2.create(device); + final command = _credentialManagementCommandFor(ctap.info); + final pinProtocol = _pinProtocolFor(ctap.info); + final clientPin = ClientPin(ctap, pinProtocol: pinProtocol); + + final List pinToken; + try { + pinToken = await clientPin.getPinToken( + pin, + permissions: [ClientPinPermission.credentialManagement], + ); + } on CtapError catch (error) { + if (error.status != CtapStatusCode.ctap2ErrPinInvalid) { + onStatus?.call(describeCtapStatus(error.status)); + rethrow; + } + pin = null; + pinAttempts++; + pinRetriesRemaining = await _pinRetries(clientPin); + final outOfRetries = + pinRetriesRemaining != null && pinRetriesRemaining <= 0; + if (pinAttempts >= _maxPinAttempts || outOfRetries) { + onStatus?.call(describeCtapStatus(error.status)); + rethrow; + } + onStatus?.call('Security key PIN was incorrect. Try again.'); + continue; + } + + onStatus?.call('Reading resident keys from the security key...'); + final credentials = _CredentialManagementClient( + device: device, + command: command, + pinProtocol: pinProtocol, + pinToken: pinToken, + ); + final keys = await _readSshKeys(credentials); + ok = true; + onStatus?.call( + keys.isEmpty + ? 'No resident SSH keys found on this security key.' + : 'Found ${keys.length} resident SSH ' + '${keys.length == 1 ? 'key' : 'keys'}.', + ); + return keys; + } finally { + await closeDevice?.call(device, ok); + } + } + } + + Future> _readSshKeys( + _CredentialManagementClient credentials, + ) async { + final keys = []; + for (final rp in await credentials.enumerateRps()) { + final rpId = rp.rp.id; + if (!rpId.startsWith(_sshApplicationPrefix)) { + continue; + } + for (final credential in await credentials.enumerateCredentials( + rp.rpIdHash, + )) { + final key = _toResidentKey(rpId, credential); + if (key != null) { + keys.add(key); + } + } + } + return keys; + } + + ResidentSecurityKey? _toResidentKey(String rpId, CmCredential credential) { + // Mirrors OpenSSH sk-usbhid.c: resident keys are marked as such, always + // require user presence, and require user verification when the + // credential was created with credProtect uvRequired (0x03). + var flags = 0x01 | 0x20; + if (credential.credProtect == 0x03) { + flags |= 0x04; + } + final keyHandle = Uint8List.fromList(credential.credentialId.id); + final publicKey = credential.publicKey; + + final OpenSSHSecurityKeyPair keyPair; + if (publicKey is EdDSA && + publicKey[CoseKey.okpCrvIdx] == CoseKey.okpCrvEd25519) { + keyPair = OpenSSHSecurityKeyEd25519KeyPair( + publicKey: Uint8List.fromList( + (publicKey[CoseKey.okpXIdx] as List).cast(), + ), + application: rpId, + flags: flags, + keyHandle: keyHandle, + reserved: '', + ); + } else if (publicKey is ES256 && + publicKey[CoseKey.ec2CrvIdx] == CoseKey.ec2CrvP256) { + keyPair = OpenSSHSecurityKeyEcdsaKeyPair( + q: _uncompressedPoint( + (publicKey[CoseKey.ec2XIdx] as List).cast(), + (publicKey[CoseKey.ec2YIdx] as List).cast(), + ), + application: rpId, + flags: flags, + keyHandle: keyHandle, + reserved: '', + ); + } else { + return null; + } + return ResidentSecurityKey( + keyPair: keyPair, + userName: credential.user.name, + ); + } + + static Uint8List _uncompressedPoint(List x, List y) { + return Uint8List.fromList([ + 0x04, + ...List.filled(32 - x.length, 0), + ...x, + ...List.filled(32 - y.length, 0), + ...y, + ]); + } + + int _credentialManagementCommandFor(AuthenticatorInfo info) { + final options = info.options; + if (options?['credMgmt'] == true) { + return Ctap2Commands.credentialManagement.value; + } + if (options?['credentialMgmtPreview'] == true) { + return _credentialManagementPreviewCommand; + } + const message = + 'This security key cannot list resident keys. Downloading them ' + 'needs CTAP2 credential management support on the key.'; + onStatus?.call(message); + throw const ResidentKeyDownloadError(message); + } + + Future _promptForPin({int? retriesRemaining}) async { + final pinRequest = onPinRequest; + if (pinRequest == null) { + throw StateError('Security key PIN is required.'); + } + final pin = await pinRequest(retriesRemaining: retriesRemaining); + if (pin == null || pin.isEmpty) { + throw const ResidentKeyDownloadCancelled(); + } + return pin; + } + + Future _pinRetries(ClientPin clientPin) async { + try { + return await clientPin.getPinRetries(); + } catch (_) { + return null; + } + } + + PinProtocol _pinProtocolFor(AuthenticatorInfo info) { + final protocols = info.pinUvAuthProtocols; + if (protocols == null || protocols.isEmpty) { + return PinProtocolV1(); + } + if (protocols.contains(2)) { + return PinProtocolV2(); + } + if (protocols.contains(1)) { + return PinProtocolV1(); + } + throw StateError('Unsupported security key PIN protocol.'); + } +} + +/// Speaks authenticatorCredentialManagement directly on a [CtapDevice] so the +/// same code paths serve both the CTAP 2.1 command (0x0A) and the widely +/// deployed "credentialMgmtPreview" variant (0x41), which shares the wire +/// format but not the command byte. The fido2 package only issues 0x0A. +class _CredentialManagementClient { + const _CredentialManagementClient({ + required this.device, + required this.command, + required this.pinProtocol, + required this.pinToken, + }); + + final CtapDevice device; + final int command; + final PinProtocol pinProtocol; + final List pinToken; + + Future> enumerateRps() async { + final first = await _invoke( + CredentialManagementSubCommand.enumerateRpsBegin.value, + allowNoCredentials: true, + ); + if (first == null || first.rp == null) { + return []; + } + final rps = [ + CmRp(rp: first.rp!, rpIdHash: first.rpIdHash!, totalRPs: first.totalRPs), + ]; + final total = first.totalRPs ?? 1; + while (rps.length < total) { + final next = await _invoke( + CredentialManagementSubCommand.enumerateRpsGetNextRp.value, + auth: false, + ); + rps.add(CmRp(rp: next!.rp!, rpIdHash: next.rpIdHash!)); + } + return rps; + } + + Future> enumerateCredentials(List rpIdHash) async { + final first = await _invoke( + CredentialManagementSubCommand.enumerateCredentialsBegin.value, + params: { + CredentialManagementSubCommandParams.rpIdHash.value: CborBytes( + rpIdHash, + ), + }, + allowNoCredentials: true, + ); + if (first == null || first.credentialId == null) { + return []; + } + final credentials = [_credentialOf(first)]; + final total = first.totalCredentials ?? 1; + while (credentials.length < total) { + final next = await _invoke( + CredentialManagementSubCommand + .enumerateCredentialsGetNextCredential + .value, + auth: false, + ); + credentials.add(_credentialOf(next!)); + } + return credentials; + } + + static CmCredential _credentialOf(CredentialManagementResponse response) { + return CmCredential( + user: response.user!, + credentialId: response.credentialId!, + publicKey: response.publicKey!, + totalCredentials: response.totalCredentials, + credProtect: response.credProtect ?? 0x01, + largeBlobKey: response.largeBlobKey, + ); + } + + Future _invoke( + int subCommand, { + Map? params, + bool auth = true, + bool allowNoCredentials = false, + }) async { + CborMap? paramsMap; + if (params != null) { + paramsMap = CborMap.fromEntries( + params.entries.map( + (entry) => MapEntry(CborSmallInt(entry.key), CborValue(entry.value)), + ), + ); + } + + List? pinUvAuthParam; + if (auth) { + final message = [ + subCommand, + if (paramsMap != null) ...cbor.encode(paramsMap), + ]; + final mac = await pinProtocol.authenticate(pinToken, message); + // PIN protocol 1 sends LEFT(HMAC, 16); protocol 2 sends the full HMAC. + pinUvAuthParam = pinProtocol.version == 1 ? mac.sublist(0, 16) : mac; + } + + final request = CredentialManagementRequest( + subCommand: subCommand, + params: paramsMap, + pinUvAuthProtocol: auth ? pinProtocol.version : null, + pinUvAuthParam: pinUvAuthParam, + ).encode(); + final response = await device.transceive([command, ...request.skip(1)]); + + if (allowNoCredentials && + response.status == CtapStatusCode.ctap2ErrNoCredentials.value) { + return null; + } + if (response.status != CtapStatusCode.ctap1ErrSuccess.value) { + throw CtapError.fromCode(response.status); + } + return response.data.isEmpty + ? null + : CredentialManagementResponse.decode(response.data); + } +} diff --git a/test/features/terminal/fido_resident_key_downloader_test.dart b/test/features/terminal/fido_resident_key_downloader_test.dart new file mode 100644 index 0000000..4ab470f --- /dev/null +++ b/test/features/terminal/fido_resident_key_downloader_test.dart @@ -0,0 +1,526 @@ +import 'package:cbor/cbor.dart'; +import 'package:conduit/features/terminal/data/fido_resident_key_downloader.dart'; +import 'package:dartssh2/dartssh2.dart'; +import 'package:fido2/fido2_client.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/test_doubles.dart'; + +const credentialManagementPreviewCommand = 0x41; + +CtapResponse> ctapOk(Map map) => CtapResponse( + CtapStatusCode.ctap1ErrSuccess.value, + cbor.encode(CborValue(map)), +); + +CtapResponse> ctapStatus(CtapStatusCode status) => + CtapResponse(status.value, const []); + +CtapResponse> infoResponse( + Map options, { + List pinProtocols = const [1], +}) => ctapOk({ + AuthenticatorInfo.versionsIdx: ['FIDO_2_0'], + AuthenticatorInfo.aaguidIdx: CborBytes(List.filled(16, 0)), + AuthenticatorInfo.optionsIdx: options, + AuthenticatorInfo.pinUvAuthProtocolsIdx: pinProtocols, +}); + +int credMgmtSubCommandOf(List command) { + final request = cbor.decode(command.sublist(1)).toObject() as Map; + return request[CredentialManagementRequest.subCmdIdx] as int; +} + +Map? credMgmtParamsOf(List command) { + final request = cbor.decode(command.sublist(1)).toObject() as Map; + return (request[CredentialManagementRequest.paramsIdx] as Map?) + ?.cast(); +} + +List? credMgmtPinUvAuthParamOf(List command) { + final request = cbor.decode(command.sublist(1)).toObject() as Map; + return (request[CredentialManagementRequest.pinUvAuthParamIdx] as List?) + ?.cast(); +} + +Map rpEntry(String rpId) => { + CredentialManagementResponse.rpIdx: {'id': rpId}, + CredentialManagementResponse.rpIdHashIdx: CborBytes(rpIdHashOf(rpId)), +}; + +List rpIdHashOf(String rpId) => + List.generate(32, (index) => (rpId.hashCode + index) & 0xff); + +Map ed25519PublicKey(List x) => { + 1: 1, // kty: OKP + 3: -8, // alg: EdDSA + -1: 6, // crv: Ed25519 + -2: CborBytes(x), +}; + +Map es256PublicKey(List x, List y) => { + 1: 2, // kty: EC2 + 3: -7, // alg: ES256 + -1: 1, // crv: P-256 + -2: CborBytes(x), + -3: CborBytes(y), +}; + +Map credentialEntry({ + required List credentialId, + required Map publicKey, + String userName = 'openssh', + int? totalCredentials, + int credProtect = 0x01, +}) => { + CredentialManagementResponse.userIdx: { + 'id': CborBytes(const [0x0F]), + 'name': userName, + }, + CredentialManagementResponse.credentialIdIdx: { + 'type': 'public-key', + 'id': CborBytes(credentialId), + }, + CredentialManagementResponse.publicKeyIdx: publicKey, + CredentialManagementResponse.totalCredentialsIdx: ?totalCredentials, + CredentialManagementResponse.credProtectIdx: credProtect, +}; + +/// Serves scripted per-RP credentials over the credential-management command, +/// tracking enumeration cursors like a real authenticator. +class CredMgmtResponder { + CredMgmtResponder({ + required this.rps, + this.command = 0x0A, + this.pinProtocols = const [1], + Map? infoOptions, + }) : infoOptions = infoOptions ?? {'clientPin': true, 'credMgmt': true}; + + final int command; + final List pinProtocols; + final Map infoOptions; + + /// rpId -> scripted credential entries. + final Map>> rps; + + final commands = >[]; + var _rpCursor = 0; + List>? _remainingCredentials; + + CtapResponse>? call(List command) { + if (command.first == Ctap2Commands.getInfo.value) { + return infoResponse(infoOptions, pinProtocols: pinProtocols); + } + if (command.first != this.command) { + return null; + } + commands.add(List.of(command)); + final subCommand = credMgmtSubCommandOf(command); + if (subCommand == CredentialManagementSubCommand.enumerateRpsBegin.value) { + _rpCursor = 0; + if (rps.isEmpty) { + return ctapStatus(CtapStatusCode.ctap2ErrNoCredentials); + } + return ctapOk({ + ...rpEntry(rps.keys.first), + CredentialManagementResponse.totalRPsIdx: rps.length, + }); + } + if (subCommand == + CredentialManagementSubCommand.enumerateRpsGetNextRp.value) { + _rpCursor++; + return ctapOk(rpEntry(rps.keys.elementAt(_rpCursor))); + } + if (subCommand == + CredentialManagementSubCommand.enumerateCredentialsBegin.value) { + final requestedHash = + (credMgmtParamsOf( + command, + )![CredentialManagementSubCommandParams.rpIdHash.value] + as List) + .cast(); + final rpId = rps.keys.firstWhere( + (id) => _sameBytes(rpIdHashOf(id), requestedHash), + ); + final credentials = rps[rpId]!; + if (credentials.isEmpty) { + return ctapStatus(CtapStatusCode.ctap2ErrNoCredentials); + } + _remainingCredentials = credentials.sublist(1); + return ctapOk({ + ...credentials.first, + CredentialManagementResponse.totalCredentialsIdx: credentials.length, + }); + } + if (subCommand == + CredentialManagementSubCommand + .enumerateCredentialsGetNextCredential + .value) { + final next = _remainingCredentials!.removeAt(0); + return ctapOk(next); + } + return ctapStatus(CtapStatusCode.ctap1ErrInvalidCommand); + } + + static bool _sameBytes(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } +} + +void main() { + FidoResidentKeyDownloader downloaderFor( + FakeCtapDevice device, { + String pin = '1234', + List? pins, + List? statuses, + List? promptedRetries, + }) { + var pinRequests = 0; + return FidoResidentKeyDownloader( + openDevice: () async => device, + onStatus: statuses?.add, + onPinRequest: ({int? retriesRemaining}) async { + promptedRetries?.add(retriesRemaining); + final index = pinRequests++; + if (pins != null) { + return index < pins.length ? pins[index] : null; + } + return pin; + }, + ); + } + + group('FidoResidentKeyDownloader', () { + test('downloads an ed25519 resident key as an OpenSSH stub', () async { + final x = List.generate(32, (index) => index + 1); + final responder = CredMgmtResponder( + rps: { + 'ssh:demo': [ + credentialEntry( + credentialId: const [0xAA, 0xBB], + publicKey: ed25519PublicKey(x), + ), + ], + }, + ); + final device = FakeCtapDevice( + signature: const [], + authData: const [], + respond: responder.call, + ); + + final keys = await downloaderFor(device).download(); + + expect(keys, hasLength(1)); + final key = keys.single; + expect(key.algorithm, 'ed25519-sk'); + expect(key.application, 'ssh:demo'); + expect(key.suggestedLabel, 'demo'); + expect(key.requiresUserVerification, isFalse); + + final keyPair = key.keyPair as OpenSSHSecurityKeyEd25519KeyPair; + expect(keyPair.publicKey, x); + expect(keyPair.keyHandle, const [0xAA, 0xBB]); + expect(keyPair.flags, 0x21); + expect(keyPair.reserved, ''); + expect(key.fingerprintSha256, startsWith('SHA256:')); + + final authParam = credMgmtPinUvAuthParamOf(responder.commands.first)!; + expect(authParam, hasLength(16)); + + final reparsed = + SSHKeyPair.fromPem(key.toPem()).single + as OpenSSHSecurityKeyEd25519KeyPair; + expect(reparsed.publicKey, x); + expect(reparsed.application, 'ssh:demo'); + expect(reparsed.flags, 0x21); + expect(reparsed.keyHandle, const [0xAA, 0xBB]); + }); + + test('sends the full pinUvAuthParam under PIN protocol 2', () async { + final responder = CredMgmtResponder( + pinProtocols: const [2, 1], + rps: { + 'ssh:v2': [ + credentialEntry( + credentialId: const [0x55], + publicKey: ed25519PublicKey(List.filled(32, 8)), + ), + ], + }, + ); + final device = FakeCtapDevice( + signature: const [], + authData: const [], + // Protocol 2 token ciphertext: 16-byte IV + 32-byte token. + pinTokenBytes: 48, + respond: responder.call, + ); + + final keys = await downloaderFor(device).download(); + + expect(keys, hasLength(1)); + expect(keys.single.application, 'ssh:v2'); + final authParam = credMgmtPinUvAuthParamOf(responder.commands.first)!; + expect(authParam, hasLength(32)); + }); + + test('distinguishes standard SSH credentials by fingerprint', () async { + final responder = CredMgmtResponder( + rps: { + 'ssh:': [ + credentialEntry( + credentialId: const [0x01], + publicKey: ed25519PublicKey(List.filled(32, 1)), + ), + credentialEntry( + credentialId: const [0x02], + publicKey: ed25519PublicKey(List.filled(32, 2)), + ), + ], + }, + ); + final device = FakeCtapDevice( + signature: const [], + authData: const [], + respond: responder.call, + ); + + final keys = await downloaderFor(device).download(); + + expect(keys.map((key) => key.suggestedLabel), everyElement(isEmpty)); + expect(keys.map((key) => key.fingerprintSha256).toSet(), hasLength(2)); + }); + + test('marks uv-protected credentials as requiring verification', () async { + final responder = CredMgmtResponder( + rps: { + 'ssh:': [ + credentialEntry( + credentialId: const [0x01], + publicKey: ed25519PublicKey(List.filled(32, 7)), + credProtect: 0x03, + ), + ], + }, + ); + final device = FakeCtapDevice( + signature: const [], + authData: const [], + respond: responder.call, + ); + + final key = (await downloaderFor(device).download()).single; + + expect(key.keyPair.flags, 0x25); + expect(key.requiresUserVerification, isTrue); + expect(key.suggestedLabel, ''); + }); + + test( + 'downloads an ecdsa resident key with an uncompressed point', + () async { + final x = List.generate(31, (index) => index + 1); + final y = List.generate(32, (index) => 32 - index); + final responder = CredMgmtResponder( + rps: { + 'ssh:work': [ + credentialEntry( + credentialId: const [0xC0], + publicKey: es256PublicKey(x, y), + ), + ], + }, + ); + final device = FakeCtapDevice( + signature: const [], + authData: const [], + respond: responder.call, + ); + + final key = (await downloaderFor(device).download()).single; + + expect(key.algorithm, 'ecdsa-sk'); + final keyPair = key.keyPair as OpenSSHSecurityKeyEcdsaKeyPair; + expect(keyPair.q, hasLength(65)); + expect(keyPair.q.first, 0x04); + // x is 31 bytes long and must be left-padded to 32. + expect(keyPair.q.sublist(1, 33), [0, ...x]); + expect(keyPair.q.sublist(33), y); + + final reparsed = + SSHKeyPair.fromPem(key.toPem()).single + as OpenSSHSecurityKeyEcdsaKeyPair; + expect(reparsed.q, keyPair.q); + }, + ); + + test('skips non-ssh relying parties and unsupported algorithms', () async { + final responder = CredMgmtResponder( + rps: { + 'example.com': [ + credentialEntry( + credentialId: const [0x99], + publicKey: ed25519PublicKey(List.filled(32, 9)), + ), + ], + 'ssh:mixed': [ + credentialEntry( + credentialId: const [0x11], + publicKey: { + 1: 3, + 3: -257, + -1: CborBytes(const [1]), + -2: CborBytes(const [2]), + }, + ), + credentialEntry( + credentialId: const [0x22], + publicKey: ed25519PublicKey(List.filled(32, 4)), + ), + ], + }, + ); + final device = FakeCtapDevice( + signature: const [], + authData: const [], + respond: responder.call, + ); + + final keys = await downloaderFor(device).download(); + + expect(keys, hasLength(1)); + expect(keys.single.keyPair.keyHandle, const [0x22]); + final enumerated = responder.commands + .where( + (command) => + credMgmtSubCommandOf(command) == + CredentialManagementSubCommand.enumerateCredentialsBegin.value, + ) + .map( + (command) => + (credMgmtParamsOf( + command, + )![CredentialManagementSubCommandParams.rpIdHash.value] + as List) + .cast(), + ); + expect(enumerated, hasLength(1)); + expect(enumerated.single, rpIdHashOf('ssh:mixed')); + }); + + test( + 'returns an empty list when the key has no resident credentials', + () async { + final responder = CredMgmtResponder(rps: {}); + final device = FakeCtapDevice( + signature: const [], + authData: const [], + respond: responder.call, + ); + + final statuses = []; + final keys = await downloaderFor(device, statuses: statuses).download(); + + expect(keys, isEmpty); + expect( + statuses.last, + 'No resident SSH keys found on this security key.', + ); + }, + ); + + test('falls back to the credentialMgmtPreview command byte', () async { + final responder = CredMgmtResponder( + command: credentialManagementPreviewCommand, + infoOptions: {'clientPin': true, 'credentialMgmtPreview': true}, + rps: { + 'ssh:legacy': [ + credentialEntry( + credentialId: const [0x33], + publicKey: ed25519PublicKey(List.filled(32, 5)), + ), + ], + }, + ); + final device = FakeCtapDevice( + signature: const [], + authData: const [], + respond: responder.call, + ); + + final keys = await downloaderFor(device).download(); + + expect(keys, hasLength(1)); + expect(keys.single.application, 'ssh:legacy'); + expect(responder.commands, isNotEmpty); + expect( + device.commands.any( + (command) => + command.first == Ctap2Commands.credentialManagement.value, + ), + isFalse, + ); + }); + + test('reports keys without credential management support', () async { + final device = FakeCtapDevice( + signature: const [], + authData: const [], + respond: (command) => command.first == Ctap2Commands.getInfo.value + ? infoResponse({'clientPin': true}) + : null, + ); + + final statuses = []; + await expectLater( + downloaderFor(device, statuses: statuses).download(), + throwsA(isA()), + ); + expect(statuses.last, contains('cannot list resident keys')); + }); + + test('re-prompts for the PIN after a rejection', () async { + final responder = CredMgmtResponder( + rps: { + 'ssh:retry': [ + credentialEntry( + credentialId: const [0x44], + publicKey: ed25519PublicKey(List.filled(32, 6)), + ), + ], + }, + ); + final device = FakeCtapDevice( + signature: const [], + authData: const [], + respond: responder.call, + )..rejectPinChecks = 1; + + final promptedRetries = []; + final keys = await downloaderFor( + device, + pins: ['0000', '1234'], + promptedRetries: promptedRetries, + ).download(); + + expect(keys, hasLength(1)); + expect(promptedRetries, [null, 8]); + expect(device.pinTokenGrants, 1); + }); + + test('throws when the PIN prompt is cancelled', () async { + final device = FakeCtapDevice(signature: const [], authData: const []); + + await expectLater( + downloaderFor(device, pins: [null]).download(), + throwsA(isA()), + ); + expect(device.commands, isEmpty); + }); + }); +} diff --git a/test/support/test_doubles.dart b/test/support/test_doubles.dart index 1315a25..d2c7079 100644 --- a/test/support/test_doubles.dart +++ b/test/support/test_doubles.dart @@ -526,12 +526,17 @@ class FakeCtapDevice extends CtapDevice { required this.authData, this.respond, this.pinRetries = 8, + this.pinTokenBytes = 32, }); final List signature; final List authData; final CtapResponse>? Function(List command)? respond; final int pinRetries; + + /// Length of the returned pinUvAuthToken ciphertext. PIN protocol 2 + /// prepends a 16-byte IV, so it needs 48 to decrypt to a 32-byte token. + final int pinTokenBytes; int rejectPinChecks = 0; int pinTokenGrants = 0; final List> commands = []; @@ -630,7 +635,7 @@ class FakeCtapDevice extends CtapDevice { cbor.encode( CborValue({ ClientPinResponse.pinUvAuthTokenIdx: CborBytes( - List.generate(32, (index) => index + 1), + List.generate(pinTokenBytes, (index) => index + 1), ), }), ),