diff --git a/.github/workflows/build-pull-requests.yml b/.github/workflows/build-pull-requests.yml index 6ece89849..ce25e1b82 100644 --- a/.github/workflows/build-pull-requests.yml +++ b/.github/workflows/build-pull-requests.yml @@ -46,6 +46,9 @@ jobs: build-artifacts: name: Build artifacts + permissions: + contents: read + packages: read runs-on: windows-latest needs: run-tests diff --git a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Fido2Session.cs b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Fido2Session.cs index 8926c3c95..5055fe1a0 100644 --- a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Fido2Session.cs +++ b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Fido2Session.cs @@ -18,6 +18,7 @@ using Microsoft.Extensions.Logging; using Yubico.Core.Logging; using Yubico.YubiKey.Fido2.Commands; +using Yubico.YubiKey.Scp; namespace Yubico.YubiKey.Fido2 { @@ -218,6 +219,7 @@ public ReadOnlyMemory? AuthenticatorCredStoreState /// YubiKey. /// /// + /// /// Because this class implements IDisposable, use the using keyword. For example, /// /// IYubiKeyDevice yubiKeyToUse = SelectYubiKey(); @@ -226,17 +228,42 @@ public ReadOnlyMemory? AuthenticatorCredStoreState /// /* Perform FIDO2 operations. */ /// } /// + /// + /// + /// To establish an SCP-protected FIDO2 session: + /// + /// using (var fido2 = new Fido2Session(yubiKeyToUse, keyParameters: Scp03KeyParameters.DefaultKey)) + /// { + /// /* All FIDO2 commands are encrypted via SCP. */ + /// } + /// + /// + /// + /// Transport notes for FIDO2 over SCP: On YubiKey firmware 5.8 and later, FIDO2 is + /// available over both HID and USB CCID (SmartCard), so SCP works over USB as well as NFC. + /// On earlier firmware, FIDO2 communicates via HID only over USB, which does not support SCP + /// (a SmartCard-layer protocol). Over NFC, all firmware versions expose FIDO2 via SmartCard. + /// /// /// /// The object that represents the actual YubiKey on which the FIDO2 operations should be performed. /// - /// If supplied, will be used for credential management read-only operations + /// If supplied, will be used for credential management read-only operations. + /// + /// + /// Optional parameters for establishing a Secure Channel Protocol (SCP) connection. + /// When provided, all communication with the YubiKey will be encrypted and authenticated + /// using the specified SCP protocol (e.g., SCP03 or SCP11). On firmware prior to 5.8, this + /// requires an NFC connection. On firmware 5.8+, SCP is also supported over USB. /// /// /// The argument is null. /// - public Fido2Session(IYubiKeyDevice yubiKey, ReadOnlyMemory? persistentPinUvAuthToken = null) - : base(Log.GetLogger(), yubiKey, YubiKeyApplication.Fido2, keyParameters: null) + public Fido2Session( + IYubiKeyDevice yubiKey, + ReadOnlyMemory? persistentPinUvAuthToken = null, + ScpKeyParameters? keyParameters = null) + : base(Log.GetLogger(), yubiKey, YubiKeyApplication.Fido2, keyParameters) { Guard.IsNotNull(yubiKey, nameof(yubiKey)); diff --git a/Yubico.YubiKey/src/Yubico/YubiKey/YubiKeyFeatureExtensions.cs b/Yubico.YubiKey/src/Yubico/YubiKey/YubiKeyFeatureExtensions.cs index 021161bdd..b6593062d 100644 --- a/Yubico.YubiKey/src/Yubico/YubiKey/YubiKeyFeatureExtensions.cs +++ b/Yubico.YubiKey/src/Yubico/YubiKey/YubiKeyFeatureExtensions.cs @@ -94,7 +94,8 @@ public static bool HasFeature(this IYubiKeyDevice yubiKeyDevice, YubiKeyFeature || HasApplication(yubiKeyDevice, YubiKeyCapabilities.Oath) || HasApplication(yubiKeyDevice, YubiKeyCapabilities.OpenPgp) || HasApplication(yubiKeyDevice, YubiKeyCapabilities.Otp) - || HasApplication(yubiKeyDevice, YubiKeyCapabilities.YubiHsmAuth)), + || HasApplication(yubiKeyDevice, YubiKeyCapabilities.YubiHsmAuth) + || HasApplication(yubiKeyDevice, YubiKeyCapabilities.Fido2)), YubiKeyFeature.Scp03Oath => yubiKeyDevice.FirmwareVersion >= FirmwareVersion.V5_6_3 diff --git a/Yubico.YubiKey/tests/integration/Yubico/YubiKey/Scp/Scp03Tests.cs b/Yubico.YubiKey/tests/integration/Yubico/YubiKey/Scp/Scp03Tests.cs index 2161b0362..f29464a21 100644 --- a/Yubico.YubiKey/tests/integration/Yubico/YubiKey/Scp/Scp03Tests.cs +++ b/Yubico.YubiKey/tests/integration/Yubico/YubiKey/Scp/Scp03Tests.cs @@ -19,6 +19,7 @@ using Xunit; using Yubico.Core.Tlv; using Yubico.YubiKey.Cryptography; +using Yubico.YubiKey.Fido2; using Yubico.YubiKey.Piv; using Yubico.YubiKey.Piv.Commands; using Yubico.YubiKey.Scp03; @@ -31,6 +32,28 @@ namespace Yubico.YubiKey.Scp public class Scp03Tests { private readonly ReadOnlyMemory _defaultPin = new byte[] { 0x31, 0x32, 0x33, 0x34, 0x35, 0x36 }; + private readonly ReadOnlyMemory _fido2Pin = "11234567"u8.ToArray(); + + private bool Fido2KeyCollector(KeyEntryData data) + { + if (data.Request == KeyEntryRequest.Release) + { + return true; + } + + if (data.Request == KeyEntryRequest.TouchRequest) + { + return true; + } + + if (data.Request is KeyEntryRequest.VerifyFido2Pin or KeyEntryRequest.SetFido2Pin) + { + data.SubmitValue(_fido2Pin.Span); + return true; + } + + return false; + } public Scp03Tests() { @@ -404,6 +427,133 @@ public void Scp03_PivSession_TryVerifyPinAndGetMetaData_Succeeds( } + [SkippableTheory(typeof(DeviceNotFoundException))] + [InlineData(StandardTestDevice.Fw5, Transport.NfcSmartCard)] + [InlineData(StandardTestDevice.Fw5, Transport.UsbSmartCard)] + public void Scp03_Fido2Session_GetAuthenticatorInfo_Succeeds( + StandardTestDevice desiredDeviceType, + Transport transport) + { + var testDevice = GetDevice(desiredDeviceType, transport); + Assert.True(testDevice.FirmwareVersion >= FirmwareVersion.V5_3_0); + Assert.True(testDevice.HasFeature(YubiKeyFeature.Scp03)); + + // FIDO2 over CCID requires firmware 5.8+. Over NFC, all applets are + // selectable via SmartCard. Over USB, FIDO2 is available on CCID + // starting with firmware 5.8; older keys only expose FIDO2 over HID. + if (transport == Transport.UsbSmartCard) + { + Skip.IfNot( + testDevice.FirmwareVersion >= FirmwareVersion.V5_8_0, + "FIDO2 over USB CCID requires firmware 5.8+"); + } + else + { + Skip.IfNot( + testDevice.AvailableNfcCapabilities.HasFlag(YubiKeyCapabilities.Fido2), + "FIDO2 is not available over NFC on this device"); + } + + using var fido2Session = new Fido2Session(testDevice, keyParameters: Scp03KeyParameters.DefaultKey); + + var info = fido2Session.AuthenticatorInfo; + Assert.NotNull(info); + Assert.NotEmpty(info.Versions); + } + + [SkippableTheory(typeof(DeviceNotFoundException))] + [InlineData(StandardTestDevice.Fw5, Transport.UsbSmartCard)] + public void Scp03_Fido2Session_MakeCredential_Over_UsbCcid_Succeeds( + StandardTestDevice desiredDeviceType, + Transport transport) + { + var testDevice = GetDevice(desiredDeviceType, transport); + Assert.True(testDevice.HasFeature(YubiKeyFeature.Scp03)); + + Skip.IfNot( + testDevice.FirmwareVersion >= FirmwareVersion.V5_8_0, + "FIDO2 over USB CCID requires firmware 5.8+"); + + using var fido2Session = new Fido2Session(testDevice, keyParameters: Scp03KeyParameters.DefaultKey); + Assert.Equal("ScpConnection", fido2Session.Connection.GetType().Name); + + fido2Session.KeyCollector = Fido2KeyCollector; + + // Ensure PIN is set and verify it + var pinOption = fido2Session.AuthenticatorInfo.GetOptionValue(AuthenticatorOptions.clientPin); + if (pinOption == OptionValue.False) + { + fido2Session.TrySetPin(_fido2Pin); + } + else if (fido2Session.AuthenticatorInfo.ForcePinChange == true) + { + Skip.If(true, "Key requires PIN change — cannot test MakeCredential in this state"); + } + + bool verified; + try + { + verified = fido2Session.TryVerifyPin( + _fido2Pin, + permissions: null, + relyingPartyId: null, + retriesRemaining: out _, + rebootRequired: out _); + } + catch (Fido2.Fido2Exception) + { + verified = false; + } + + Skip.IfNot(verified, "PIN verification failed — key may have a different PIN set. Reset FIDO2 app to use default test PIN."); + + // MakeCredential — requires touch + var rp = new RelyingParty("scp03-ccid-test.yubico.com"); + var userId = new UserEntity(new byte[] { 0x01, 0x02, 0x03 }) + { + Name = "scp03-ccid-test", + DisplayName = "SCP03 CCID Test" + }; + + var mcParams = new MakeCredentialParameters(rp, userId) + { + ClientDataHash = new byte[] + { + 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, + 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, + 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, + 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38 + } + }; + + var mcData = fido2Session.MakeCredential(mcParams); + Assert.True(mcData.VerifyAttestation(mcParams.ClientDataHash)); + } + + [SkippableTheory(typeof(DeviceNotFoundException))] + [InlineData(StandardTestDevice.Fw5, Transport.UsbSmartCard)] + public void Scp03_Fido2Session_Pre58_UsbCcid_Skips_Gracefully( + StandardTestDevice desiredDeviceType, + Transport transport) + { + var testDevice = GetDevice(desiredDeviceType, transport); + + if (testDevice.FirmwareVersion >= FirmwareVersion.V5_8_0) + { + // On 5.8+, FIDO2 over CCID should work — verify it does + using var session = new Fido2Session(testDevice, keyParameters: Scp03KeyParameters.DefaultKey); + Assert.NotNull(session.AuthenticatorInfo); + } + else + { + // On pre-5.8, FIDO2 AID SELECT over CCID should fail with ApduException (0x6A82) + Assert.ThrowsAny(() => + { + using var session = new Fido2Session(testDevice, keyParameters: Scp03KeyParameters.DefaultKey); + }); + } + } + [SkippableTheory(typeof(DeviceNotFoundException))] [InlineData(StandardTestDevice.Fw5, Transport.UsbSmartCard)] [InlineData(StandardTestDevice.Fw5Fips, Transport.UsbSmartCard)] diff --git a/Yubico.YubiKey/tests/integration/Yubico/YubiKey/Scp/Scp11Tests.cs b/Yubico.YubiKey/tests/integration/Yubico/YubiKey/Scp/Scp11Tests.cs index 3d9af2c89..72830e0c4 100644 --- a/Yubico.YubiKey/tests/integration/Yubico/YubiKey/Scp/Scp11Tests.cs +++ b/Yubico.YubiKey/tests/integration/Yubico/YubiKey/Scp/Scp11Tests.cs @@ -25,6 +25,7 @@ using Yubico.Core.Devices.Hid; using Yubico.Core.Tlv; using Yubico.YubiKey.Cryptography; +using Yubico.YubiKey.Fido2; using Yubico.YubiKey.Oath; using Yubico.YubiKey.Otp; using Yubico.YubiKey.Piv; @@ -131,6 +132,43 @@ public void Scp11b_App_OtpSession_Operations_Succeeds( configObj.Execute(); } + [SkippableTheory(typeof(DeviceNotFoundException))] + [InlineData(StandardTestDevice.Fw5, Transport.NfcSmartCard)] + [InlineData(StandardTestDevice.Fw5, Transport.UsbSmartCard)] + [InlineData(StandardTestDevice.Fw5Fips, Transport.NfcSmartCard)] + [InlineData(StandardTestDevice.Fw5Fips, Transport.UsbSmartCard)] + public void Scp11b_App_Fido2Session_GetAuthenticatorInfo_Succeeds( + StandardTestDevice desiredDeviceType, + Transport transport) + { + var testDevice = GetDevice(desiredDeviceType, transport); + + // FIDO2 over CCID requires firmware 5.8+. Over NFC, all applets are + // selectable via SmartCard. Over USB, FIDO2 is available on CCID + // starting with firmware 5.8; older keys only expose FIDO2 over HID. + if (transport == Transport.UsbSmartCard) + { + Skip.IfNot( + testDevice.FirmwareVersion >= FirmwareVersion.V5_8_0, + "FIDO2 over USB CCID requires firmware 5.8+"); + } + else + { + Skip.IfNot( + testDevice.AvailableNfcCapabilities.HasFlag(YubiKeyCapabilities.Fido2), + "FIDO2 is not available over NFC on this device"); + } + + var keyReference = new KeyReference(ScpKeyIds.Scp11B, 0x1); + var keyParams = Get_Scp11b_SecureConnection_Parameters(testDevice, keyReference); + + using var session = new Fido2Session(testDevice, keyParameters: keyParams); + + var info = session.AuthenticatorInfo; + Assert.NotNull(info); + Assert.NotEmpty(info.Versions); + } + [SkippableTheory(typeof(DeviceNotFoundException))] [InlineData(StandardTestDevice.Fw5)] [InlineData(StandardTestDevice.Fw5Fips)] diff --git a/Yubico.YubiKey/tests/sandbox/Plugins/Fido2CcidProbePlugin.cs b/Yubico.YubiKey/tests/sandbox/Plugins/Fido2CcidProbePlugin.cs new file mode 100644 index 000000000..7e2e81caa --- /dev/null +++ b/Yubico.YubiKey/tests/sandbox/Plugins/Fido2CcidProbePlugin.cs @@ -0,0 +1,151 @@ +// Quick probe: test FIDO2 AID selection over USB CCID (SmartCard) on 5.8+ keys +// Tests: plain CCID, SCP03, and SCP11b — all over USB SmartCard +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Yubico.Core.Devices.SmartCard; +using Yubico.YubiKey.Cryptography; +using Yubico.YubiKey.DeviceExtensions; +using Yubico.YubiKey.Fido2; +using Yubico.YubiKey.Fido2.Commands; +using Yubico.YubiKey.Scp; + +namespace Yubico.YubiKey.TestApp.Plugins +{ + internal class Fido2CcidProbePlugin : PluginBase + { + public override string Name => "Fido2CcidProbe"; + public override string Description => "Probes FIDO2 over USB CCID (SmartCard) on 5.8+ keys — SCP03 and SCP11b"; + + public Fido2CcidProbePlugin(IOutput output) : base(output) + { + Parameters["command"].Description = "[serial] Serial number of the YubiKey to test (e.g. 125)"; + } + + public override bool Execute() + { + int? targetSerial = string.IsNullOrEmpty(Command) ? null : int.Parse(Command); + + Output.WriteLine("=== FIDO2 over USB CCID Probe (SCP03 + SCP11b) ==="); + Output.WriteLine(); + + var allKeys = YubiKeyDevice.FindAll(); + Output.WriteLine($"Found {allKeys.Count()} YubiKey(s) total"); + + foreach (var key in allKeys) + { + Output.WriteLine($" Serial: {key.SerialNumber}, FW: {key.FirmwareVersion}, Transports: {key.AvailableTransports}"); + Output.WriteLine($" USB Capabilities: {key.AvailableUsbCapabilities}"); + Output.WriteLine($" HasSmartCard: {((YubiKeyDevice)key).HasSmartCard}, IsNfc: {((YubiKeyDevice)key).IsNfcDevice}"); + } + + var targetKey = allKeys.FirstOrDefault(k => + targetSerial == null || k.SerialNumber == targetSerial); + + if (targetKey == null) + { + Output.WriteLine($"No YubiKey found{(targetSerial.HasValue ? $" with serial {targetSerial}" : "")}"); + return false; + } + + Output.WriteLine(); + Output.WriteLine($"--- Target: Serial={targetKey.SerialNumber}, FW={targetKey.FirmwareVersion} ---"); + + var device = (YubiKeyDevice)targetKey; + Output.WriteLine($"FIDO2 in AvailableUsbCapabilities: {targetKey.AvailableUsbCapabilities.HasFlag(YubiKeyCapabilities.Fido2)}"); + + // ---- Test 1: Standard HID path ---- + RunTest("Test 1: Standard Connect(Fido2) — HID path", () => + { + using var conn = targetKey.Connect(YubiKeyApplication.Fido2); + Output.WriteLine($" Connection type: {conn.GetType().Name}"); + var info = conn.SendCommand(new GetInfoCommand()).GetData(); + Output.WriteLine($" AAGUID: {BitConverter.ToString(info.Aaguid.ToArray())}"); + Output.WriteLine($" Versions: {string.Join(", ", info.Versions ?? Array.Empty())}"); + }); + + // ---- Test 2: Direct SmartCard FIDO2 ---- + RunTest("Test 2: Direct SmartCardConnection for FIDO2 over USB CCID", () => + { + if (!device.HasSmartCard) { Output.WriteLine(" SKIPPED — no SmartCard interface"); return; } + var scDevice = device.GetSmartCardDevice(); + Output.WriteLine($" SmartCard path: {scDevice.Path}, IsNfc: {scDevice.IsNfcTransport()}"); + + using var scConn = new SmartCardConnection(scDevice, YubiKeyApplication.Fido2); + Output.WriteLine($" SmartCardConnection created for FIDO2!"); + var info = scConn.SendCommand(new GetInfoCommand()).GetData(); + Output.WriteLine($" AAGUID: {BitConverter.ToString(info.Aaguid.ToArray())}"); + Output.WriteLine($" Transports: {string.Join(", ", info.Transports ?? Array.Empty())}"); + }); + + // ---- Test 3: FIDO2 + SCP03 (default keys) over USB CCID ---- + RunTest("Test 3: Fido2Session + SCP03 (DefaultKey) over USB CCID", () => + { + using var session = new Fido2Session(targetKey, keyParameters: Scp03KeyParameters.DefaultKey); + Output.WriteLine($" Connection type: {session.Connection.GetType().Name}"); + Output.WriteLine($" AAGUID: {BitConverter.ToString(session.AuthenticatorInfo.Aaguid.ToArray())}"); + Output.WriteLine($" Versions: {string.Join(", ", session.AuthenticatorInfo.Versions ?? Array.Empty())}"); + }); + + // ---- Test 4: FIDO2 + SCP11b over USB CCID ---- + RunTest("Test 4: Fido2Session + SCP11b over USB CCID", () => + { + // Step 1: Reset Security Domain to clean state + Output.WriteLine(" Resetting Security Domain..."); + using (var sdSession = new SecurityDomainSession(targetKey)) + { + sdSession.Reset(); + } + Output.WriteLine(" Security Domain reset OK"); + + // Step 2: Get SCP11b key parameters (generates key ref on device) + var keyReference = new KeyReference(ScpKeyIds.Scp11B, 0x1); + Output.WriteLine($" Getting SCP11b certificates for {keyReference}..."); + + IReadOnlyCollection certs; + using (var sdSession = new SecurityDomainSession(targetKey)) + { + certs = sdSession.GetCertificates(keyReference); + } + + var leaf = certs.Last(); + var ecDsaPublicKey = leaf.PublicKey.GetECDsaPublicKey()!; + var keyParams = new Scp11KeyParameters( + keyReference, + ECPublicKey.CreateFromParameters(ecDsaPublicKey.ExportParameters(false))); + Output.WriteLine($" SCP11b key params created (leaf cert subject: {leaf.Subject})"); + + // Step 3: Open FIDO2 session with SCP11b + using var session = new Fido2Session(targetKey, keyParameters: keyParams); + Output.WriteLine($" Connection type: {session.Connection.GetType().Name}"); + Output.WriteLine($" AAGUID: {BitConverter.ToString(session.AuthenticatorInfo.Aaguid.ToArray())}"); + Output.WriteLine($" Versions: {string.Join(", ", session.AuthenticatorInfo.Versions ?? Array.Empty())}"); + }); + + Output.WriteLine(); + Output.WriteLine("=== Probe Complete ==="); + return true; + } + + private void RunTest(string name, Action action) + { + Output.WriteLine(); + Output.WriteLine(name); + try + { + action(); + Output.WriteLine($" >>> PASS"); + } + catch (Exception ex) + { + Output.WriteLine($" >>> FAIL: {ex.GetType().Name}: {ex.Message}"); + if (ex.InnerException != null) + { + Output.WriteLine($" Inner: {ex.InnerException.GetType().Name}: {ex.InnerException.Message}"); + } + } + } + } +} diff --git a/Yubico.YubiKey/tests/sandbox/Program.cs b/Yubico.YubiKey/tests/sandbox/Program.cs index bbc7856f1..ab8a33104 100644 --- a/Yubico.YubiKey/tests/sandbox/Program.cs +++ b/Yubico.YubiKey/tests/sandbox/Program.cs @@ -54,6 +54,7 @@ class Program : IOutput, IDisposable ["feature"] = (output) => new YubiKeyFeaturePlugin(output), ["david"] = (output) => new DavidPlugin(output), ["oath"] = (output) => new OathPlugin(output), + ["fido2ccid"] = (output) => new Fido2CcidProbePlugin(output), }; #region IDisposable Implementation