From 4248e60b8a989ff792be4ffe27c0525377cbf27d Mon Sep 17 00:00:00 2001 From: "J.L.M" <57787248+JMarkstrom@users.noreply.github.com> Date: Fri, 15 May 2026 10:34:11 +0200 Subject: [PATCH 01/12] Add CTAP 2.2 authenticatorSelection (0x0B) API, docs, sample, and tests --- .../Fido2KeyCollectorOperation.cs | 1 + .../KeyCollector/Fido2SampleKeyCollector.cs | 5 + .../Fido2SampleCode/Run/Fido2MainMenuItem.cs | 4 +- .../Run/Fido2SampleRun.Operations.cs | 14 ++- .../Fido2SampleCode/Run/Fido2SampleRun.cs | 3 +- .../Fido2AuthenticatorSelection.cs | 116 ++++++++++++++++++ .../ResponseStatusMessages.Designer.cs | 11 +- .../src/Resources/ResponseStatusMessages.resx | 3 + .../Commands/AuthenticatorSelectionCommand.cs | 55 +++++++++ .../AuthenticatorSelectionResponse.cs | 45 +++++++ .../YubiKey/Fido2/Commands/CtapConstants.cs | 1 + .../Fido2Session.AuthenticatorSelection.cs | 98 +++++++++++++++ .../AuthenticatorSelectionCommandTests.cs | 63 ++++++++++ .../AuthenticatorSelectionResponseTests.cs | 61 +++++++++ .../apdu/authenticator-selection.md | 53 ++++++++ .../application-fido2/fido2-commands.md | 34 ++++- .../fido2-touch-notification.md | 7 +- 17 files changed, 566 insertions(+), 8 deletions(-) create mode 100644 Yubico.YubiKey/examples/Fido2SampleCode/YubiKeyOperations/Fido2AuthenticatorSelection.cs create mode 100644 Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionCommand.cs create mode 100644 Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionResponse.cs create mode 100644 Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Fido2Session.AuthenticatorSelection.cs create mode 100644 Yubico.YubiKey/tests/unit/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionCommandTests.cs create mode 100644 Yubico.YubiKey/tests/unit/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionResponseTests.cs create mode 100644 docs/users-manual/application-fido2/apdu/authenticator-selection.md diff --git a/Yubico.YubiKey/examples/Fido2SampleCode/KeyCollector/Fido2KeyCollectorOperation.cs b/Yubico.YubiKey/examples/Fido2SampleCode/KeyCollector/Fido2KeyCollectorOperation.cs index 1e2fb8cc3..b6280e043 100644 --- a/Yubico.YubiKey/examples/Fido2SampleCode/KeyCollector/Fido2KeyCollectorOperation.cs +++ b/Yubico.YubiKey/examples/Fido2SampleCode/KeyCollector/Fido2KeyCollectorOperation.cs @@ -21,5 +21,6 @@ public enum Fido2KeyCollectorOperation GetAssertion = 2, Reset = 3, Verify = 4, + AuthenticatorSelection = 5, } } diff --git a/Yubico.YubiKey/examples/Fido2SampleCode/KeyCollector/Fido2SampleKeyCollector.cs b/Yubico.YubiKey/examples/Fido2SampleCode/KeyCollector/Fido2SampleKeyCollector.cs index 88b4771d0..fcd8501ae 100644 --- a/Yubico.YubiKey/examples/Fido2SampleCode/KeyCollector/Fido2SampleKeyCollector.cs +++ b/Yubico.YubiKey/examples/Fido2SampleCode/KeyCollector/Fido2SampleKeyCollector.cs @@ -165,6 +165,11 @@ private void ReportOperation() SampleMenu.WriteMessage(MessageType.Title, 0, "\nThe YubiKey is trying to reset the FIDO2 application,"); break; + + case Fido2KeyCollectorOperation.AuthenticatorSelection: + SampleMenu.WriteMessage(MessageType.Title, 0, + "\nThe YubiKey is waiting for authenticatorSelection (user presence),"); + break; } } diff --git a/Yubico.YubiKey/examples/Fido2SampleCode/Run/Fido2MainMenuItem.cs b/Yubico.YubiKey/examples/Fido2SampleCode/Run/Fido2MainMenuItem.cs index 0cd590bc0..c6c8cf5e6 100644 --- a/Yubico.YubiKey/examples/Fido2SampleCode/Run/Fido2MainMenuItem.cs +++ b/Yubico.YubiKey/examples/Fido2SampleCode/Run/Fido2MainMenuItem.cs @@ -45,6 +45,8 @@ public enum Fido2MainMenuItem Reset = 26, - Exit = 27, + AuthenticatorSelection = 28, + + Exit = 29, } } diff --git a/Yubico.YubiKey/examples/Fido2SampleCode/Run/Fido2SampleRun.Operations.cs b/Yubico.YubiKey/examples/Fido2SampleCode/Run/Fido2SampleRun.Operations.cs index d20e2ce12..55af5339b 100644 --- a/Yubico.YubiKey/examples/Fido2SampleCode/Run/Fido2SampleRun.Operations.cs +++ b/Yubico.YubiKey/examples/Fido2SampleCode/Run/Fido2SampleRun.Operations.cs @@ -71,6 +71,7 @@ public bool RunMenuItem(Fido2MainMenuItem menuItem) Fido2MainMenuItem.ToggleAlwaysUv => RunToggleAlwaysUv(), Fido2MainMenuItem.SetPinConfig => RunSetPinConfig(), Fido2MainMenuItem.Reset => RunReset(), + Fido2MainMenuItem.AuthenticatorSelection => RunAuthenticatorSelection(), _ => RunUnimplementedOperation(), }; } @@ -87,6 +88,17 @@ public static bool RunUnimplementedOperation() return true; } + public bool RunAuthenticatorSelection() + { + _keyCollector.Operation = Fido2KeyCollectorOperation.AuthenticatorSelection; + + _ = Fido2AuthenticatorSelection.Run( + _keyCollector.Fido2SampleKeyCollectorDelegate, + ref _yubiKeyChosen); + + return true; + } + public bool RunReset() { string versionNumber = _yubiKeyChosen.FirmwareVersion.ToString(); @@ -1580,4 +1592,4 @@ private UserEntity GetUpdatedInfo(UserEntity original) return returnValue; } } -} +} \ No newline at end of file diff --git a/Yubico.YubiKey/examples/Fido2SampleCode/Run/Fido2SampleRun.cs b/Yubico.YubiKey/examples/Fido2SampleCode/Run/Fido2SampleRun.cs index 864677592..9896d8fd7 100644 --- a/Yubico.YubiKey/examples/Fido2SampleCode/Run/Fido2SampleRun.cs +++ b/Yubico.YubiKey/examples/Fido2SampleCode/Run/Fido2SampleRun.cs @@ -106,6 +106,7 @@ private bool DefaultChooseYubiKey(Fido2MainMenuItem menuItem) { case Fido2MainMenuItem.ListYubiKeys: case Fido2MainMenuItem.ChooseYubiKey: + case Fido2MainMenuItem.AuthenticatorSelection: case Fido2MainMenuItem.Exit: return true; @@ -135,4 +136,4 @@ private bool RunChooseYubiKey() return _chosenByUser; } } -} +} \ No newline at end of file diff --git a/Yubico.YubiKey/examples/Fido2SampleCode/YubiKeyOperations/Fido2AuthenticatorSelection.cs b/Yubico.YubiKey/examples/Fido2SampleCode/YubiKeyOperations/Fido2AuthenticatorSelection.cs new file mode 100644 index 000000000..49779efcb --- /dev/null +++ b/Yubico.YubiKey/examples/Fido2SampleCode/YubiKeyOperations/Fido2AuthenticatorSelection.cs @@ -0,0 +1,116 @@ +// Copyright 2026 Yubico AB +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Linq; +using Yubico.YubiKey; +using Yubico.YubiKey.Fido2; +using Yubico.YubiKey.Fido2.Commands; +using Yubico.YubiKey.Sample.SharedCode; + +namespace Yubico.YubiKey.Sample.Fido2SampleCode +{ + // This file demonstrates CTAP 2.2 authenticatorSelection (0x0B) via + // Fido2Session.TryAuthenticatorSelection over USB (HID FIDO). The YubiKey + // prompts for user presence (touch) by blinking; on success the host can + // treat that YubiKey as the chosen authenticator for subsequent commands. + public static class Fido2AuthenticatorSelection + { + public static bool Run( + Func keyCollector, + ref IYubiKeyDevice yubiKeyChosen) + { + if (keyCollector is null) + { + throw new ArgumentNullException(nameof(keyCollector)); + } + + // Look for YubiKeys over the FIDO HID (USB) transport. + IYubiKeyDevice[] keys = YubiKeyDevice.FindByTransport(Transport.HidFido).ToArray(); + if (keys.Length == 0) + { + SampleMenu.WriteMessage(MessageType.Title, 0, "\nNo YubiKeys found over HID FIDO.\n"); + PauseBeforeMainMenu(); + return true; + } + + SampleMenu.WriteMessage(MessageType.Title, 0, "\nTouch a YubiKey when it blinks.\n"); + + foreach (IYubiKeyDevice device in keys) + { + try + { + if (TrySelection(device, keyCollector, out AuthenticatorSelectionResponse response)) + { + yubiKeyChosen = device; + SampleMenu.WriteMessage(MessageType.Title, 0, "\nOK\n"); + PauseBeforeMainMenu(); + return true; + } + + // CTAP INVALID_COMMAND: firmware does not support CTAP 2.2. + if (response.CtapStatus == CtapStatus.InvalidCommand) + { + SampleMenu.WriteMessage( + MessageType.Title, + 0, + "\nOne or more YubiKeys does not support CTAP 2.2.\n"); + PauseBeforeMainMenu(); + return true; + } + } + catch (TimeoutException) + { + // No touch (or wrong YubiKey) before the session timeout; try another YubiKey. + } + catch (OperationCanceledException ex) + { + // Key collector returned false (e.g. user ignored the prompt). + SampleMenu.WriteMessage(MessageType.Title, 0, ex.Message + "\n"); + PauseBeforeMainMenu(); + return true; + } + catch (Fido2Exception ex) + { + // Other FIDO2 errors: show and continue to the next YubiKey, if any. + SampleMenu.WriteMessage(MessageType.Title, 0, ex.Message + "\n"); + } + } + + SampleMenu.WriteMessage(MessageType.Title, 0, "\nSelection did not complete.\n"); + PauseBeforeMainMenu(); + return true; + } + + // Wait so the user can read messages before the sample redraws the main menu. + private static void PauseBeforeMainMenu() + { + SampleMenu.WriteMessage(MessageType.Title, 0, "Press Enter to return to the main menu."); + _ = SampleMenu.ReadResponse(out string _); + } + + private static bool TrySelection( + IYubiKeyDevice device, + Func keyCollector, + out AuthenticatorSelectionResponse response) + { + using var session = new Fido2Session(device) + { + KeyCollector = keyCollector, + }; + + return session.TryAuthenticatorSelection(out response); + } + } +} \ No newline at end of file diff --git a/Yubico.YubiKey/src/Resources/ResponseStatusMessages.Designer.cs b/Yubico.YubiKey/src/Resources/ResponseStatusMessages.Designer.cs index 56f06c031..ebf0c842e 100644 --- a/Yubico.YubiKey/src/Resources/ResponseStatusMessages.Designer.cs +++ b/Yubico.YubiKey/src/Resources/ResponseStatusMessages.Designer.cs @@ -465,6 +465,15 @@ internal static string Fido2AuthInvalid { } } + /// + /// Looks up a localized string similar to User presence was denied for authenticator selection.. + /// + internal static string Fido2AuthenticatorSelectionDenied { + get { + return ResourceManager.GetString("Fido2AuthenticatorSelectionDenied", resourceCulture); + } + } + /// /// Looks up a localized string similar to The credential was rejected because it is on the relying party's exclude list.. /// @@ -807,4 +816,4 @@ internal static string YubiHsmAuthTouchRequired { } } } -} +} \ No newline at end of file diff --git a/Yubico.YubiKey/src/Resources/ResponseStatusMessages.resx b/Yubico.YubiKey/src/Resources/ResponseStatusMessages.resx index 06d4ebb34..9f2f2ee3f 100644 --- a/Yubico.YubiKey/src/Resources/ResponseStatusMessages.resx +++ b/Yubico.YubiKey/src/Resources/ResponseStatusMessages.resx @@ -312,6 +312,9 @@ The authentication was invalid for the requested FIDO2 operation. + + User presence was denied for authenticator selection. + The operation was canceled by the caller or user. diff --git a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionCommand.cs b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionCommand.cs new file mode 100644 index 000000000..c3603012e --- /dev/null +++ b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionCommand.cs @@ -0,0 +1,55 @@ +// Copyright 2026 Yubico AB +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Yubico.Core.Iso7816; + +namespace Yubico.YubiKey.Fido2.Commands +{ + /// + /// Ask the authenticator for User Presence (UP) so the user can select this YubiKey. + /// + /// + /// The partner response class is . + /// Specified in CTAP 2.2 as authenticatorSelection (command byte 0x0B). + /// There are no command parameters. Whether the authenticator implements this command + /// is firmware-specific; unsupported devices typically return . + /// + public sealed class AuthenticatorSelectionCommand : IYubiKeyCommand + { + /// + public YubiKeyApplication Application => YubiKeyApplication.Fido2; + + /// + /// Constructs an instance of the class. + /// + public AuthenticatorSelectionCommand() + { + } + + /// + public CommandApdu CreateCommandApdu() + { + byte[] payload = new byte[] { CtapConstants.CtapAuthenticatorSelectionCmd }; + return new CommandApdu() + { + Ins = CtapConstants.CtapHidCbor, + Data = payload + }; + } + + /// + public AuthenticatorSelectionResponse CreateResponseForApdu(ResponseApdu responseApdu) => + new AuthenticatorSelectionResponse(responseApdu); + } +} \ No newline at end of file diff --git a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionResponse.cs b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionResponse.cs new file mode 100644 index 000000000..bebbbbcb8 --- /dev/null +++ b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionResponse.cs @@ -0,0 +1,45 @@ +// Copyright 2026 Yubico AB +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Yubico.Core.Iso7816; + +namespace Yubico.YubiKey.Fido2.Commands +{ + /// + /// The response to the . + /// + /// + /// On success there is no response payload. If the authenticator does not implement + /// authenticatorSelection, expect or another CTAP error; + /// that reflects authenticator support, not an SDK defect. + /// + public sealed class AuthenticatorSelectionResponse : Fido2Response, IYubiKeyResponse + { + /// + /// Constructs an from the YubiKey APDU response. + /// + /// The response APDU returned by the YubiKey. + public AuthenticatorSelectionResponse(ResponseApdu responseApdu) : + base(responseApdu) + { + } + + /// + protected override ResponseStatusPair StatusCodeMap => CtapStatus switch + { + CtapStatus.OperationDenied => new ResponseStatusPair(ResponseStatus.Failed, ResponseStatusMessages.Fido2AuthenticatorSelectionDenied), + _ => base.StatusCodeMap, + }; + } +} \ No newline at end of file diff --git a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Commands/CtapConstants.cs b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Commands/CtapConstants.cs index 19a50e15d..20fab620c 100644 --- a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Commands/CtapConstants.cs +++ b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Commands/CtapConstants.cs @@ -27,5 +27,6 @@ internal static class CtapConstants public const byte CtapMakeCredentialCmd = 0x01; public const byte CtapGetAssertionCmd = 0x02; public const byte CtapClientPinCmd = 0x06; + public const byte CtapAuthenticatorSelectionCmd = 0x0B; } } diff --git a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Fido2Session.AuthenticatorSelection.cs b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Fido2Session.AuthenticatorSelection.cs new file mode 100644 index 000000000..1a72a4356 --- /dev/null +++ b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Fido2Session.AuthenticatorSelection.cs @@ -0,0 +1,98 @@ +// Copyright 2026 Yubico AB +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using Microsoft.Extensions.Logging; +using Yubico.YubiKey.Fido2.Commands; + +namespace Yubico.YubiKey.Fido2 +{ + // CTAP 2.2 authenticatorSelection (0x0B): User Presence (UP) for single or multi-YubiKey selection. + public sealed partial class Fido2Session + { + /// + /// Requests User Presence (UP) on this YubiKey so the user can select it for intended use (CTAP 2.2 + /// authenticatorSelection). + /// + /// + /// + /// Per the CTAP specification, after a successful selection the platform should send cancel + /// to other authenticators. This SDK does not manage multiple devices; callers orchestrate that. + /// + /// + /// This method calls the with + /// while waiting for touch, the same as . + /// + /// + /// Returns false if the YubiKey returns (command + /// not implemented) or (user declined). There is no SDK + /// firmware gate; behavior depends on the YubiKey's support for CTAP 2.2. + /// + /// + /// The response from the YubiKey, including the CTAP status (see ). + /// true if the operation completed with ; otherwise false for unsupported or denied selection. + /// The is not set. + /// The authenticator timed out waiting for user action. + /// The operation was canceled (e.g. keepalive cancel). + /// Another CTAP error occurred. + public bool TryAuthenticatorSelection(out AuthenticatorSelectionResponse response) + { + Logger.LogInformation("Authenticator selection."); + + var keyCollector = EnsureKeyCollector(); + var keyEntryData = new KeyEntryData + { + Request = KeyEntryRequest.TouchRequest, + }; + + using var touchTask = new TouchFingerprintTask( + keyCollector, + keyEntryData, + Connection, + CtapConstants.CtapAuthenticatorSelectionCmd); + + try + { + response = Connection.SendCommand(new AuthenticatorSelectionCommand()); + CtapStatus ctapStatus = touchTask.IsUserCanceled ? CtapStatus.KeepAliveCancel : response.CtapStatus; + + switch (ctapStatus) + { + case CtapStatus.Ok: + return true; + + case CtapStatus.InvalidCommand: + case CtapStatus.OperationDenied: + return false; + + case CtapStatus.KeepAliveCancel: + throw new OperationCanceledException(ExceptionMessages.OperationCancelled); + + case CtapStatus.ActionTimeout: + case CtapStatus.UserActionTimeout: + throw new TimeoutException(ExceptionMessages.Fido2TouchTimeout); + + default: + throw new Fido2Exception(response.CtapStatus, response.StatusMessage); + } + } + finally + { + keyEntryData.Clear(); + keyEntryData.Request = KeyEntryRequest.Release; + touchTask.SdkUpdate(keyEntryData); + } + } + } +} \ No newline at end of file diff --git a/Yubico.YubiKey/tests/unit/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionCommandTests.cs b/Yubico.YubiKey/tests/unit/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionCommandTests.cs new file mode 100644 index 000000000..2b9e7494c --- /dev/null +++ b/Yubico.YubiKey/tests/unit/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionCommandTests.cs @@ -0,0 +1,63 @@ +// Copyright 2026 Yubico AB +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using Xunit; +using Yubico.Core.Iso7816; + +namespace Yubico.YubiKey.Fido2.Commands +{ + // Unit tests for AuthenticatorSelectionCommand (CTAP 2.2 authenticatorSelection, command byte 0x0B). + public class AuthenticatorSelectionCommandTests + { + [Fact] + public void Constructor_Succeeds() + { + var command = new AuthenticatorSelectionCommand(); + + Assert.NotNull(command); + } + + [Fact] + public void CreateCommandApdu_CreatesCorrectApdu() + { + var command = new AuthenticatorSelectionCommand(); + CommandApdu apdu = command.CreateCommandApdu(); + + // Payload is the CTAP command byte only; no CBOR parameters (CTAP 2.2 authenticatorSelection). + byte[] expectedData = new byte[] + { + 0x0B, // authenticatorSelection + }; + + Assert.Equal(0, apdu.Cla); + Assert.Equal(0x10, apdu.Ins); // CTAPHID_CBOR / FIDO2 extended APDU INS, same as other CTAP-via-APDU commands + Assert.Equal(0, apdu.P1); + Assert.Equal(0, apdu.P2); + Assert.True(apdu.Data.Span.SequenceEqual(expectedData)); + } + + [Fact] + public void CreateResponseForApdu_ReturnsAuthenticatorSelectionResponse() + { + var command = new AuthenticatorSelectionCommand(); + // Empty response body and 9000; maps to CTAP OK in AuthenticatorSelectionResponse. + var responseApdu = new ResponseApdu(System.Array.Empty(), SWConstants.Success); + + AuthenticatorSelectionResponse response = command.CreateResponseForApdu(responseApdu); + + Assert.NotNull(response); + } + } +} \ No newline at end of file diff --git a/Yubico.YubiKey/tests/unit/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionResponseTests.cs b/Yubico.YubiKey/tests/unit/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionResponseTests.cs new file mode 100644 index 000000000..d515ff3b1 --- /dev/null +++ b/Yubico.YubiKey/tests/unit/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionResponseTests.cs @@ -0,0 +1,61 @@ +// Copyright 2026 Yubico AB +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Xunit; +using Yubico.Core.Iso7816; +using Yubico.YubiKey; +using Yubico.YubiKey.Fido2; + +namespace Yubico.YubiKey.Fido2.Commands +{ + // Unit tests for AuthenticatorSelectionResponse (CTAP 2.2 authenticatorSelection; partner to AuthenticatorSelectionCommand). + public class AuthenticatorSelectionResponseTests + { + [Fact] + public void Constructor_GivenSuccessApdu_SetsOkAndSuccess() + { + // Empty response body and 9000; maps to CTAP OK (mirrors successful authenticatorSelection). + var responseApdu = new ResponseApdu(System.Array.Empty(), SWConstants.Success); + var response = new AuthenticatorSelectionResponse(responseApdu); + + Assert.Equal(CtapStatus.Ok, response.CtapStatus); + Assert.Equal(ResponseStatus.Success, response.Status); + } + + [Fact] + public void Constructor_GivenInvalidCommand_SetsCtapStatus() + { + // CTAP error encoding: SW1=0x6F, SW2=CTAP status byte (see CtapToApduResponse.GetSwForCtapError). + short sw = unchecked((short)((0x6F << 8) | (byte)CtapStatus.InvalidCommand)); // InvalidCommand / CTAP1_ERR_INVALID_COMMAND + var responseApdu = new ResponseApdu(System.Array.Empty(), sw); + var response = new AuthenticatorSelectionResponse(responseApdu); + + Assert.Equal(CtapStatus.InvalidCommand, response.CtapStatus); + Assert.Equal(ResponseStatus.Failed, response.Status); + } + + [Fact] + public void Constructor_GivenOperationDenied_UsesSelectionDeniedMessage() + { + // Same SW packing as InvalidCommand test; OperationDenied when user presence is refused for selection. + short sw = unchecked((short)((0x6F << 8) | (byte)CtapStatus.OperationDenied)); // CTAP2_ERR_OPERATION_DENIED + var responseApdu = new ResponseApdu(System.Array.Empty(), sw); + var response = new AuthenticatorSelectionResponse(responseApdu); + + Assert.Equal(CtapStatus.OperationDenied, response.CtapStatus); + Assert.Equal(ResponseStatus.Failed, response.Status); + Assert.Equal(ResponseStatusMessages.Fido2AuthenticatorSelectionDenied, response.StatusMessage); + } + } +} \ No newline at end of file diff --git a/docs/users-manual/application-fido2/apdu/authenticator-selection.md b/docs/users-manual/application-fido2/apdu/authenticator-selection.md new file mode 100644 index 000000000..daec90fbe --- /dev/null +++ b/docs/users-manual/application-fido2/apdu/authenticator-selection.md @@ -0,0 +1,53 @@ +--- +uid: Fido2AuthenticatorSelectionApdu +--- + + + +## Authenticator selection (CTAP `authenticatorSelection`) + +### Command APDU info + +| CLA | INS | P1 | P2 | Lc | Data | Le | +|:---:|:---:|:--:|:--:|:--:|:----:|:--------:| +| 00 | 10 | 00 | 00 | 01 | 0B | (absent) | + +The `Ins` byte is `10` (CTAPHID_CBOR). The data is the CTAP command byte `0B` (*authenticatorSelection*) only; there are no CBOR parameters (CTAP version `2.2` §6.9). + +### Response APDU info + +#### Success + +Total Length: 2 (or success with empty CBOR payload after status, depending on transport framing) +Data Length: 0 + +| Data | SW1 | SW2 | +|:---------:|:---:|:---:| +| (no data) | 90 | 00 | + +#### Command not supported + +If the authenticator does not implement *authenticatorSelection*, it may return `CTAP1_ERR_INVALID_COMMAND` (`0x01`), which the SDK surfaces with SW2 = `01` and SW1 = `6F` (no precise diagnosis), consistent with other CTAP error mappings. + +#### User action timeout + +If the user does not complete User Presence (UP) in time, the authenticator returns `CTAP2_ERR_USER_ACTION_TIMEOUT` (`0x2F`). + +#### User Presence (UP) denied + +CTAP version `2.2` §6.9 states that ifUser Presence (UP) is **explicitly denied**, the authenticator returns `CTAP2_ERR_OPERATION_DENIED` (`0x27`). That is distinct from waiting until a timer expires (see below). + +> [!NOTE] +> On the YubiKey, the only user affordance is **touch to approve** or **no touch** until the operation times out. There is **no separate “deny” or “cancel” control on the security key itself**, so when the user does not complete UP you will usually see **`CTAP2_ERR_USER_ACTION_TIMEOUT`**, not an explicit denial. **`CTAP2_ERR_OPERATION_DENIED`** may be returned if the user engages a platform dialog to cancel the request. diff --git a/docs/users-manual/application-fido2/fido2-commands.md b/docs/users-manual/application-fido2/fido2-commands.md index e1c5b2350..32ef14882 100644 --- a/docs/users-manual/application-fido2/fido2-commands.md +++ b/docs/users-manual/application-fido2/fido2-commands.md @@ -40,6 +40,7 @@ what information is needed from the caller for that command. * [Enumerate RPs Get Next RP](#enumerate-rps-get-next-rp) * [Get Large Blob](#get-large-blob) * [Set Large Blob](#set-large-blob) +* [Authenticator selection](#authenticator-selection) * [Reset](#reset) ___ @@ -613,6 +614,37 @@ None [Technical APDU Details](apdu/set-large-blob.md) ___ +## Authenticator selection + +Request user presence (UP) so the user can indicate _which_ authenticator to use, for example when more than one YubiKey is connected. + +### Available + +All YubiKey with the FIDO2 application having CTAP version `2.2` or greater. + +### SDK classes + +[Fido2Session.TryAuthenticatorSelection](xref:Yubico.YubiKey.Fido2.Fido2Session.TryAuthenticatorSelection%2a) + +[AuthenticatorSelectionCommand](xref:Yubico.YubiKey.Fido2.Commands.AuthenticatorSelectionCommand) + +[AuthenticatorSelectionResponse](xref:Yubico.YubiKey.Fido2.Commands.AuthenticatorSelectionResponse) + +For a minimal sample (one or several keys, touch to select), see `Fido2AuthenticatorSelection` in the SDK’s **Fido2SampleCode** example project (`YubiKeyOperations/Fido2AuthenticatorSelection.cs`). + +### Input + +None. + +### Output + +None on success. + +### APDU + +[Technical APDU Details](apdu/authenticator-selection.md) +___ + ## Reset Reset the FIDO2 application on a YubiKey. This will delete all existing FIDO2 keys and @@ -646,4 +678,4 @@ None ### APDU [Technical APDU Details](apdu/reset.md) -___ +___ \ No newline at end of file diff --git a/docs/users-manual/application-fido2/fido2-touch-notification.md b/docs/users-manual/application-fido2/fido2-touch-notification.md index 951bdeb6b..3b0488c25 100644 --- a/docs/users-manual/application-fido2/fido2-touch-notification.md +++ b/docs/users-manual/application-fido2/fido2-touch-notification.md @@ -23,8 +23,9 @@ verify their fingerprint on the YubiKey's fingerprint reader (this applies to th Bio series only). In addition, some operations, such as -[MakeCredential](xref:Yubico.YubiKey.Fido2.Fido2Session.MakeCredential%2a) or -[GetAssertion](xref:Yubico.YubiKey.Fido2.Fido2Session.GetAssertions%2a), will not complete +[MakeCredential](xref:Yubico.YubiKey.Fido2.Fido2Session.MakeCredential%2a), +[GetAssertion](xref:Yubico.YubiKey.Fido2.Fido2Session.GetAssertions%2a), or +[TryAuthenticatorSelection](xref:Yubico.YubiKey.Fido2.Fido2Session.TryAuthenticatorSelection%2a), will not complete until the user touches the contact. For example, a YubiKey will begin an operation, but at some point will stop processing until the contact has been touched. Once touched, it will finish the operation. @@ -42,4 +43,4 @@ is necessary only to notify the user to perform some task. The [KeyCollector and touch article](../sdk-programming-guide/key-collector-touch.md) in the User's Manual "SDK programming guide" explains how the process of touch notification -works, describes requirements of your `KeyCollector`, and provides rudimentary samples. +works, describes requirements of your `KeyCollector`, and provides rudimentary samples. \ No newline at end of file From e0f4ec3d4ac86601203b9e710f374298e47b1ba3 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 11:35:47 +0000 Subject: [PATCH 02/12] fix: address Copilot review findings in authenticatorSelection PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Continue iterating YubiKeys on InvalidCommand instead of returning early; report unsupported-firmware message only after all devices are tried - Replace hardcoded SW1 literal 0x6F with SW1Constants.NoPreciseDiagnosis in tests - Fix double space in Fido2Session.AuthenticatorSelection.cs header comment - Fix grammar: "All YubiKey" → "All YubiKeys" in fido2-commands.md - Fix typo: "ifUser" → "if User" in authenticator-selection.md Co-authored-by: Dennis Dyallo --- .../Fido2AuthenticatorSelection.cs | 21 ++++++++++++------- .../Fido2Session.AuthenticatorSelection.cs | 2 +- .../AuthenticatorSelectionResponseTests.cs | 4 ++-- .../apdu/authenticator-selection.md | 2 +- .../application-fido2/fido2-commands.md | 2 +- 5 files changed, 19 insertions(+), 12 deletions(-) diff --git a/Yubico.YubiKey/examples/Fido2SampleCode/YubiKeyOperations/Fido2AuthenticatorSelection.cs b/Yubico.YubiKey/examples/Fido2SampleCode/YubiKeyOperations/Fido2AuthenticatorSelection.cs index 49779efcb..7e76d5003 100644 --- a/Yubico.YubiKey/examples/Fido2SampleCode/YubiKeyOperations/Fido2AuthenticatorSelection.cs +++ b/Yubico.YubiKey/examples/Fido2SampleCode/YubiKeyOperations/Fido2AuthenticatorSelection.cs @@ -47,6 +47,7 @@ public static bool Run( SampleMenu.WriteMessage(MessageType.Title, 0, "\nTouch a YubiKey when it blinks.\n"); + bool anyUnsupported = false; foreach (IYubiKeyDevice device in keys) { try @@ -59,15 +60,11 @@ public static bool Run( return true; } - // CTAP INVALID_COMMAND: firmware does not support CTAP 2.2. + // CTAP INVALID_COMMAND: this firmware does not support CTAP 2.2; try the next key. if (response.CtapStatus == CtapStatus.InvalidCommand) { - SampleMenu.WriteMessage( - MessageType.Title, - 0, - "\nOne or more YubiKeys does not support CTAP 2.2.\n"); - PauseBeforeMainMenu(); - return true; + anyUnsupported = true; + continue; } } catch (TimeoutException) @@ -88,6 +85,16 @@ public static bool Run( } } + if (anyUnsupported) + { + SampleMenu.WriteMessage( + MessageType.Title, + 0, + "\nOne or more YubiKeys does not support CTAP 2.2.\n"); + PauseBeforeMainMenu(); + return true; + } + SampleMenu.WriteMessage(MessageType.Title, 0, "\nSelection did not complete.\n"); PauseBeforeMainMenu(); return true; diff --git a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Fido2Session.AuthenticatorSelection.cs b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Fido2Session.AuthenticatorSelection.cs index 1a72a4356..6b52b4b03 100644 --- a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Fido2Session.AuthenticatorSelection.cs +++ b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Fido2Session.AuthenticatorSelection.cs @@ -18,7 +18,7 @@ namespace Yubico.YubiKey.Fido2 { - // CTAP 2.2 authenticatorSelection (0x0B): User Presence (UP) for single or multi-YubiKey selection. + // CTAP 2.2 authenticatorSelection (0x0B): User Presence (UP) for single or multi-YubiKey selection. public sealed partial class Fido2Session { /// diff --git a/Yubico.YubiKey/tests/unit/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionResponseTests.cs b/Yubico.YubiKey/tests/unit/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionResponseTests.cs index d515ff3b1..a3930653c 100644 --- a/Yubico.YubiKey/tests/unit/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionResponseTests.cs +++ b/Yubico.YubiKey/tests/unit/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionResponseTests.cs @@ -37,7 +37,7 @@ public void Constructor_GivenSuccessApdu_SetsOkAndSuccess() public void Constructor_GivenInvalidCommand_SetsCtapStatus() { // CTAP error encoding: SW1=0x6F, SW2=CTAP status byte (see CtapToApduResponse.GetSwForCtapError). - short sw = unchecked((short)((0x6F << 8) | (byte)CtapStatus.InvalidCommand)); // InvalidCommand / CTAP1_ERR_INVALID_COMMAND + short sw = unchecked((short)((SW1Constants.NoPreciseDiagnosis << 8) | (byte)CtapStatus.InvalidCommand)); // InvalidCommand / CTAP1_ERR_INVALID_COMMAND var responseApdu = new ResponseApdu(System.Array.Empty(), sw); var response = new AuthenticatorSelectionResponse(responseApdu); @@ -49,7 +49,7 @@ public void Constructor_GivenInvalidCommand_SetsCtapStatus() public void Constructor_GivenOperationDenied_UsesSelectionDeniedMessage() { // Same SW packing as InvalidCommand test; OperationDenied when user presence is refused for selection. - short sw = unchecked((short)((0x6F << 8) | (byte)CtapStatus.OperationDenied)); // CTAP2_ERR_OPERATION_DENIED + short sw = unchecked((short)((SW1Constants.NoPreciseDiagnosis << 8) | (byte)CtapStatus.OperationDenied)); // CTAP2_ERR_OPERATION_DENIED var responseApdu = new ResponseApdu(System.Array.Empty(), sw); var response = new AuthenticatorSelectionResponse(responseApdu); diff --git a/docs/users-manual/application-fido2/apdu/authenticator-selection.md b/docs/users-manual/application-fido2/apdu/authenticator-selection.md index daec90fbe..c7798b6f2 100644 --- a/docs/users-manual/application-fido2/apdu/authenticator-selection.md +++ b/docs/users-manual/application-fido2/apdu/authenticator-selection.md @@ -47,7 +47,7 @@ If the user does not complete User Presence (UP) in time, the authenticator retu #### User Presence (UP) denied -CTAP version `2.2` §6.9 states that ifUser Presence (UP) is **explicitly denied**, the authenticator returns `CTAP2_ERR_OPERATION_DENIED` (`0x27`). That is distinct from waiting until a timer expires (see below). +CTAP version `2.2` §6.9 states that if User Presence (UP) is **explicitly denied**, the authenticator returns `CTAP2_ERR_OPERATION_DENIED` (`0x27`). That is distinct from waiting until a timer expires (see below). > [!NOTE] > On the YubiKey, the only user affordance is **touch to approve** or **no touch** until the operation times out. There is **no separate “deny” or “cancel” control on the security key itself**, so when the user does not complete UP you will usually see **`CTAP2_ERR_USER_ACTION_TIMEOUT`**, not an explicit denial. **`CTAP2_ERR_OPERATION_DENIED`** may be returned if the user engages a platform dialog to cancel the request. diff --git a/docs/users-manual/application-fido2/fido2-commands.md b/docs/users-manual/application-fido2/fido2-commands.md index 32ef14882..ed4390c33 100644 --- a/docs/users-manual/application-fido2/fido2-commands.md +++ b/docs/users-manual/application-fido2/fido2-commands.md @@ -620,7 +620,7 @@ Request user presence (UP) so the user can indicate _which_ authenticator to use ### Available -All YubiKey with the FIDO2 application having CTAP version `2.2` or greater. +All YubiKeys with the FIDO2 application having CTAP version `2.2` or greater. ### SDK classes From 3adb390ed1e8b45350804adbbbf8464fb3030f2d Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 14:43:07 +0000 Subject: [PATCH 03/12] fix: move AuthenticatorSelection before Reset and Exit in FIDO2 sample menu Reorder Fido2MainMenuItem enum so AuthenticatorSelection (26) appears before Reset (27) and Exit (29), matching the intended UX where destructive/terminal actions are listed last. Update the "automatic authentication" banner guard in RunMenuItem to use AuthenticatorSelection as its upper bound (instead of Reset) so the banner is not shown for AuthenticatorSelection, Reset, or Exit. Co-authored-by: Dennis Dyallo --- .../examples/Fido2SampleCode/Run/Fido2MainMenuItem.cs | 4 ++-- .../examples/Fido2SampleCode/Run/Fido2SampleRun.Operations.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Yubico.YubiKey/examples/Fido2SampleCode/Run/Fido2MainMenuItem.cs b/Yubico.YubiKey/examples/Fido2SampleCode/Run/Fido2MainMenuItem.cs index c6c8cf5e6..876e4a5a1 100644 --- a/Yubico.YubiKey/examples/Fido2SampleCode/Run/Fido2MainMenuItem.cs +++ b/Yubico.YubiKey/examples/Fido2SampleCode/Run/Fido2MainMenuItem.cs @@ -43,9 +43,9 @@ public enum Fido2MainMenuItem ToggleAlwaysUv = 24, SetPinConfig = 25, - Reset = 26, + AuthenticatorSelection = 26, - AuthenticatorSelection = 28, + Reset = 27, Exit = 29, } diff --git a/Yubico.YubiKey/examples/Fido2SampleCode/Run/Fido2SampleRun.Operations.cs b/Yubico.YubiKey/examples/Fido2SampleCode/Run/Fido2SampleRun.Operations.cs index 55af5339b..5c98a12af 100644 --- a/Yubico.YubiKey/examples/Fido2SampleCode/Run/Fido2SampleRun.Operations.cs +++ b/Yubico.YubiKey/examples/Fido2SampleCode/Run/Fido2SampleRun.Operations.cs @@ -35,7 +35,7 @@ public partial class Fido2SampleRun public bool RunMenuItem(Fido2MainMenuItem menuItem) { if (menuItem >= Fido2MainMenuItem.MakeCredential - && menuItem < Fido2MainMenuItem.Reset) + && menuItem < Fido2MainMenuItem.AuthenticatorSelection) { SampleMenu.WriteMessage( MessageType.Title, 0, From 2bb03f398515f7742b65fd2ef6b167fc7e0c7e01 Mon Sep 17 00:00:00 2001 From: Dennis Dyall Date: Mon, 18 May 2026 16:57:07 +0200 Subject: [PATCH 04/12] docs: align authenticator selection docs --- .../apdu/authenticator-selection.md | 52 +++++++++++++++---- .../application-fido2/fido2-commands.md | 2 +- .../fido2-touch-notification.md | 2 +- docs/users-manual/toc.yml | 2 + 4 files changed, 46 insertions(+), 12 deletions(-) diff --git a/docs/users-manual/application-fido2/apdu/authenticator-selection.md b/docs/users-manual/application-fido2/apdu/authenticator-selection.md index c7798b6f2..0f756f764 100644 --- a/docs/users-manual/application-fido2/apdu/authenticator-selection.md +++ b/docs/users-manual/application-fido2/apdu/authenticator-selection.md @@ -24,30 +24,62 @@ limitations under the License. --> |:---:|:---:|:--:|:--:|:--:|:----:|:--------:| | 00 | 10 | 00 | 00 | 01 | 0B | (absent) | -The `Ins` byte is `10` (CTAPHID_CBOR). The data is the CTAP command byte `0B` (*authenticatorSelection*) only; there are no CBOR parameters (CTAP version `2.2` §6.9). +The Ins byte (instruction) is 10, which is the byte for CTAPHID_CBOR. +That means the command information is in the Data. + +The data consists of the CTAP Command Byte. In this case, the CTAP +Command Byte is `0B`, which is the command "`authenticatorSelection`". +There are no command parameters. ### Response APDU info -#### Success +#### Response APDU for a successful selection -Total Length: 2 (or success with empty CBOR payload after status, depending on transport framing) +Total Length: 2\ Data Length: 0 | Data | SW1 | SW2 | |:---------:|:---:|:---:| | (no data) | 90 | 00 | -#### Command not supported +#### Response APDU when the command is not supported + +If the authenticator does not implement `authenticatorSelection`, it +may return `CTAP1_ERR_INVALID_COMMAND` (`0x01`). + +Total Length: 2\ +Data Length: 0 + +| Data | SW1 | SW2 | +|:---------:|:---:|:---:| +| (no data) | 6F | 01 | + +#### Response APDU when the YubiKey times out -If the authenticator does not implement *authenticatorSelection*, it may return `CTAP1_ERR_INVALID_COMMAND` (`0x01`), which the SDK surfaces with SW2 = `01` and SW1 = `6F` (no precise diagnosis), consistent with other CTAP error mappings. +This happens when the user does not touch the contact within the timeout +period. -#### User action timeout +Total Length: 2\ +Data Length: 0 + +| Data | SW1 | SW2 | +|:---------:|:---:|:---:| +| (no data) | 6F | 2F | -If the user does not complete User Presence (UP) in time, the authenticator returns `CTAP2_ERR_USER_ACTION_TIMEOUT` (`0x2F`). +#### Response APDU when user presence is denied -#### User Presence (UP) denied +This happens when user presence (UP) is explicitly denied. -CTAP version `2.2` §6.9 states that if User Presence (UP) is **explicitly denied**, the authenticator returns `CTAP2_ERR_OPERATION_DENIED` (`0x27`). That is distinct from waiting until a timer expires (see below). +Total Length: 2\ +Data Length: 0 + +| Data | SW1 | SW2 | +|:---------:|:---:|:---:| +| (no data) | 6F | 27 | > [!NOTE] -> On the YubiKey, the only user affordance is **touch to approve** or **no touch** until the operation times out. There is **no separate “deny” or “cancel” control on the security key itself**, so when the user does not complete UP you will usually see **`CTAP2_ERR_USER_ACTION_TIMEOUT`**, not an explicit denial. **`CTAP2_ERR_OPERATION_DENIED`** may be returned if the user engages a platform dialog to cancel the request. +> On the YubiKey, the only user affordance is touch to approve, or no touch until the +> operation times out. There is no separate deny or cancel control on the security key +> itself, so when the user does not complete UP you will usually see +> `CTAP2_ERR_USER_ACTION_TIMEOUT`, not an explicit denial. `CTAP2_ERR_OPERATION_DENIED` +> may be returned if the user engages a platform dialog to cancel the request. diff --git a/docs/users-manual/application-fido2/fido2-commands.md b/docs/users-manual/application-fido2/fido2-commands.md index ed4390c33..164d62f75 100644 --- a/docs/users-manual/application-fido2/fido2-commands.md +++ b/docs/users-manual/application-fido2/fido2-commands.md @@ -678,4 +678,4 @@ None ### APDU [Technical APDU Details](apdu/reset.md) -___ \ No newline at end of file +___ diff --git a/docs/users-manual/application-fido2/fido2-touch-notification.md b/docs/users-manual/application-fido2/fido2-touch-notification.md index 3b0488c25..7db09927d 100644 --- a/docs/users-manual/application-fido2/fido2-touch-notification.md +++ b/docs/users-manual/application-fido2/fido2-touch-notification.md @@ -43,4 +43,4 @@ is necessary only to notify the user to perform some task. The [KeyCollector and touch article](../sdk-programming-guide/key-collector-touch.md) in the User's Manual "SDK programming guide" explains how the process of touch notification -works, describes requirements of your `KeyCollector`, and provides rudimentary samples. \ No newline at end of file +works, describes requirements of your `KeyCollector`, and provides rudimentary samples. diff --git a/docs/users-manual/toc.yml b/docs/users-manual/toc.yml index 2dc87f0fe..628642687 100644 --- a/docs/users-manual/toc.yml +++ b/docs/users-manual/toc.yml @@ -381,6 +381,8 @@ href: application-fido2/apdu/enum-rps-begin.md - name: Enumerate RPs next href: application-fido2/apdu/enum-rps-next.md + - name: Authenticator selection + href: application-fido2/apdu/authenticator-selection.md - name: Reset href: application-fido2/apdu/reset.md From 02061cb4217bbc3fb65f0818a0615fcdc3f115bf Mon Sep 17 00:00:00 2001 From: Dennis Dyall Date: Mon, 18 May 2026 17:04:03 +0200 Subject: [PATCH 05/12] docs: update headings for clarity in documentation - Changed section title from "Authenticator selection (CTAP `authenticatorSelection`)" to "Select an authenticator". - Updated list item from "Authenticator selection" to "Authenticator Selection". --- .../application-fido2/apdu/authenticator-selection.md | 2 +- docs/users-manual/application-fido2/fido2-commands.md | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/users-manual/application-fido2/apdu/authenticator-selection.md b/docs/users-manual/application-fido2/apdu/authenticator-selection.md index 0f756f764..96dd973a1 100644 --- a/docs/users-manual/application-fido2/apdu/authenticator-selection.md +++ b/docs/users-manual/application-fido2/apdu/authenticator-selection.md @@ -16,7 +16,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. --> -## Authenticator selection (CTAP `authenticatorSelection`) +## Select an authenticator ### Command APDU info diff --git a/docs/users-manual/application-fido2/fido2-commands.md b/docs/users-manual/application-fido2/fido2-commands.md index 164d62f75..040a5fff5 100644 --- a/docs/users-manual/application-fido2/fido2-commands.md +++ b/docs/users-manual/application-fido2/fido2-commands.md @@ -40,7 +40,7 @@ what information is needed from the caller for that command. * [Enumerate RPs Get Next RP](#enumerate-rps-get-next-rp) * [Get Large Blob](#get-large-blob) * [Set Large Blob](#set-large-blob) -* [Authenticator selection](#authenticator-selection) +* [Authenticator Selection](#authenticator-selection) * [Reset](#reset) ___ @@ -630,15 +630,13 @@ All YubiKeys with the FIDO2 application having CTAP version `2.2` or greater. [AuthenticatorSelectionResponse](xref:Yubico.YubiKey.Fido2.Commands.AuthenticatorSelectionResponse) -For a minimal sample (one or several keys, touch to select), see `Fido2AuthenticatorSelection` in the SDK’s **Fido2SampleCode** example project (`YubiKeyOperations/Fido2AuthenticatorSelection.cs`). - ### Input -None. +None ### Output -None on success. +None ### APDU From 021fa0d2c8bcf0e98041edaded320a2da2bfe2e3 Mon Sep 17 00:00:00 2001 From: Dennis Dyall Date: Mon, 18 May 2026 17:20:27 +0200 Subject: [PATCH 06/12] docs: correct authenticatorSelection spec version and firmware threshold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CTAP authenticatorSelection (0x0B) command is specified in CTAP 2.1 §6.9, not CTAP 2.2, and YubiKey support for it shipped in firmware 5.5.1 (2020-11-16), not the 5.7 generation. Update all user-facing and in-source references across the SDK, sample app, unit tests, and docs. Co-Authored-By: Claude Opus 4.7 --- .../YubiKeyOperations/Fido2AuthenticatorSelection.cs | 6 +++--- .../Fido2/Commands/AuthenticatorSelectionCommand.cs | 2 +- .../YubiKey/Fido2/Fido2Session.AuthenticatorSelection.cs | 8 ++++---- .../Fido2/Commands/AuthenticatorSelectionCommandTests.cs | 4 ++-- .../Fido2/Commands/AuthenticatorSelectionResponseTests.cs | 2 +- docs/users-manual/application-fido2/fido2-commands.md | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Yubico.YubiKey/examples/Fido2SampleCode/YubiKeyOperations/Fido2AuthenticatorSelection.cs b/Yubico.YubiKey/examples/Fido2SampleCode/YubiKeyOperations/Fido2AuthenticatorSelection.cs index 7e76d5003..293ebe103 100644 --- a/Yubico.YubiKey/examples/Fido2SampleCode/YubiKeyOperations/Fido2AuthenticatorSelection.cs +++ b/Yubico.YubiKey/examples/Fido2SampleCode/YubiKeyOperations/Fido2AuthenticatorSelection.cs @@ -21,7 +21,7 @@ namespace Yubico.YubiKey.Sample.Fido2SampleCode { - // This file demonstrates CTAP 2.2 authenticatorSelection (0x0B) via + // This file demonstrates CTAP 2.1 §6.9 authenticatorSelection (0x0B) via // Fido2Session.TryAuthenticatorSelection over USB (HID FIDO). The YubiKey // prompts for user presence (touch) by blinking; on success the host can // treat that YubiKey as the chosen authenticator for subsequent commands. @@ -60,7 +60,7 @@ public static bool Run( return true; } - // CTAP INVALID_COMMAND: this firmware does not support CTAP 2.2; try the next key. + // CTAP INVALID_COMMAND: this firmware does not support authenticatorSelection (requires 5.5.1+); try the next key. if (response.CtapStatus == CtapStatus.InvalidCommand) { anyUnsupported = true; @@ -90,7 +90,7 @@ public static bool Run( SampleMenu.WriteMessage( MessageType.Title, 0, - "\nOne or more YubiKeys does not support CTAP 2.2.\n"); + "\nOne or more YubiKeys does not support authenticatorSelection (requires firmware 5.5.1 or later).\n"); PauseBeforeMainMenu(); return true; } diff --git a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionCommand.cs b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionCommand.cs index c3603012e..f2607d489 100644 --- a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionCommand.cs +++ b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionCommand.cs @@ -21,7 +21,7 @@ namespace Yubico.YubiKey.Fido2.Commands /// /// /// The partner response class is . - /// Specified in CTAP 2.2 as authenticatorSelection (command byte 0x0B). + /// Specified in CTAP 2.1 §6.9 as authenticatorSelection (command byte 0x0B). Supported by YubiKey firmware 5.5.1 and later. /// There are no command parameters. Whether the authenticator implements this command /// is firmware-specific; unsupported devices typically return . /// diff --git a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Fido2Session.AuthenticatorSelection.cs b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Fido2Session.AuthenticatorSelection.cs index 6b52b4b03..80efc5958 100644 --- a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Fido2Session.AuthenticatorSelection.cs +++ b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Fido2Session.AuthenticatorSelection.cs @@ -18,12 +18,12 @@ namespace Yubico.YubiKey.Fido2 { - // CTAP 2.2 authenticatorSelection (0x0B): User Presence (UP) for single or multi-YubiKey selection. + // CTAP 2.1 §6.9 authenticatorSelection (0x0B): User Presence (UP) for single or multi-YubiKey selection. YubiKey firmware 5.5.1+. public sealed partial class Fido2Session { /// - /// Requests User Presence (UP) on this YubiKey so the user can select it for intended use (CTAP 2.2 - /// authenticatorSelection). + /// Requests User Presence (UP) on this YubiKey so the user can select it for intended use + /// (CTAP 2.1 §6.9 authenticatorSelection, command byte 0x0B). Requires YubiKey firmware 5.5.1 or later. /// /// /// @@ -37,7 +37,7 @@ public sealed partial class Fido2Session /// /// Returns false if the YubiKey returns (command /// not implemented) or (user declined). There is no SDK - /// firmware gate; behavior depends on the YubiKey's support for CTAP 2.2. + /// firmware gate; behavior depends on the YubiKey's firmware version (5.5.1+). /// /// /// The response from the YubiKey, including the CTAP status (see ). diff --git a/Yubico.YubiKey/tests/unit/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionCommandTests.cs b/Yubico.YubiKey/tests/unit/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionCommandTests.cs index 2b9e7494c..ede5cbf88 100644 --- a/Yubico.YubiKey/tests/unit/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionCommandTests.cs +++ b/Yubico.YubiKey/tests/unit/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionCommandTests.cs @@ -18,7 +18,7 @@ namespace Yubico.YubiKey.Fido2.Commands { - // Unit tests for AuthenticatorSelectionCommand (CTAP 2.2 authenticatorSelection, command byte 0x0B). + // Unit tests for AuthenticatorSelectionCommand (CTAP 2.1 §6.9 authenticatorSelection, command byte 0x0B). public class AuthenticatorSelectionCommandTests { [Fact] @@ -35,7 +35,7 @@ public void CreateCommandApdu_CreatesCorrectApdu() var command = new AuthenticatorSelectionCommand(); CommandApdu apdu = command.CreateCommandApdu(); - // Payload is the CTAP command byte only; no CBOR parameters (CTAP 2.2 authenticatorSelection). + // Payload is the CTAP command byte only; no CBOR parameters (CTAP 2.1 §6.9 authenticatorSelection). byte[] expectedData = new byte[] { 0x0B, // authenticatorSelection diff --git a/Yubico.YubiKey/tests/unit/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionResponseTests.cs b/Yubico.YubiKey/tests/unit/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionResponseTests.cs index a3930653c..c2ca839f9 100644 --- a/Yubico.YubiKey/tests/unit/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionResponseTests.cs +++ b/Yubico.YubiKey/tests/unit/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionResponseTests.cs @@ -19,7 +19,7 @@ namespace Yubico.YubiKey.Fido2.Commands { - // Unit tests for AuthenticatorSelectionResponse (CTAP 2.2 authenticatorSelection; partner to AuthenticatorSelectionCommand). + // Unit tests for AuthenticatorSelectionResponse (CTAP 2.1 §6.9 authenticatorSelection; partner to AuthenticatorSelectionCommand). public class AuthenticatorSelectionResponseTests { [Fact] diff --git a/docs/users-manual/application-fido2/fido2-commands.md b/docs/users-manual/application-fido2/fido2-commands.md index 040a5fff5..51e06441e 100644 --- a/docs/users-manual/application-fido2/fido2-commands.md +++ b/docs/users-manual/application-fido2/fido2-commands.md @@ -620,7 +620,7 @@ Request user presence (UP) so the user can indicate _which_ authenticator to use ### Available -All YubiKeys with the FIDO2 application having CTAP version `2.2` or greater. +YubiKeys with FIDO2 firmware `5.5.1` or later. The underlying command, `authenticatorSelection` (0x0B), is specified in [CTAP 2.1 §6.9](https://fidoalliance.org/specs/fido-v2.1-ps-20210615/fido-client-to-authenticator-protocol-v2.1-ps-20210615.html). ### SDK classes From abc6cddd0155379b2ce447b54652d680e778ce36 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 10:13:12 +0000 Subject: [PATCH 07/12] docs,refactor: apply equijano21 review suggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fido2-commands.md: rewrite authenticator selection description as two sentences; add #authenticatorSelection anchor to CTAP 2.1 §6.9 link - apdu/authenticator-selection.md: rewrite NOTE block for clarity - Fido2Session.AuthenticatorSelection.cs: expand summary XML doc - AuthenticatorSelectionCommand.cs: align summary with session method Co-authored-by: Dennis Dyallo --- .../Fido2/Commands/AuthenticatorSelectionCommand.cs | 2 +- .../YubiKey/Fido2/Fido2Session.AuthenticatorSelection.cs | 2 +- .../application-fido2/apdu/authenticator-selection.md | 8 ++++---- docs/users-manual/application-fido2/fido2-commands.md | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionCommand.cs b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionCommand.cs index f2607d489..576f5e29b 100644 --- a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionCommand.cs +++ b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Commands/AuthenticatorSelectionCommand.cs @@ -17,7 +17,7 @@ namespace Yubico.YubiKey.Fido2.Commands { /// - /// Ask the authenticator for User Presence (UP) so the user can select this YubiKey. + /// Requests User Presence (UP) on the connected YubiKey so the user may indicate their intention to use the YubiKey by touching it. This method can be useful in situations where a user has more than one YubiKey and the application needs to determine which key to use for a subsequent FIDO2 operation. /// /// /// The partner response class is . diff --git a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Fido2Session.AuthenticatorSelection.cs b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Fido2Session.AuthenticatorSelection.cs index 80efc5958..f97b242d2 100644 --- a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Fido2Session.AuthenticatorSelection.cs +++ b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Fido2Session.AuthenticatorSelection.cs @@ -22,7 +22,7 @@ namespace Yubico.YubiKey.Fido2 public sealed partial class Fido2Session { /// - /// Requests User Presence (UP) on this YubiKey so the user can select it for intended use + /// Requests User Presence (UP) on the connected YubiKey so the user may indicate their intention to use the YubiKey by touching it. This method can be useful in situations where a user has more than one YubiKey and the application needs to determine which key to use for a subsequent FIDO2 operation. /// (CTAP 2.1 §6.9 authenticatorSelection, command byte 0x0B). Requires YubiKey firmware 5.5.1 or later. /// /// diff --git a/docs/users-manual/application-fido2/apdu/authenticator-selection.md b/docs/users-manual/application-fido2/apdu/authenticator-selection.md index 96dd973a1..bffd1b2ab 100644 --- a/docs/users-manual/application-fido2/apdu/authenticator-selection.md +++ b/docs/users-manual/application-fido2/apdu/authenticator-selection.md @@ -78,8 +78,8 @@ Data Length: 0 | (no data) | 6F | 27 | > [!NOTE] -> On the YubiKey, the only user affordance is touch to approve, or no touch until the -> operation times out. There is no separate deny or cancel control on the security key -> itself, so when the user does not complete UP you will usually see -> `CTAP2_ERR_USER_ACTION_TIMEOUT`, not an explicit denial. `CTAP2_ERR_OPERATION_DENIED` +> On the YubiKey, the user can either touch the key to select it or wait for the +> operation to time out—there is no separate deny or cancel control on the security key +> itself. When the user does not complete UP you will usually see +> `CTAP2_ERR_USER_ACTION_TIMEOUT`. However, `CTAP2_ERR_OPERATION_DENIED` > may be returned if the user engages a platform dialog to cancel the request. diff --git a/docs/users-manual/application-fido2/fido2-commands.md b/docs/users-manual/application-fido2/fido2-commands.md index 51e06441e..3c6cf8d0c 100644 --- a/docs/users-manual/application-fido2/fido2-commands.md +++ b/docs/users-manual/application-fido2/fido2-commands.md @@ -616,11 +616,11 @@ ___ ## Authenticator selection -Request user presence (UP) so the user can indicate _which_ authenticator to use, for example when more than one YubiKey is connected. +Request user presence (UP) so the user can indicate _which_ authenticator to use for a subsequent operation. This can be useful in situations where more than one YubiKey is connected. ### Available -YubiKeys with FIDO2 firmware `5.5.1` or later. The underlying command, `authenticatorSelection` (0x0B), is specified in [CTAP 2.1 §6.9](https://fidoalliance.org/specs/fido-v2.1-ps-20210615/fido-client-to-authenticator-protocol-v2.1-ps-20210615.html). +YubiKeys with FIDO2 firmware `5.5.1` or later. The underlying command, `authenticatorSelection` (0x0B), is specified in [CTAP 2.1 §6.9](https://fidoalliance.org/specs/fido-v2.1-ps-20210615/fido-client-to-authenticator-protocol-v2.1-ps-20210615.html#authenticatorSelection). ### SDK classes From 373e1d0d08cc12f08693ba829f1bababfface224 Mon Sep 17 00:00:00 2001 From: Dennis Dyallo Date: Tue, 26 May 2026 12:20:28 +0200 Subject: [PATCH 08/12] Refine FIDO2 authenticator selection sample --- .../Fido2SampleCode/Run/SampleStart.cs | 13 +- .../AuthenticatorSelectionCoordinator.cs | 155 ++++++++++ .../Fido2AuthenticatorSelection.cs | 289 ++++++++++++++---- .../examples/Fido2SampleCode/appsettings.json | 4 +- .../Fido2Session.AuthenticatorSelection.cs | 14 +- .../unit/Yubico.YubiKey.UnitTests.csproj | 1 + .../AuthenticatorSelectionCoordinatorTests.cs | 99 ++++++ .../apdu/authenticator-selection.md | 5 + .../application-fido2/fido2-commands.md | 10 + 9 files changed, 533 insertions(+), 57 deletions(-) create mode 100644 Yubico.YubiKey/examples/Fido2SampleCode/YubiKeyOperations/AuthenticatorSelectionCoordinator.cs create mode 100644 Yubico.YubiKey/tests/unit/Yubico/YubiKey/Fido2/AuthenticatorSelectionCoordinatorTests.cs diff --git a/Yubico.YubiKey/examples/Fido2SampleCode/Run/SampleStart.cs b/Yubico.YubiKey/examples/Fido2SampleCode/Run/SampleStart.cs index 01da3247e..f9c6ed0cb 100644 --- a/Yubico.YubiKey/examples/Fido2SampleCode/Run/SampleStart.cs +++ b/Yubico.YubiKey/examples/Fido2SampleCode/Run/SampleStart.cs @@ -13,6 +13,7 @@ // limitations under the License. using System; +using System.IO; // ReSharper disable once RedundantUsingDirective Used on line 44 using Yubico.YubiKey.Sample.SharedCode; @@ -28,6 +29,8 @@ internal sealed class StartProgram // Note that the GUI version is available only on Windows platforms. static void Main(string[] args) { + ConfigureSampleLogging(); + bool useGui = false; if (args.Length != 0) @@ -43,7 +46,7 @@ static void Main(string[] args) #else SampleMenu.WriteMessage( MessageType.Title, 0, - "\n---The GUI version of this sample is not available on this plaform---\n"); + "\n---The GUI version of this sample is not available on this platform---\n"); #endif } else @@ -56,5 +59,13 @@ static void Main(string[] args) #endif } } + + private static void ConfigureSampleLogging() + { + if (File.Exists(Path.Combine(AppContext.BaseDirectory, "appsettings.json"))) + { + Directory.SetCurrentDirectory(AppContext.BaseDirectory); + } + } } } diff --git a/Yubico.YubiKey/examples/Fido2SampleCode/YubiKeyOperations/AuthenticatorSelectionCoordinator.cs b/Yubico.YubiKey/examples/Fido2SampleCode/YubiKeyOperations/AuthenticatorSelectionCoordinator.cs new file mode 100644 index 000000000..7e69ca3ca --- /dev/null +++ b/Yubico.YubiKey/examples/Fido2SampleCode/YubiKeyOperations/AuthenticatorSelectionCoordinator.cs @@ -0,0 +1,155 @@ +// Copyright 2026 Yubico AB +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using Yubico.YubiKey; + +namespace Yubico.YubiKey.Sample.Fido2SampleCode +{ + internal sealed class AuthenticatorSelectionCoordinator + { + private readonly object _lockObject = new object(); + private readonly Dictionary _cancelDelegates = + new Dictionary(DeviceReferenceComparer.Instance); + private readonly HashSet _cancelSignaled = + new HashSet(DeviceReferenceComparer.Instance); + + private IYubiKeyDevice? _selectedDevice; + + public bool HasWinner + { + get + { + lock (_lockObject) + { + return _selectedDevice is not null; + } + } + } + + public IYubiKeyDevice? SelectedDevice + { + get + { + lock (_lockObject) + { + return _selectedDevice; + } + } + } + + public void CaptureCancel(IYubiKeyDevice device, SignalUserCancel signalUserCancel) + { + if (device is null || signalUserCancel is null) + { + return; + } + + bool cancelNow = false; + lock (_lockObject) + { + if (_selectedDevice is null) + { + if (!_cancelDelegates.ContainsKey(device)) + { + _cancelDelegates.Add(device, signalUserCancel); + } + } + else if (!ReferenceEquals(device, _selectedDevice) && _cancelSignaled.Add(device)) + { + cancelNow = true; + } + } + + if (cancelNow) + { + signalUserCancel(); + } + } + + public bool TrySelectWinner(IYubiKeyDevice device) + { + List loserCancels = new List(); + lock (_lockObject) + { + if (_selectedDevice is not null) + { + return false; + } + + _selectedDevice = device; + AddLoserCancels(loserCancels); + } + + InvokeCancels(loserCancels); + return true; + } + + public void CancelLosers() + { + List loserCancels = new List(); + lock (_lockObject) + { + if (_selectedDevice is null) + { + return; + } + + AddLoserCancels(loserCancels); + } + + InvokeCancels(loserCancels); + } + + public bool IsExpectedLoserCancellation(IYubiKeyDevice device) + { + lock (_lockObject) + { + return _selectedDevice is not null && !ReferenceEquals(device, _selectedDevice); + } + } + + private void AddLoserCancels(List loserCancels) + { + foreach (KeyValuePair current in _cancelDelegates) + { + if (!ReferenceEquals(current.Key, _selectedDevice) && _cancelSignaled.Add(current.Key)) + { + loserCancels.Add(current.Value); + } + } + } + + private static void InvokeCancels(List loserCancels) + { + foreach (SignalUserCancel current in loserCancels) + { + current(); + } + } + + private sealed class DeviceReferenceComparer : IEqualityComparer + { + internal static readonly DeviceReferenceComparer Instance = new DeviceReferenceComparer(); + + public bool Equals(IYubiKeyDevice? x, IYubiKeyDevice? y) => ReferenceEquals(x, y); + + public int GetHashCode(IYubiKeyDevice obj) => RuntimeHelpers.GetHashCode(obj); + } + } +} diff --git a/Yubico.YubiKey/examples/Fido2SampleCode/YubiKeyOperations/Fido2AuthenticatorSelection.cs b/Yubico.YubiKey/examples/Fido2SampleCode/YubiKeyOperations/Fido2AuthenticatorSelection.cs index 293ebe103..06219e118 100644 --- a/Yubico.YubiKey/examples/Fido2SampleCode/YubiKeyOperations/Fido2AuthenticatorSelection.cs +++ b/Yubico.YubiKey/examples/Fido2SampleCode/YubiKeyOperations/Fido2AuthenticatorSelection.cs @@ -13,7 +13,10 @@ // limitations under the License. using System; +using System.Collections.Generic; +using System.Globalization; using System.Linq; +using System.Threading.Tasks; using Yubico.YubiKey; using Yubico.YubiKey.Fido2; using Yubico.YubiKey.Fido2.Commands; @@ -21,12 +24,14 @@ namespace Yubico.YubiKey.Sample.Fido2SampleCode { - // This file demonstrates CTAP 2.1 §6.9 authenticatorSelection (0x0B) via - // Fido2Session.TryAuthenticatorSelection over USB (HID FIDO). The YubiKey - // prompts for user presence (touch) by blinking; on success the host can - // treat that YubiKey as the chosen authenticator for subsequent commands. + // Demonstrates CTAP 2.1 authenticatorSelection (0x0B) over USB HID FIDO. + // Each connected authenticator runs selection in its own Fido2Session. The + // first touched authenticator wins; the others are canceled through the + // SDK's SignalUserCancel -> CTAPHID_CANCEL path. public static class Fido2AuthenticatorSelection { + private static readonly object OutputLock = new object(); + public static bool Run( Func keyCollector, ref IYubiKeyDevice yubiKeyChosen) @@ -36,77 +41,145 @@ public static bool Run( throw new ArgumentNullException(nameof(keyCollector)); } - // Look for YubiKeys over the FIDO HID (USB) transport. - IYubiKeyDevice[] keys = YubiKeyDevice.FindByTransport(Transport.HidFido).ToArray(); - if (keys.Length == 0) + IYubiKeyDevice[] devices = YubiKeyDevice.FindByTransport(Transport.HidFido).ToArray(); + if (devices.Length == 0) { - SampleMenu.WriteMessage(MessageType.Title, 0, "\nNo YubiKeys found over HID FIDO.\n"); + WriteLine("\nNo YubiKeys found over HID FIDO.\n"); PauseBeforeMainMenu(); return true; } - SampleMenu.WriteMessage(MessageType.Title, 0, "\nTouch a YubiKey when it blinks.\n"); + WriteLine("\nAuthenticator selection"); + WriteLine("Touch the YubiKey you want to use for the next FIDO2 operation.\n"); + WriteDevices(devices); + + var coordinator = new AuthenticatorSelectionCoordinator(); + Task[] attempts = devices + .Select(device => Task.Run(() => SelectAuthenticator(device, keyCollector, coordinator))) + .ToArray(); - bool anyUnsupported = false; - foreach (IYubiKeyDevice device in keys) + SelectionAttemptResult[] results = WaitForSelection(attempts, coordinator); + IYubiKeyDevice selectedDevice = coordinator.SelectedDevice; + if (selectedDevice is not null) { - try - { - if (TrySelection(device, keyCollector, out AuthenticatorSelectionResponse response)) - { - yubiKeyChosen = device; - SampleMenu.WriteMessage(MessageType.Title, 0, "\nOK\n"); - PauseBeforeMainMenu(); - return true; - } + yubiKeyChosen = selectedDevice; + WriteLine("\nSelected " + GetDeviceDisplayName(selectedDevice) + "."); - // CTAP INVALID_COMMAND: this firmware does not support authenticatorSelection (requires 5.5.1+); try the next key. - if (response.CtapStatus == CtapStatus.InvalidCommand) - { - anyUnsupported = true; - continue; - } - } - catch (TimeoutException) - { - // No touch (or wrong YubiKey) before the session timeout; try another YubiKey. - } - catch (OperationCanceledException ex) + int canceledCount = results.Count(result => result.Outcome == SelectionAttemptOutcome.Canceled); + if (canceledCount > 0) { - // Key collector returned false (e.g. user ignored the prompt). - SampleMenu.WriteMessage(MessageType.Title, 0, ex.Message + "\n"); - PauseBeforeMainMenu(); - return true; + WriteLine( + "Canceled " + canceledCount.ToString(CultureInfo.InvariantCulture) + + " non-selected authenticator session(s)."); + WriteLine("The SDK sends CTAPHID_CANCEL (0x11) when SignalUserCancel is invoked."); } - catch (Fido2Exception ex) - { - // Other FIDO2 errors: show and continue to the next YubiKey, if any. - SampleMenu.WriteMessage(MessageType.Title, 0, ex.Message + "\n"); - } - } - if (anyUnsupported) - { - SampleMenu.WriteMessage( - MessageType.Title, - 0, - "\nOne or more YubiKeys does not support authenticatorSelection (requires firmware 5.5.1 or later).\n"); PauseBeforeMainMenu(); return true; } - SampleMenu.WriteMessage(MessageType.Title, 0, "\nSelection did not complete.\n"); + WriteIncompleteSelectionSummary(results); PauseBeforeMainMenu(); return true; } - // Wait so the user can read messages before the sample redraws the main menu. - private static void PauseBeforeMainMenu() + private static SelectionAttemptResult[] WaitForSelection( + Task[] attempts, + AuthenticatorSelectionCoordinator coordinator) { - SampleMenu.WriteMessage(MessageType.Title, 0, "Press Enter to return to the main menu."); - _ = SampleMenu.ReadResponse(out string _); + var pendingAttempts = new List>(attempts); + + while (pendingAttempts.Count > 0) + { + Task completedAttempt = Task + .WhenAny(pendingAttempts) + .GetAwaiter() + .GetResult(); + + _ = pendingAttempts.Remove(completedAttempt); + + if (completedAttempt.GetAwaiter().GetResult().Outcome == SelectionAttemptOutcome.Selected) + { + coordinator.CancelLosers(); + break; + } + } + + coordinator.CancelLosers(); + Task.WaitAll(attempts); + + return attempts.Select(attempt => attempt.Result).ToArray(); + } + + private static SelectionAttemptResult SelectAuthenticator( + IYubiKeyDevice device, + Func keyCollector, + AuthenticatorSelectionCoordinator coordinator) + { + try + { + bool selected = TrySelection( + device, + CreateSelectionKeyCollector(device, keyCollector, coordinator), + out AuthenticatorSelectionResponse response); + + if (selected) + { + return coordinator.TrySelectWinner(device) + ? SelectionAttemptResult.Selected() + : SelectionAttemptResult.NotSelected(); + } + + return response.CtapStatus switch + { + CtapStatus.InvalidCommand => SelectionAttemptResult.Unsupported(), + CtapStatus.OperationDenied => SelectionAttemptResult.Denied(), + _ => SelectionAttemptResult.NotSelected(), + }; + } + catch (OperationCanceledException) when (coordinator.IsExpectedLoserCancellation(device)) + { + return SelectionAttemptResult.Canceled(); + } + catch (TimeoutException) + { + return SelectionAttemptResult.TimedOut(); + } + catch (Exception ex) + { + return SelectionAttemptResult.Error(device, ex.Message); + } } + private static Func CreateSelectionKeyCollector( + IYubiKeyDevice device, + Func keyCollector, + AuthenticatorSelectionCoordinator coordinator) => + keyEntryData => + { + if (keyEntryData is null) + { + return false; + } + + if (keyEntryData.Request == KeyEntryRequest.TouchRequest) + { + coordinator.CaptureCancel(device, keyEntryData.SignalUserCancel); + if (coordinator.HasWinner) + { + return true; + } + + lock (OutputLock) + { + WriteLine("Waiting for touch on " + GetDeviceDisplayName(device) + "."); + return keyCollector(keyEntryData); + } + } + + return keyEntryData.Request == KeyEntryRequest.Release || keyCollector(keyEntryData); + }; + private static bool TrySelection( IYubiKeyDevice device, Func keyCollector, @@ -119,5 +192,113 @@ private static bool TrySelection( return session.TryAuthenticatorSelection(out response); } + + private static void WriteIncompleteSelectionSummary(SelectionAttemptResult[] results) + { + foreach (SelectionAttemptResult result in results.Where(result => result.Outcome == SelectionAttemptOutcome.Error)) + { + WriteLine(result.Message); + } + + if (results.Any(result => result.Outcome == SelectionAttemptOutcome.Unsupported)) + { + WriteLine("\nOne or more YubiKeys does not support authenticatorSelection."); + WriteLine("This command requires YubiKey firmware 5.5.1 or later."); + return; + } + + if (results.Any(result => result.Outcome == SelectionAttemptOutcome.Denied)) + { + WriteLine("\nSelection was denied."); + return; + } + + if (results.Any(result => result.Outcome == SelectionAttemptOutcome.TimedOut)) + { + WriteLine("\nSelection timed out before a YubiKey was touched."); + return; + } + + WriteLine("\nSelection did not complete."); + } + + private static void WriteDevices(IYubiKeyDevice[] devices) + { + WriteLine("Connected HID FIDO YubiKeys:"); + foreach (IYubiKeyDevice device in devices) + { + WriteLine(" " + GetDeviceDisplayName(device)); + } + + WriteLine(string.Empty); + } + + private static string GetDeviceDisplayName(IYubiKeyDevice device) + { + if (device.SerialNumber.HasValue) + { + return "YubiKey serial " + device.SerialNumber.Value.ToString(CultureInfo.InvariantCulture); + } + + return "YubiKey " + device.FirmwareVersion.ToString(); + } + + private static void WriteLine(string message) => + SampleMenu.WriteMessage(MessageType.Title, 0, message); + + private static void PauseBeforeMainMenu() + { + WriteLine("\nPress Enter to return to the main menu."); + _ = SampleMenu.ReadResponse(out string _); + } + + private enum SelectionAttemptOutcome + { + Selected, + NotSelected, + Unsupported, + Denied, + TimedOut, + Canceled, + Error, + } + + private sealed class SelectionAttemptResult + { + private SelectionAttemptResult( + SelectionAttemptOutcome outcome, + string message) + { + Outcome = outcome; + Message = message; + } + + public SelectionAttemptOutcome Outcome { get; } + + public string Message { get; } + + public static SelectionAttemptResult Selected() => + new SelectionAttemptResult(SelectionAttemptOutcome.Selected, string.Empty); + + public static SelectionAttemptResult NotSelected() => + new SelectionAttemptResult(SelectionAttemptOutcome.NotSelected, string.Empty); + + public static SelectionAttemptResult Unsupported() => + new SelectionAttemptResult(SelectionAttemptOutcome.Unsupported, string.Empty); + + public static SelectionAttemptResult Denied() => + new SelectionAttemptResult(SelectionAttemptOutcome.Denied, string.Empty); + + public static SelectionAttemptResult TimedOut() => + new SelectionAttemptResult(SelectionAttemptOutcome.TimedOut, string.Empty); + + public static SelectionAttemptResult Canceled() => + new SelectionAttemptResult(SelectionAttemptOutcome.Canceled, string.Empty); + + public static SelectionAttemptResult Error(IYubiKeyDevice device, string message) => + new SelectionAttemptResult( + SelectionAttemptOutcome.Error, + GetDeviceDisplayName(device) + ": " + message); + } } -} \ No newline at end of file +} diff --git a/Yubico.YubiKey/examples/Fido2SampleCode/appsettings.json b/Yubico.YubiKey/examples/Fido2SampleCode/appsettings.json index adf3cc68c..9747b2fad 100644 --- a/Yubico.YubiKey/examples/Fido2SampleCode/appsettings.json +++ b/Yubico.YubiKey/examples/Fido2SampleCode/appsettings.json @@ -2,7 +2,9 @@ "AppName": "FidoSampleCode", "Logging": { "LogLevel": { - "Yubico": "Error" + "Yubico": "Error", + "Yubico.Core.Devices.SmartCard.DesktopSmartCardDevice": "Critical", + "Yubico.YubiKey.YubiKeyDeviceListener": "Critical" } } } diff --git a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Fido2Session.AuthenticatorSelection.cs b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Fido2Session.AuthenticatorSelection.cs index f97b242d2..77b5fdc7c 100644 --- a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Fido2Session.AuthenticatorSelection.cs +++ b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Fido2Session.AuthenticatorSelection.cs @@ -29,6 +29,18 @@ public sealed partial class Fido2Session /// /// Per the CTAP specification, after a successful selection the platform should send cancel /// to other authenticators. This SDK does not manage multiple devices; callers orchestrate that. + /// For multi-device selection, enumerate the HID FIDO devices, start one + /// per device, and run this method concurrently on each session. In each session's + /// , capture from the + /// callback. When the first session returns success, + /// call the captured delegates for the non-selected sessions and wait for those sessions to + /// complete cancellation. + /// + /// + /// Applications should not construct CTAPHID_CANCEL packets directly. Calling + /// sets the SDK cancellation state for the pending + /// touch operation. The HID FIDO pipeline observes that state during keepalive processing and + /// sends CTAPHID_CANCEL (0x11) on the session's connection. /// /// /// This method calls the with @@ -95,4 +107,4 @@ public bool TryAuthenticatorSelection(out AuthenticatorSelectionResponse respons } } } -} \ No newline at end of file +} diff --git a/Yubico.YubiKey/tests/unit/Yubico.YubiKey.UnitTests.csproj b/Yubico.YubiKey/tests/unit/Yubico.YubiKey.UnitTests.csproj index 06dcb5a75..9c3d49ba7 100644 --- a/Yubico.YubiKey/tests/unit/Yubico.YubiKey.UnitTests.csproj +++ b/Yubico.YubiKey/tests/unit/Yubico.YubiKey.UnitTests.csproj @@ -33,6 +33,7 @@ limitations under the License. --> + diff --git a/Yubico.YubiKey/tests/unit/Yubico/YubiKey/Fido2/AuthenticatorSelectionCoordinatorTests.cs b/Yubico.YubiKey/tests/unit/Yubico/YubiKey/Fido2/AuthenticatorSelectionCoordinatorTests.cs new file mode 100644 index 000000000..81abeb6f9 --- /dev/null +++ b/Yubico.YubiKey/tests/unit/Yubico/YubiKey/Fido2/AuthenticatorSelectionCoordinatorTests.cs @@ -0,0 +1,99 @@ +// Copyright 2026 Yubico AB +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using NSubstitute; +using Xunit; + +namespace Yubico.YubiKey.Sample.Fido2SampleCode +{ + public class AuthenticatorSelectionCoordinatorTests + { + [Fact] + public void TrySelectWinner_FirstSuccessWinsOnce() + { + var coordinator = new AuthenticatorSelectionCoordinator(); + IYubiKeyDevice first = Device(); + IYubiKeyDevice second = Device(); + + Assert.True(coordinator.TrySelectWinner(first)); + Assert.False(coordinator.TrySelectWinner(second)); + + Assert.True(coordinator.HasWinner); + Assert.Same(first, coordinator.SelectedDevice); + } + + [Fact] + public void TrySelectWinner_LoserDelegatesInvokedOnce() + { + var coordinator = new AuthenticatorSelectionCoordinator(); + IYubiKeyDevice winner = Device(); + IYubiKeyDevice loser = Device(); + int winnerCancelCount = 0; + int loserCancelCount = 0; + + coordinator.CaptureCancel(winner, () => winnerCancelCount++); + coordinator.CaptureCancel(loser, () => loserCancelCount++); + + _ = coordinator.TrySelectWinner(winner); + coordinator.CancelLosers(); + + Assert.Equal(0, winnerCancelCount); + Assert.Equal(1, loserCancelCount); + } + + [Fact] + public void CaptureCancel_LateLoserDelegateImmediatelyCanceled() + { + var coordinator = new AuthenticatorSelectionCoordinator(); + IYubiKeyDevice winner = Device(); + IYubiKeyDevice lateLoser = Device(); + int lateCancelCount = 0; + + _ = coordinator.TrySelectWinner(winner); + coordinator.CaptureCancel(lateLoser, () => lateCancelCount++); + + Assert.Equal(1, lateCancelCount); + } + + [Fact] + public void CancelLosers_BeforeWinnerDoesNotCancel() + { + var coordinator = new AuthenticatorSelectionCoordinator(); + IYubiKeyDevice device = Device(); + int cancelCount = 0; + + coordinator.CaptureCancel(device, () => cancelCount++); + coordinator.CancelLosers(); + + Assert.False(coordinator.HasWinner); + Assert.Null(coordinator.SelectedDevice); + Assert.Equal(0, cancelCount); + } + + [Fact] + public void IsExpectedLoserCancellation_LoserCancellationExpectedAfterWinner() + { + var coordinator = new AuthenticatorSelectionCoordinator(); + IYubiKeyDevice winner = Device(); + IYubiKeyDevice loser = Device(); + + _ = coordinator.TrySelectWinner(winner); + + Assert.True(coordinator.IsExpectedLoserCancellation(loser)); + Assert.False(coordinator.IsExpectedLoserCancellation(winner)); + } + + private static IYubiKeyDevice Device() => Substitute.For(); + } +} diff --git a/docs/users-manual/application-fido2/apdu/authenticator-selection.md b/docs/users-manual/application-fido2/apdu/authenticator-selection.md index bffd1b2ab..3fb7dcb18 100644 --- a/docs/users-manual/application-fido2/apdu/authenticator-selection.md +++ b/docs/users-manual/application-fido2/apdu/authenticator-selection.md @@ -83,3 +83,8 @@ Data Length: 0 > itself. When the user does not complete UP you will usually see > `CTAP2_ERR_USER_ACTION_TIMEOUT`. However, `CTAP2_ERR_OPERATION_DENIED` > may be returned if the user engages a platform dialog to cancel the request. + +When selecting among multiple authenticators, cancel the non-selected SDK sessions by +calling the `SignalUserCancel` delegate from each session's touch callback. The SDK sends +CTAPHID_CANCEL (`0x11`) on that session during keepalive processing; applications should +not add a cancel payload to this APDU. diff --git a/docs/users-manual/application-fido2/fido2-commands.md b/docs/users-manual/application-fido2/fido2-commands.md index 3c6cf8d0c..e5e600d7e 100644 --- a/docs/users-manual/application-fido2/fido2-commands.md +++ b/docs/users-manual/application-fido2/fido2-commands.md @@ -638,6 +638,16 @@ None None +### Multi-device selection + +To select among several connected HID FIDO authenticators, run one +`Fido2Session.TryAuthenticatorSelection` call per device concurrently. Each session's +`KeyCollector` receives a `TouchRequest`; capture its `SignalUserCancel` delegate. After +the first session succeeds, call the captured delegates for the other sessions and wait +for them to finish. The SDK converts those cancellation signals into CTAPHID_CANCEL +(`0x11`) during keepalive handling, so applications do not need to build raw HID cancel +packets. + ### APDU [Technical APDU Details](apdu/authenticator-selection.md) From f13b24b59a0d3739d5f7e8c0352d308821a5f822 Mon Sep 17 00:00:00 2001 From: Dennis Dyallo Date: Tue, 26 May 2026 12:24:32 +0200 Subject: [PATCH 09/12] Configure FIDO2 sample logging explicitly --- .../examples/Fido2SampleCode/Run/SampleStart.cs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/Yubico.YubiKey/examples/Fido2SampleCode/Run/SampleStart.cs b/Yubico.YubiKey/examples/Fido2SampleCode/Run/SampleStart.cs index f9c6ed0cb..c9d4ff8a1 100644 --- a/Yubico.YubiKey/examples/Fido2SampleCode/Run/SampleStart.cs +++ b/Yubico.YubiKey/examples/Fido2SampleCode/Run/SampleStart.cs @@ -14,6 +14,9 @@ using System; using System.IO; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Yubico.Core.Logging; // ReSharper disable once RedundantUsingDirective Used on line 44 using Yubico.YubiKey.Sample.SharedCode; @@ -62,10 +65,20 @@ static void Main(string[] args) private static void ConfigureSampleLogging() { - if (File.Exists(Path.Combine(AppContext.BaseDirectory, "appsettings.json"))) + string settingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); + if (!File.Exists(settingsPath)) { - Directory.SetCurrentDirectory(AppContext.BaseDirectory); + return; } + + IConfigurationRoot configuration = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: false) + .Build(); + + Log.ConfigureLoggerFactory(builder => builder + .AddConfiguration(configuration.GetSection("Logging")) + .AddConsole()); } } } From 2145a842961029686870d2a95400b75a6b7a7be4 Mon Sep 17 00:00:00 2001 From: Dennis Dyallo Date: Tue, 26 May 2026 12:40:29 +0200 Subject: [PATCH 10/12] Simplify authenticator selection coordinator --- .../AuthenticatorSelectionCoordinator.cs | 60 ++++++++++--------- 1 file changed, 33 insertions(+), 27 deletions(-) diff --git a/Yubico.YubiKey/examples/Fido2SampleCode/YubiKeyOperations/AuthenticatorSelectionCoordinator.cs b/Yubico.YubiKey/examples/Fido2SampleCode/YubiKeyOperations/AuthenticatorSelectionCoordinator.cs index 7e69ca3ca..6f8f92440 100644 --- a/Yubico.YubiKey/examples/Fido2SampleCode/YubiKeyOperations/AuthenticatorSelectionCoordinator.cs +++ b/Yubico.YubiKey/examples/Fido2SampleCode/YubiKeyOperations/AuthenticatorSelectionCoordinator.cs @@ -16,7 +16,6 @@ using System; using System.Collections.Generic; -using System.Runtime.CompilerServices; using Yubico.YubiKey; namespace Yubico.YubiKey.Sample.Fido2SampleCode @@ -24,10 +23,8 @@ namespace Yubico.YubiKey.Sample.Fido2SampleCode internal sealed class AuthenticatorSelectionCoordinator { private readonly object _lockObject = new object(); - private readonly Dictionary _cancelDelegates = - new Dictionary(DeviceReferenceComparer.Instance); - private readonly HashSet _cancelSignaled = - new HashSet(DeviceReferenceComparer.Instance); + private readonly List<(IYubiKeyDevice Device, SignalUserCancel Cancel)> _cancelRegistrations = + new List<(IYubiKeyDevice Device, SignalUserCancel Cancel)>(); private IYubiKeyDevice? _selectedDevice; @@ -53,9 +50,9 @@ public IYubiKeyDevice? SelectedDevice } } - public void CaptureCancel(IYubiKeyDevice device, SignalUserCancel signalUserCancel) + public void CaptureCancel(IYubiKeyDevice device, SignalUserCancel? signalUserCancel) { - if (device is null || signalUserCancel is null) + if (signalUserCancel is null) { return; } @@ -65,12 +62,12 @@ public void CaptureCancel(IYubiKeyDevice device, SignalUserCancel signalUserCanc { if (_selectedDevice is null) { - if (!_cancelDelegates.ContainsKey(device)) + if (!HasCancelRegistration(device)) { - _cancelDelegates.Add(device, signalUserCancel); + _cancelRegistrations.Add((device, signalUserCancel)); } } - else if (!ReferenceEquals(device, _selectedDevice) && _cancelSignaled.Add(device)) + else if (!ReferenceEquals(device, _selectedDevice)) { cancelNow = true; } @@ -84,7 +81,7 @@ public void CaptureCancel(IYubiKeyDevice device, SignalUserCancel signalUserCanc public bool TrySelectWinner(IYubiKeyDevice device) { - List loserCancels = new List(); + SignalUserCancel[] loserCancels; lock (_lockObject) { if (_selectedDevice is not null) @@ -93,7 +90,7 @@ public bool TrySelectWinner(IYubiKeyDevice device) } _selectedDevice = device; - AddLoserCancels(loserCancels); + loserCancels = TakeLoserCancels(); } InvokeCancels(loserCancels); @@ -102,7 +99,7 @@ public bool TrySelectWinner(IYubiKeyDevice device) public void CancelLosers() { - List loserCancels = new List(); + SignalUserCancel[] loserCancels; lock (_lockObject) { if (_selectedDevice is null) @@ -110,7 +107,7 @@ public void CancelLosers() return; } - AddLoserCancels(loserCancels); + loserCancels = TakeLoserCancels(); } InvokeCancels(loserCancels); @@ -124,32 +121,41 @@ public bool IsExpectedLoserCancellation(IYubiKeyDevice device) } } - private void AddLoserCancels(List loserCancels) + private bool HasCancelRegistration(IYubiKeyDevice device) { - foreach (KeyValuePair current in _cancelDelegates) + foreach ((IYubiKeyDevice currentDevice, _) in _cancelRegistrations) { - if (!ReferenceEquals(current.Key, _selectedDevice) && _cancelSignaled.Add(current.Key)) + if (ReferenceEquals(currentDevice, device)) { - loserCancels.Add(current.Value); + return true; } } + + return false; } - private static void InvokeCancels(List loserCancels) + private SignalUserCancel[] TakeLoserCancels() { - foreach (SignalUserCancel current in loserCancels) + var loserCancels = new List(); + for (int index = _cancelRegistrations.Count - 1; index >= 0; index--) { - current(); + (IYubiKeyDevice currentDevice, SignalUserCancel currentCancel) = _cancelRegistrations[index]; + if (!ReferenceEquals(currentDevice, _selectedDevice)) + { + loserCancels.Add(currentCancel); + _cancelRegistrations.RemoveAt(index); + } } + + return loserCancels.ToArray(); } - private sealed class DeviceReferenceComparer : IEqualityComparer + private static void InvokeCancels(SignalUserCancel[] loserCancels) { - internal static readonly DeviceReferenceComparer Instance = new DeviceReferenceComparer(); - - public bool Equals(IYubiKeyDevice? x, IYubiKeyDevice? y) => ReferenceEquals(x, y); - - public int GetHashCode(IYubiKeyDevice obj) => RuntimeHelpers.GetHashCode(obj); + foreach (SignalUserCancel current in loserCancels) + { + current(); + } } } } From f6fecda2c516145834df2e98c94aff6563192d5e Mon Sep 17 00:00:00 2001 From: Dennis Dyallo Date: Tue, 26 May 2026 13:39:53 +0200 Subject: [PATCH 11/12] Flatten authenticator selection coordinator --- .../AuthenticatorSelectionCoordinator.cs | 81 +++++++++++-------- 1 file changed, 47 insertions(+), 34 deletions(-) diff --git a/Yubico.YubiKey/examples/Fido2SampleCode/YubiKeyOperations/AuthenticatorSelectionCoordinator.cs b/Yubico.YubiKey/examples/Fido2SampleCode/YubiKeyOperations/AuthenticatorSelectionCoordinator.cs index 6f8f92440..d53f62325 100644 --- a/Yubico.YubiKey/examples/Fido2SampleCode/YubiKeyOperations/AuthenticatorSelectionCoordinator.cs +++ b/Yubico.YubiKey/examples/Fido2SampleCode/YubiKeyOperations/AuthenticatorSelectionCoordinator.cs @@ -57,67 +57,78 @@ public void CaptureCancel(IYubiKeyDevice device, SignalUserCancel? signalUserCan return; } - bool cancelNow = false; - lock (_lockObject) + if (ShouldCancelImmediately(device, signalUserCancel)) { - if (_selectedDevice is null) - { - if (!HasCancelRegistration(device)) - { - _cancelRegistrations.Add((device, signalUserCancel)); - } - } - else if (!ReferenceEquals(device, _selectedDevice)) - { - cancelNow = true; - } + signalUserCancel(); } + } + + public bool TrySelectWinner(IYubiKeyDevice device) + { + bool selected = TrySetSelectedDevice(device, out SignalUserCancel[] loserCancels); + InvokeCancels(loserCancels); - if (cancelNow) + return selected; + } + + public void CancelLosers() + { + InvokeCancels(TakeLoserCancelsIfSelected()); + } + + public bool IsExpectedLoserCancellation(IYubiKeyDevice device) + { + lock (_lockObject) { - signalUserCancel(); + return _selectedDevice is not null && !ReferenceEquals(device, _selectedDevice); } } - public bool TrySelectWinner(IYubiKeyDevice device) + private bool ShouldCancelImmediately(IYubiKeyDevice device, SignalUserCancel signalUserCancel) { - SignalUserCancel[] loserCancels; lock (_lockObject) { if (_selectedDevice is not null) { - return false; + return !ReferenceEquals(device, _selectedDevice); } - _selectedDevice = device; - loserCancels = TakeLoserCancels(); + RegisterCancel(device, signalUserCancel); + return false; } - - InvokeCancels(loserCancels); - return true; } - public void CancelLosers() + private bool TrySetSelectedDevice(IYubiKeyDevice device, out SignalUserCancel[] loserCancels) { - SignalUserCancel[] loserCancels; lock (_lockObject) { - if (_selectedDevice is null) + if (_selectedDevice is not null) { - return; + loserCancels = Array.Empty(); + return false; } + _selectedDevice = device; loserCancels = TakeLoserCancels(); + return true; } - - InvokeCancels(loserCancels); } - public bool IsExpectedLoserCancellation(IYubiKeyDevice device) + private SignalUserCancel[] TakeLoserCancelsIfSelected() { lock (_lockObject) { - return _selectedDevice is not null && !ReferenceEquals(device, _selectedDevice); + return _selectedDevice is null + ? Array.Empty() + : TakeLoserCancels(); + } + } + + private void RegisterCancel(IYubiKeyDevice device, SignalUserCancel signalUserCancel) + { + if (!HasCancelRegistration(device)) + { + _cancelRegistrations.Add((device, signalUserCancel)); } } @@ -140,11 +151,13 @@ private SignalUserCancel[] TakeLoserCancels() for (int index = _cancelRegistrations.Count - 1; index >= 0; index--) { (IYubiKeyDevice currentDevice, SignalUserCancel currentCancel) = _cancelRegistrations[index]; - if (!ReferenceEquals(currentDevice, _selectedDevice)) + if (ReferenceEquals(currentDevice, _selectedDevice)) { - loserCancels.Add(currentCancel); - _cancelRegistrations.RemoveAt(index); + continue; } + + loserCancels.Add(currentCancel); + _cancelRegistrations.RemoveAt(index); } return loserCancels.ToArray(); From c1af57b6bc7ec91d3cd96a3c12d5876e98def208 Mon Sep 17 00:00:00 2001 From: Dennis Dyallo Date: Tue, 26 May 2026 14:04:23 +0200 Subject: [PATCH 12/12] Document authenticator selection coordinator flow --- .../AuthenticatorSelectionCoordinator.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Yubico.YubiKey/examples/Fido2SampleCode/YubiKeyOperations/AuthenticatorSelectionCoordinator.cs b/Yubico.YubiKey/examples/Fido2SampleCode/YubiKeyOperations/AuthenticatorSelectionCoordinator.cs index d53f62325..4e82b7d9f 100644 --- a/Yubico.YubiKey/examples/Fido2SampleCode/YubiKeyOperations/AuthenticatorSelectionCoordinator.cs +++ b/Yubico.YubiKey/examples/Fido2SampleCode/YubiKeyOperations/AuthenticatorSelectionCoordinator.cs @@ -20,6 +20,21 @@ namespace Yubico.YubiKey.Sample.Fido2SampleCode { + /// + /// Coordinates concurrent authenticator selection workers for the FIDO2 sample. + /// + /// + /// + /// The sample orchestration is: + /// + /// + /// Start one worker per HID FIDO YubiKey. + /// Each worker captures its delegate when touch is requested. + /// The first worker to complete successfully records its device as the selected authenticator. + /// Captured delegates for non-selected workers are invoked once, allowing the SDK to send CTAPHID_CANCEL (0x11). + /// Late-arriving non-selected workers are canceled immediately after a winner exists. + /// + /// internal sealed class AuthenticatorSelectionCoordinator { private readonly object _lockObject = new object();