diff --git a/Yubico.Core/src/Yubico/Core/Cryptography/ArkgPrimitives.cs b/Yubico.Core/src/Yubico/Core/Cryptography/ArkgPrimitives.cs new file mode 100644 index 000000000..b99b4c00c --- /dev/null +++ b/Yubico.Core/src/Yubico/Core/Cryptography/ArkgPrimitives.cs @@ -0,0 +1,47 @@ +// Copyright 2025 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. + +namespace Yubico.Core.Cryptography +{ + /// + /// Factory for the default implementation. + /// + /// + /// This factory creates instances of the platform-specific ARKG-P256 + /// cryptographic primitives implementation. The default implementation + /// uses OpenSSL via P/Invoke through the Yubico.NativeShims library. + /// + public static class ArkgPrimitives + { + /// + /// Creates the OpenSSL-backed ARKG primitives instance. + /// + /// + /// An implementation that performs + /// ARKG-P256 operations using OpenSSL. + /// + /// + /// + /// This method returns a new instance on each call. The implementation + /// is stateless and thread-safe. + /// + /// + /// For testing or custom implementations, applications can replace the + /// default factory by setting the ArkgPrimitivesCreator property + /// in Yubico.YubiKey.Cryptography.CryptographyProviders. + /// + /// + public static IArkgPrimitives Create() => new ArkgPrimitivesOpenSsl(); + } +} diff --git a/Yubico.Core/src/Yubico/Core/Cryptography/ArkgPrimitivesOpenSsl.cs b/Yubico.Core/src/Yubico/Core/Cryptography/ArkgPrimitivesOpenSsl.cs new file mode 100644 index 000000000..c936d7560 --- /dev/null +++ b/Yubico.Core/src/Yubico/Core/Cryptography/ArkgPrimitivesOpenSsl.cs @@ -0,0 +1,525 @@ +// Copyright 2025 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.Globalization; +using System.Numerics; +using System.Security; +using System.Security.Cryptography; +using System.Text; +using Yubico.PlatformInterop; + +namespace Yubico.Core.Cryptography +{ + /// + /// OpenSSL-backed implementation of for ARKG-P256. + /// + /// + /// Provides the security-critical primitives required by the ARKG-P256 + /// algorithm: on-curve point validation, ECDH shared-secret computation, + /// and the full draft-bradleylundberg-cfrg-arkg-09 derivation. Point math + /// goes through Yubico.NativeShims (OpenSSL); scalar reduction uses + /// . + /// + internal sealed class ArkgPrimitivesOpenSsl : IArkgPrimitives + { + private const int P256CoordinateLength = 32; + private const int Sec1UncompressedLength = 1 + (2 * P256CoordinateLength); + private const byte Sec1UncompressedTag = 0x04; + + private const string DstExt = "ARKG-P256"; + + // P-256 group order N (SEC2 v2 §2.4.2). Used for scalar reduction mod N. + private static readonly BigInteger N = BigInteger.Parse( + "00FFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551", + NumberStyles.HexNumber, + CultureInfo.InvariantCulture); + + // 2^256 mod N — used by the wide reduction in HashToScalar. + // Bytes (BE): 00 00 00 00 FF FF FF FF 00 00 00 00 00 00 00 00 + // 43 19 05 52 58 E8 61 7B 0C 46 35 3D 03 9C DA AF + private static readonly BigInteger TwoPow256ModN = BigInteger.Parse( + "0000000000FFFFFFFF00000000000000004319055258E8617B0C46353D039CDAAF", + NumberStyles.HexNumber, + CultureInfo.InvariantCulture); + + /// + public bool IsPointOnCurve(byte[] point) + { + if (point is null) + { + throw new ArgumentNullException(nameof(point)); + } + + if (point.Length != Sec1UncompressedLength || point[0] != Sec1UncompressedTag) + { + return false; + } + + byte[] xBytes = new byte[P256CoordinateLength]; + byte[] yBytes = new byte[P256CoordinateLength]; + Buffer.BlockCopy(point, 1, xBytes, 0, P256CoordinateLength); + Buffer.BlockCopy(point, 1 + P256CoordinateLength, yBytes, 0, P256CoordinateLength); + + using SafeEcGroup group = NativeMethods.EcGroupNewByCurveName( + ECCurve.NamedCurves.nistP256.ToSslCurveId()); + using SafeEcPoint sslPoint = NativeMethods.EcPointNew(group); + using SafeBigNum xBn = NativeMethods.BnBinaryToBigNum(xBytes); + using SafeBigNum yBn = NativeMethods.BnBinaryToBigNum(yBytes); + + // EC_POINT_set_affine_coordinates returns 0 for an off-curve point on + // most modern OpenSSL builds; fall through to the explicit on-curve + // check so the answer is unambiguous regardless of OpenSSL version. + int setResult = NativeMethods.EcPointSetAffineCoordinates(group, sslPoint, xBn, yBn); + if (setResult != 1) + { + return false; + } + + int onCurve = NativeMethods.EcPointIsOnCurve(group, sslPoint); + return onCurve == 1; + } + + /// + public byte[] ComputeEcdhSharedSecret(byte[] privateScalar, byte[] publicPoint) + { + if (privateScalar is null) + { + throw new ArgumentNullException(nameof(privateScalar)); + } + + if (publicPoint is null) + { + throw new ArgumentNullException(nameof(publicPoint)); + } + + if (publicPoint.Length != Sec1UncompressedLength || publicPoint[0] != Sec1UncompressedTag) + { + throw new ArgumentException( + "Public point must be a 65-byte SEC1 uncompressed P-256 point.", + nameof(publicPoint)); + } + + byte[] x = new byte[P256CoordinateLength]; + byte[] y = new byte[P256CoordinateLength]; + Buffer.BlockCopy(publicPoint, 1, x, 0, P256CoordinateLength); + Buffer.BlockCopy(publicPoint, 1 + P256CoordinateLength, y, 0, P256CoordinateLength); + + var publicKey = new ECParameters + { + Curve = ECCurve.NamedCurves.nistP256, + Q = new ECPoint { X = x, Y = y } + }; + + // Reject points that lie outside the curve before doing any scalar + // multiplication. Defends against invalid-curve attacks on the KEM + // public key carried in the previewSign generated-key blob. + if (!IsPointOnCurve(publicPoint)) + { + throw new SecurityException("Public point is not on the P-256 curve."); + } + + return EcdhPrimitives.Create().ComputeSharedSecret(publicKey, privateScalar); + } + + /// + public (byte[] derivedPk, byte[] arkgKeyHandle) Derive( + byte[] pkBl, + byte[] pkKem, + byte[] ikm, + byte[] ctx) + { + if (pkBl is null) + { + throw new ArgumentNullException(nameof(pkBl)); + } + + if (pkKem is null) + { + throw new ArgumentNullException(nameof(pkKem)); + } + + if (ikm is null) + { + throw new ArgumentNullException(nameof(ikm)); + } + + if (ctx is null) + { + throw new ArgumentNullException(nameof(ctx)); + } + + if (ctx.Length > 64) + { + throw new ArgumentOutOfRangeException(nameof(ctx), "ctx must be <= 64 bytes."); + } + + if (!IsPointOnCurve(pkBl)) + { + throw new ArgumentException("pkBl is not on the P-256 curve.", nameof(pkBl)); + } + + if (!IsPointOnCurve(pkKem)) + { + throw new ArgumentException("pkKem is not on the P-256 curve.", nameof(pkKem)); + } + + byte[] ctxPrime = new byte[1 + ctx.Length]; + ctxPrime[0] = (byte)ctx.Length; + Buffer.BlockCopy(ctx, 0, ctxPrime, 1, ctx.Length); + + byte[] ctxKem = Concat(Encoding.ASCII.GetBytes("ARKG-Derive-Key-KEM."), ctxPrime); + byte[] ctxBl = Concat(Encoding.ASCII.GetBytes("ARKG-Derive-Key-BL."), ctxPrime); + + (byte[] ikmTau, byte[] cipher) = HmacKemEncaps(pkKem, ikm, ctxKem); + BigInteger tau; + try + { + tau = BlPrf(ikmTau, ctxBl); + } + finally + { + CryptographicOperations.ZeroMemory(ikmTau); + } + + byte[] derivedPk = BlBlindPublicKey(pkBl, tau); + return (derivedPk, cipher); + } + + // --------------------------------------------------------------------- + // ARKG-BL: blinding-key arithmetic + // --------------------------------------------------------------------- + + private static BigInteger BlPrf(byte[] ikmTau, byte[] ctx) + { + byte[] dst = Concat( + Encoding.ASCII.GetBytes("ARKG-BL-EC."), + Encoding.ASCII.GetBytes(DstExt), + ctx); + return HashToScalar(ikmTau, dst); + } + + private static byte[] BlBlindPublicKey(byte[] pkBl, BigInteger tau) + { + byte[] tauBytes = ScalarToBytes(tau); + try + { + using SafeEcGroup group = NativeMethods.EcGroupNewByCurveName( + ECCurve.NamedCurves.nistP256.ToSslCurveId()); + using SafeEcPoint pkBlPoint = SecToPoint(group, pkBl); + using SafeEcPoint result = NativeMethods.EcPointNew(group); + using SafeBigNum tauBn = NativeMethods.BnBinaryToBigNum(tauBytes); + using SafeBigNum oneBn = NativeMethods.BnBinaryToBigNum(new byte[] { 0x01 }); + + // r = tau*G + 1*pkBl => r = pkBl + tau*G. + int rc = NativeMethods.EcPointMul( + group, + result, + tauBn.DangerousGetHandle(), + pkBlPoint.DangerousGetHandle(), + oneBn.DangerousGetHandle()); + if (rc != 1) + { + throw new CryptographicException("EC_POINT_mul failed in BlBlindPublicKey."); + } + + return PointToSec(group, result); + } + finally + { + CryptographicOperations.ZeroMemory(tauBytes); + } + } + + // --------------------------------------------------------------------- + // ARKG-KEM: ECDH-KEM with HMAC wrapper + // --------------------------------------------------------------------- + + private (byte[] shared, byte[] ciphertext) HmacKemEncaps(byte[] pkKem, byte[] ikm, byte[] ctx) + { + byte[] dstAug = Encoding.ASCII.GetBytes("ARKG-ECDH.ARKG-P256"); + + // Generate ephemeral keypair from IKM (deterministic, matches Rust reference). + (byte[] ephPk, BigInteger ephSk) = KemDeriveKeypair(ikm); + + byte[] ephSkBytes = ScalarToBytes(ephSk); + byte[] kPrime; + try + { + kPrime = ComputeEcdhSharedSecret(ephSkBytes, pkKem); + } + finally + { + CryptographicOperations.ZeroMemory(ephSkBytes); + } + + byte[] macInfo = Concat( + Encoding.ASCII.GetBytes("ARKG-KEM-HMAC-mac."), + dstAug, + ctx); + byte[] mk = HkdfUtilities.DeriveKey(kPrime, salt: ReadOnlySpan.Empty, contextInfo: macInfo, length: 32).ToArray(); + + byte[] tag; + try + { + using HMACSHA256 hmac = new HMACSHA256(mk); + byte[] full = hmac.ComputeHash(ephPk); + tag = new byte[16]; + Buffer.BlockCopy(full, 0, tag, 0, 16); + CryptographicOperations.ZeroMemory(full); + } + finally + { + CryptographicOperations.ZeroMemory(mk); + } + + byte[] sharedInfo = Concat( + Encoding.ASCII.GetBytes("ARKG-KEM-HMAC-shared."), + dstAug, + ctx); + byte[] shared = HkdfUtilities.DeriveKey(kPrime, salt: ReadOnlySpan.Empty, contextInfo: sharedInfo, length: kPrime.Length).ToArray(); + CryptographicOperations.ZeroMemory(kPrime); + + // Ciphertext = MAC tag || ephemeral public key. + byte[] ciphertext = new byte[tag.Length + ephPk.Length]; + Buffer.BlockCopy(tag, 0, ciphertext, 0, tag.Length); + Buffer.BlockCopy(ephPk, 0, ciphertext, tag.Length, ephPk.Length); + + return (shared, ciphertext); + } + + private static (byte[] pk, BigInteger sk) KemDeriveKeypair(byte[] ikm) + { + byte[] dst = Concat( + Encoding.ASCII.GetBytes("ARKG-KEM-ECDH-KG.ARKG-ECDH."), + Encoding.ASCII.GetBytes(DstExt)); + BigInteger sk = HashToScalar(ikm, dst); + byte[] pk = ScalarMulGenerator(sk); + return (pk, sk); + } + + private static byte[] ScalarMulGenerator(BigInteger sk) + { + byte[] skBytes = ScalarToBytes(sk); + try + { + using SafeEcGroup group = NativeMethods.EcGroupNewByCurveName( + ECCurve.NamedCurves.nistP256.ToSslCurveId()); + using SafeEcPoint pkPoint = NativeMethods.EcPointNew(group); + using SafeBigNum skBn = NativeMethods.BnBinaryToBigNum(skBytes); + + int rc = NativeMethods.EcPointMul( + group, + pkPoint, + skBn.DangerousGetHandle(), + IntPtr.Zero, + IntPtr.Zero); + if (rc != 1) + { + throw new CryptographicException("EC_POINT_mul failed in ScalarMulGenerator."); + } + + return PointToSec(group, pkPoint); + } + finally + { + CryptographicOperations.ZeroMemory(skBytes); + } + } + + // --------------------------------------------------------------------- + // RFC 9380 hash-to-curve helpers (scalar variant only) + // --------------------------------------------------------------------- + + private static BigInteger HashToScalar(byte[] msg, byte[] dst) + { + // P256_L = 48 = ceil((ceil(log2(p)) + k) / 8) with k=128. + const int L = 48; + byte[] uniform = ExpandMessageXmd(msg, dst, L); + + // Wide reduction: split into high(16) || low(32), each interpreted big-endian, + // then result = high * (2^256 mod N) + low (mod N). + BigInteger high = BytesToBigIntBE(uniform, 0, 16); + BigInteger low = BytesToBigIntBE(uniform, 16, 32); + return Mod((high * TwoPow256ModN) + low, N); + } + + private static byte[] ExpandMessageXmd(byte[] msg, byte[] dst, int lenInBytes) + { + const int BInBytes = 32; + const int SInBytes = 64; + + int ell = (lenInBytes + BInBytes - 1) / BInBytes; + if (ell > 255 || lenInBytes > 65535 || dst.Length > 255) + { + throw new ArgumentException("expand_message_xmd parameter out of range."); + } + + byte[] dstPrime = new byte[dst.Length + 1]; + Buffer.BlockCopy(dst, 0, dstPrime, 0, dst.Length); + dstPrime[dst.Length] = (byte)dst.Length; + + byte[] zPad = new byte[SInBytes]; + byte[] lIBStr = { (byte)((lenInBytes >> 8) & 0xFF), (byte)(lenInBytes & 0xFF) }; + + byte[] msgPrime = Concat(zPad, msg, lIBStr, new byte[] { 0x00 }, dstPrime); + + byte[][] bVals = new byte[ell + 1][]; + using (SHA256 sha = SHA256.Create()) + { + bVals[0] = sha.ComputeHash(msgPrime); + } + + using (SHA256 sha = SHA256.Create()) + { + byte[] input = Concat(bVals[0], new byte[] { 0x01 }, dstPrime); + bVals[1] = sha.ComputeHash(input); + } + + for (int i = 2; i <= ell; i++) + { + byte[] xored = new byte[BInBytes]; + for (int j = 0; j < BInBytes; j++) + { + xored[j] = (byte)(bVals[0][j] ^ bVals[i - 1][j]); + } + + using SHA256 sha = SHA256.Create(); + byte[] input = Concat(xored, new byte[] { (byte)i }, dstPrime); + bVals[i] = sha.ComputeHash(input); + } + + byte[] result = new byte[lenInBytes]; + int offset = 0; + for (int i = 1; i <= ell && offset < lenInBytes; i++) + { + int copy = Math.Min(BInBytes, lenInBytes - offset); + Buffer.BlockCopy(bVals[i], 0, result, offset, copy); + offset += copy; + } + + return result; + } + + // --------------------------------------------------------------------- + // Conversion helpers + // --------------------------------------------------------------------- + + private static byte[] ScalarToBytes(BigInteger scalar) + { + // BigInteger.ToByteArray is little-endian and includes a sign byte + // when the high bit would otherwise read as negative — strip it, + // then left-pad to 32 bytes big-endian. + byte[] le = scalar.ToByteArray(); + int len = le.Length; + if (len > 1 && le[len - 1] == 0) + { + len--; + } + + byte[] be = new byte[P256CoordinateLength]; + int copy = Math.Min(len, P256CoordinateLength); + for (int i = 0; i < copy; i++) + { + be[P256CoordinateLength - 1 - i] = le[i]; + } + + return be; + } + + private static BigInteger BytesToBigIntBE(byte[] bytes, int offset, int length) + { + byte[] padded = new byte[length + 1]; + for (int i = 0; i < length; i++) + { + padded[length - 1 - i] = bytes[offset + i]; + } + + // padded[length] = 0 by default — explicit positive sign. + return new BigInteger(padded); + } + + private static BigInteger Mod(BigInteger value, BigInteger modulus) + { + BigInteger r = value % modulus; + if (r.Sign < 0) + { + r += modulus; + } + + return r; + } + + private static SafeEcPoint SecToPoint(SafeEcGroup group, byte[] sec1) + { + byte[] x = new byte[P256CoordinateLength]; + byte[] y = new byte[P256CoordinateLength]; + Buffer.BlockCopy(sec1, 1, x, 0, P256CoordinateLength); + Buffer.BlockCopy(sec1, 1 + P256CoordinateLength, y, 0, P256CoordinateLength); + + using SafeBigNum xBn = NativeMethods.BnBinaryToBigNum(x); + using SafeBigNum yBn = NativeMethods.BnBinaryToBigNum(y); + SafeEcPoint point = NativeMethods.EcPointNew(group); + int rc = NativeMethods.EcPointSetAffineCoordinates(group, point, xBn, yBn); + if (rc != 1) + { + point.Dispose(); + throw new CryptographicException("EC_POINT_set_affine_coordinates failed."); + } + + return point; + } + + private static byte[] PointToSec(SafeEcGroup group, SafeEcPoint point) + { + using SafeBigNum xBn = NativeMethods.BnNew(); + using SafeBigNum yBn = NativeMethods.BnNew(); + int rc = NativeMethods.EcPointGetAffineCoordinates(group, point, xBn, yBn); + if (rc != 1) + { + throw new CryptographicException("EC_POINT_get_affine_coordinates failed."); + } + + byte[] xBytes = new byte[P256CoordinateLength]; + byte[] yBytes = new byte[P256CoordinateLength]; + _ = NativeMethods.BnBigNumToBinaryWithPadding(xBn, xBytes); + _ = NativeMethods.BnBigNumToBinaryWithPadding(yBn, yBytes); + + byte[] sec1 = new byte[Sec1UncompressedLength]; + sec1[0] = Sec1UncompressedTag; + Buffer.BlockCopy(xBytes, 0, sec1, 1, P256CoordinateLength); + Buffer.BlockCopy(yBytes, 0, sec1, 1 + P256CoordinateLength, P256CoordinateLength); + return sec1; + } + + private static byte[] Concat(params byte[][] parts) + { + int total = 0; + for (int i = 0; i < parts.Length; i++) + { + total += parts[i].Length; + } + + byte[] result = new byte[total]; + int offset = 0; + for (int i = 0; i < parts.Length; i++) + { + Buffer.BlockCopy(parts[i], 0, result, offset, parts[i].Length); + offset += parts[i].Length; + } + + return result; + } + } +} diff --git a/Yubico.Core/src/Yubico/Core/Cryptography/IArkgPrimitives.cs b/Yubico.Core/src/Yubico/Core/Cryptography/IArkgPrimitives.cs new file mode 100644 index 000000000..aff687b66 --- /dev/null +++ b/Yubico.Core/src/Yubico/Core/Cryptography/IArkgPrimitives.cs @@ -0,0 +1,77 @@ +// Copyright 2025 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. + +namespace Yubico.Core.Cryptography +{ + /// + /// Defines cryptographic primitives required for ARKG-P256 operations. + /// + /// + /// This interface abstracts platform-specific implementations of + /// elliptic curve operations needed for Asynchronous Remote Key Generation. + /// + public interface IArkgPrimitives + { + /// + /// Verifies that an elliptic curve point lies on the P-256 curve. + /// + /// The point to verify, in uncompressed SEC1 format. + /// true if the point is on the curve; otherwise, false. + bool IsPointOnCurve(byte[] point); + + /// + /// Computes an ECDH shared secret using a private scalar and public point. + /// + /// The private scalar value. + /// The public point in uncompressed SEC1 format. + /// The computed shared secret. + byte[] ComputeEcdhSharedSecret(byte[] privateScalar, byte[] publicPoint); + + /// + /// Derives a public key and ARKG key handle using the ARKG-P256 algorithm. + /// + /// + /// + /// This method implements the ARKG-P256 key derivation algorithm as specified + /// in the previewSign WebAuthn extension. Given the blinding and KEM public + /// keys from the YubiKey, along with application-provided input keying material + /// and context, it derives a new public key. + /// + /// + /// The derived public key can be used to verify signatures produced by the + /// YubiKey when called with the corresponding ARKG key handle and context. + /// The ARKG key handle returned by this method must be provided to the + /// YubiKey during signing operations. + /// + /// + /// The context string allows multiple independent public keys to be derived + /// from the same generated key material. Each unique context produces a + /// unique derived key pair. + /// + /// + /// The blinding public key in uncompressed SEC1 format. + /// The KEM public key in uncompressed SEC1 format. + /// Input keying material for HKDF derivation. + /// Context string for domain separation. + /// + /// A tuple containing the derived public key (in uncompressed SEC1 format) + /// and the ARKG key handle (to be passed to the YubiKey during signing). + /// + (byte[] derivedPk, byte[] arkgKeyHandle) Derive( + byte[] pkBl, + byte[] pkKem, + byte[] ikm, + byte[] ctx); + } +} diff --git a/Yubico.Core/tests/unit/Yubico/Core/Cryptography/ArkgPrimitivesTests.cs b/Yubico.Core/tests/unit/Yubico/Core/Cryptography/ArkgPrimitivesTests.cs new file mode 100644 index 000000000..0e57c43f2 --- /dev/null +++ b/Yubico.Core/tests/unit/Yubico/Core/Cryptography/ArkgPrimitivesTests.cs @@ -0,0 +1,135 @@ +// Copyright 2025 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.Security; +using System.Security.Cryptography; +using Xunit; + +namespace Yubico.Core.Cryptography +{ + public class ArkgPrimitivesTests + { + // P-256 generator G (SEC1 uncompressed: 0x04 || Gx || Gy). Authoritative + // reference: SEC2 v2 §2.4.2. + private static readonly byte[] P256Generator = + { + 0x04, + 0x6B, 0x17, 0xD1, 0xF2, 0xE1, 0x2C, 0x42, 0x47, + 0xF8, 0xBC, 0xE6, 0xE5, 0x63, 0xA4, 0x40, 0xF2, + 0x77, 0x03, 0x7D, 0x81, 0x2D, 0xEB, 0x33, 0xA0, + 0xF4, 0xA1, 0x39, 0x45, 0xD8, 0x98, 0xC2, 0x96, + 0x4F, 0xE3, 0x42, 0xE2, 0xFE, 0x1A, 0x7F, 0x9B, + 0x8E, 0xE7, 0xEB, 0x4A, 0x7C, 0x0F, 0x9E, 0x16, + 0x2B, 0xCE, 0x33, 0x57, 0x6B, 0x31, 0x5E, 0xCE, + 0xCB, 0xB6, 0x40, 0x68, 0x37, 0xBF, 0x51, 0xF5, + }; + + [Fact] + public void IsPointOnCurve_ValidP256Generator_ReturnsTrue() + { + IArkgPrimitives primitives = ArkgPrimitives.Create(); + + Assert.True(primitives.IsPointOnCurve(P256Generator)); + } + + [Fact] + public void IsPointOnCurve_OffCurvePoint_ReturnsFalse() + { + byte[] offCurve = (byte[])P256Generator.Clone(); + offCurve[64] ^= 0x01; // Flip the lowest bit of Y → no longer on curve. + + IArkgPrimitives primitives = ArkgPrimitives.Create(); + + Assert.False(primitives.IsPointOnCurve(offCurve)); + } + + [Fact] + public void IsPointOnCurve_MalformedLength_ReturnsFalse() + { + IArkgPrimitives primitives = ArkgPrimitives.Create(); + + Assert.False(primitives.IsPointOnCurve(new byte[10])); + } + + [Fact] + public void IsPointOnCurve_WrongTagByte_ReturnsFalse() + { + byte[] compressedTag = (byte[])P256Generator.Clone(); + compressedTag[0] = 0x02; + + IArkgPrimitives primitives = ArkgPrimitives.Create(); + + Assert.False(primitives.IsPointOnCurve(compressedTag)); + } + + [Fact] + public void IsPointOnCurve_NullPoint_Throws() + { + IArkgPrimitives primitives = ArkgPrimitives.Create(); + + _ = Assert.Throws(() => primitives.IsPointOnCurve(null!)); + } + + [Fact] + public void ComputeEcdhSharedSecret_RoundTrip_MatchesPeer() + { + using var alice = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256); + using var bob = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256); + + ECParameters aliceParams = alice.ExportParameters(includePrivateParameters: true); + ECParameters bobParams = bob.ExportParameters(includePrivateParameters: true); + + byte[] alicePub = ToSec1(bobParams); + byte[] bobPub = ToSec1(aliceParams); + + IArkgPrimitives primitives = ArkgPrimitives.Create(); + byte[] secretFromAlice = primitives.ComputeEcdhSharedSecret(aliceParams.D!, alicePub); + byte[] secretFromBob = primitives.ComputeEcdhSharedSecret(bobParams.D!, bobPub); + + Assert.Equal(secretFromAlice, secretFromBob); + Assert.Equal(32, secretFromAlice.Length); + } + + [Fact] + public void ComputeEcdhSharedSecret_OffCurvePublicPoint_Throws() + { + byte[] offCurve = (byte[])P256Generator.Clone(); + offCurve[64] ^= 0x01; + + IArkgPrimitives primitives = ArkgPrimitives.Create(); + + _ = Assert.Throws( + () => primitives.ComputeEcdhSharedSecret(new byte[32] { 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, offCurve)); + } + + [Fact] + public void ComputeEcdhSharedSecret_MalformedPublicPoint_Throws() + { + IArkgPrimitives primitives = ArkgPrimitives.Create(); + + _ = Assert.Throws( + () => primitives.ComputeEcdhSharedSecret(new byte[32], new byte[10])); + } + + private static byte[] ToSec1(ECParameters p) + { + byte[] sec1 = new byte[65]; + sec1[0] = 0x04; + Buffer.BlockCopy(p.Q.X!, 0, sec1, 1, 32); + Buffer.BlockCopy(p.Q.Y!, 0, sec1, 33, 32); + return sec1; + } + } +} diff --git a/Yubico.YubiKey/src/Yubico/YubiKey/Cryptography/CryptographyProviders.cs b/Yubico.YubiKey/src/Yubico/YubiKey/Cryptography/CryptographyProviders.cs index 87bb706f9..70e9541ab 100644 --- a/Yubico.YubiKey/src/Yubico/YubiKey/Cryptography/CryptographyProviders.cs +++ b/Yubico.YubiKey/src/Yubico/YubiKey/Cryptography/CryptographyProviders.cs @@ -422,6 +422,20 @@ public static class CryptographyProviders /// public static Func EcdhPrimitivesCreator { get; set; } = EcdhPrimitives.Create; + /// + /// This property is a delegate (function pointer). This method will return + /// an instance of . + /// + /// + /// + /// When the SDK derives an ARKG-P256 public key for the previewSign + /// WebAuthn extension, it calls this delegate for the underlying + /// elliptic-curve primitives. The default implementation routes through + /// Yubico.NativeShims (OpenSSL). + /// + /// + public static Func ArkgPrimitivesCreator { get; set; } = ArkgPrimitives.Create; + /// /// This property is a delegate (function pointer). This method will return /// an instance of , built to use the diff --git a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Arkg/ArkgP256.cs b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Arkg/ArkgP256.cs new file mode 100644 index 000000000..85418a3d0 --- /dev/null +++ b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Arkg/ArkgP256.cs @@ -0,0 +1,54 @@ +// Copyright 2025 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.Cryptography; +using Yubico.YubiKey.Cryptography; + +namespace Yubico.YubiKey.Fido2.Arkg +{ + /// + /// Provides ARKG-P256 (Asynchronous Remote Key Generation for P-256) operations. + /// + /// + /// Thin wrapper that routes to the OpenSSL-backed + /// . The full algorithm body lives in + /// Yubico.Core.Cryptography.ArkgPrimitivesOpenSsl.Derive because + /// it needs Yubico.Core's internal OpenSSL P/Invoke surface, which is not + /// visible from Yubico.YubiKey. Conforms to draft-bradleylundberg-cfrg-arkg-09; + /// reference implementation in + /// cnh-authenticator-rs-extension/native/crates/hid-test/src/arkg.rs. + /// + internal static class ArkgP256 + { + /// + /// Derives a public key using the ARKG-P256 algorithm. + /// + /// The blinding public key (SEC1 uncompressed, 65 bytes). + /// The KEM public key (SEC1 uncompressed, 65 bytes). + /// Input keying material for derivation. + /// Context string for derivation. Must be at most 64 bytes. + /// + /// A tuple containing the SEC1 uncompressed derived public key + /// (65 bytes) and the ARKG key handle to send to the authenticator. + /// + public static (byte[] derivedPk, byte[] arkgKeyHandle) DerivePublicKey( + byte[] pkBl, + byte[] pkKem, + byte[] ikm, + byte[] ctx) + { + return CryptographyProviders.ArkgPrimitivesCreator().Derive(pkBl, pkKem, ikm, ctx); + } + } +} diff --git a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/AuthenticatorData.cs b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/AuthenticatorData.cs index 995de2545..a8ba7276e 100644 --- a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/AuthenticatorData.cs +++ b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/AuthenticatorData.cs @@ -389,6 +389,28 @@ public CredProtectPolicy GetCredProtectExtension() return (CredProtectPolicy)encodedValue.Span[0]; } + /// + /// Gets the previewSign signature from the extension outputs. + /// + /// + /// + /// The previewSign extension returns a DER-encoded ECDSA signature in the + /// signed extension outputs. This method extracts that signature if present. + /// + /// + /// + /// The signature bytes if the extension was used and returned data; otherwise, null. + /// + public byte[]? GetPreviewSignSignature() + { + if (!TryGetExtensionData(Fido2.Extensions.PreviewSign, out Memory encodedValue)) + { + return null; + } + + return PreviewSignExtension.DecodeSignature(encodedValue); + } + private bool TryGetExtensionData(string extensionKey, out Memory encodedValue) { Guard.IsNotNullOrEmpty(extensionKey, nameof(extensionKey)); diff --git a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Cose/CoseAlgorithmIdentifier.cs b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Cose/CoseAlgorithmIdentifier.cs index f2c78a449..9d15a0734 100644 --- a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Cose/CoseAlgorithmIdentifier.cs +++ b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Cose/CoseAlgorithmIdentifier.cs @@ -56,10 +56,33 @@ public enum CoseAlgorithmIdentifier /// EdDSA = -8, + /// + /// ECDSA with SHA-256 using NIST P-256 (ESP256). Identifies the resulting + /// signature algorithm produced by previewSign — the YubiKey emits a + /// standard ECDSA-P256 signature. + /// + /// + /// Do NOT pass this value as the alg field of the previewSign + /// COSE_Sign_Args map; that field requests an ARKG-derived signing + /// operation and must be (-65539). + /// + Esp256 = -9, + /// /// RSASSA-PKCS1-v1_5 with SHA-256 /// Currently, not supported by any YubiKey /// RS256 = -257, + + /// + /// ESP256-split with ARKG-P256 (-65539). Used in two places for the + /// previewSign extension: (1) the algorithms array passed to + /// + /// to request ARKG-P256 key generation, and (2) the alg field of + /// the COSE_Sign_Args map sent during a sign-by-credential request to + /// identify the operation as ARKG-derived. The resulting signature + /// itself is plain ECDSA-P256 (). + /// + ArkgP256Esp256 = -65539, } } diff --git a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Extensions.cs b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Extensions.cs index 81a084350..93abe9928 100644 --- a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Extensions.cs +++ b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/Extensions.cs @@ -39,4 +39,9 @@ public static class Extensions /// The third party payment extension identifier. /// public const string ThirdPartyPayment = "thirdPartyPayment"; + + /// + /// The preview sign extension identifier. + /// + public const string PreviewSign = "previewSign"; } diff --git a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/GetAssertionParameters.cs b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/GetAssertionParameters.cs index cd254210f..81b8a125b 100644 --- a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/GetAssertionParameters.cs +++ b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/GetAssertionParameters.cs @@ -14,6 +14,10 @@ using System; using System.Collections.Generic; +using System.Formats.Cbor; +using System.Security.Cryptography; +using CommunityToolkit.Diagnostics; +using Yubico.YubiKey.Cryptography; using Yubico.YubiKey.Fido2.Cbor; using Yubico.YubiKey.Fido2.PinProtocols; @@ -394,6 +398,113 @@ public void EncodeHmacSecretExtension(PinUvAuthProtocolBase authProtocol) AddExtension(Fido2.Extensions.HmacSecret, _hmacSecretEncoding); } + /// + /// Adds the previewSign extension for signing with a derived credential. + /// + /// + /// + /// The previewSign extension enables signing operations using a public key + /// derived offline via ARKG (Asynchronous Remote Key Generation) from a + /// generated key returned during credential creation. + /// + /// + /// The caller supplies the for the YubiKey, + /// obtained by calling the or + /// providing the property. + /// This method will verify that the YubiKey supports the previewSign extension. + /// + /// + /// The should be obtained by calling + /// on the generated + /// key material from the original credential creation. The message is + /// hashed (SHA-256) internally before being sent to the YubiKey for signing. + /// + /// + /// After the assertion succeeds, retrieve the signature using + /// and verify it + /// with . + /// + /// + /// + /// The FIDO2 for the YubiKey being used. + /// + /// + /// The derived key containing the ARKG key handle and context, obtained + /// from . + /// + /// + /// The message to be signed. This will be hashed with SHA-256 before + /// being sent to the YubiKey. + /// + /// + /// The , , or + /// is null. + /// + /// + /// The YubiKey does not support the previewSign extension. + /// + /// + /// The allow-list is null or empty. The previewSign extension requires at least one credential + /// in the allow-list. + /// + public void AddPreviewSignByCredentialExtension( + AuthenticatorInfo authenticatorInfo, + PreviewSignDerivedKey derivedKey, + byte[] message) + { + Guard.IsNotNull(authenticatorInfo, nameof(authenticatorInfo)); + Guard.IsNotNull(derivedKey, nameof(derivedKey)); + Guard.IsNotNull(message, nameof(message)); + + if (!authenticatorInfo.IsExtensionSupported(Fido2.Extensions.PreviewSign)) + { + throw new NotSupportedException(ExceptionMessages.NotSupportedByYubiKeyVersion); + } + + if (AllowList is null || AllowList.Count == 0) + { + throw new InvalidOperationException( + "The previewSign extension's sign-by-credential mode requires at least one credential in the allow-list. " + + "Call AllowCredential() with the credential ID returned from MakeCredential before invoking AddPreviewSignByCredentialExtension()."); + } + + byte[] tbs; + using (SHA256 sha = CryptographyProviders.Sha256Creator()) + { + tbs = sha.ComputeHash(message); + } + + byte[] additionalArgs = EncodeArkgSignArgs(derivedKey); + + byte[] encoded = PreviewSignExtension.EncodeSignByCredentialInput( + derivedKey.DeviceKeyHandle, + tbs, + additionalArgs); + AddExtension(Fido2.Extensions.PreviewSign, encoded); + } + + private static byte[] EncodeArkgSignArgs(PreviewSignDerivedKey derivedKey) + { + // COSE_Sign_Args map {3: alg, -1: arkg_kh, -2: ctx}. The alg field + // identifies the SIGN-ARGS request as ARKG-derived (-65539), not the + // raw signing algorithm. Rust hid-test, python-fido2, and the JS + // test page all pass -65539 here; firmware rejects other values. + var cbor = new CborWriter(CborConformanceMode.Ctap2Canonical, convertIndefiniteLengthEncodings: true); + cbor.WriteStartMap(3); + + cbor.WriteInt32(3); + cbor.WriteInt32((int)Cose.CoseAlgorithmIdentifier.ArkgP256Esp256); + + cbor.WriteInt32(-1); + cbor.WriteByteString(derivedKey.ArkgKeyHandle.Span); + + cbor.WriteInt32(-2); + cbor.WriteByteString(derivedKey.Context.Span); + + cbor.WriteEndMap(); + return cbor.Encode(); + } + /// public override byte[] CborEncode() => new CborMapWriter() diff --git a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/MakeCredentialData.cs b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/MakeCredentialData.cs index 9262c056d..ff580e6f9 100644 --- a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/MakeCredentialData.cs +++ b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/MakeCredentialData.cs @@ -38,6 +38,7 @@ public class MakeCredentialData private const int KeyAttestationStatement = 3; private const int KeyEnterpriseAttestation = 4; private const int KeyLargeBlob = 5; + private const int KeyUnsignedExtensionOutputs = 6; private const int MaxAttestationMapCount = 3; @@ -148,6 +149,15 @@ public class MakeCredentialData /// public ReadOnlyMemory? LargeBlobKey { get; private set; } + /// + /// Gets the unsigned extension outputs returned by the authenticator, if any. + /// + /// + /// This dictionary contains extension outputs that are not included in the + /// signed authenticator data. The previewSign extension uses this to return + /// generated key material. + /// + public IReadOnlyDictionary>? UnsignedExtensionOutputs { get; private set; } /// /// This returns the raw CBOR encoded credential data from the YubiKey, as returned by the MakeCredential operation. @@ -204,6 +214,12 @@ public MakeCredentialData(ReadOnlyMemory cborEncoding) { LargeBlobKey = map.ReadByteString(KeyLargeBlob); } + + if (map.Contains(KeyUnsignedExtensionOutputs)) + { + var unsignedMap = map.ReadMap(KeyUnsignedExtensionOutputs); + UnsignedExtensionOutputs = PreviewSignExtension.ParseUnsignedExtensionOutputs(unsignedMap.Encoded); + } } catch (CborContentException cborException) { @@ -255,6 +271,32 @@ private bool ReadAttestation(CborMap map) return true; } + /// + /// Retrieves the previewSign generated key from the unsigned extension outputs. + /// + /// + /// + /// The previewSign extension returns generated key material in the unsigned + /// extension outputs (CTAP response key 0x06). This method parses that data + /// and returns a instance containing + /// the key handle and public key components. + /// + /// + /// + /// A if the extension was used and returned + /// data; otherwise, null. + /// + public PreviewSignGeneratedKey? GetPreviewSignGeneratedKey() + { + if (UnsignedExtensionOutputs is null + || !UnsignedExtensionOutputs.TryGetValue(Extensions.PreviewSign, out ReadOnlyMemory value)) + { + return null; + } + + return PreviewSignExtension.DecodeGeneratedKey(value); + } + /// /// Use the zero'th public key in the /// list to verify the diff --git a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/MakeCredentialParameters.cs b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/MakeCredentialParameters.cs index 4dac99393..dd13a464c 100644 --- a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/MakeCredentialParameters.cs +++ b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/MakeCredentialParameters.cs @@ -726,6 +726,64 @@ public void AddCredProtectExtension( AuthenticatorInfo authenticatorInfo) => AddCredProtectExtension(credProtectPolicy, true, authenticatorInfo); + /// + /// Adds the previewSign extension for generating a key that can be used + /// for offline public key derivation and signing. + /// + /// + /// + /// The previewSign extension enables ARKG (Asynchronous Remote Key Generation) + /// functionality, allowing offline derivation of public keys from a generated + /// key material returned by the YubiKey. + /// + /// + /// The caller supplies the for the YubiKey, + /// obtained by calling the or + /// providing the property. + /// This method will verify that the YubiKey supports the previewSign extension. + /// + /// + /// After credential creation, retrieve the generated key material using + /// , then use + /// to derive public + /// keys offline. The YubiKey can sign with any derived key when provided + /// the corresponding ARKG key handle and context via + /// . + /// + /// + /// + /// The FIDO2 for the YubiKey being used. + /// + /// + /// The algorithms to use for key generation, ordered by preference. For + /// ARKG-P256, include . + /// + /// + /// If true, requires user verification; otherwise, only user presence is required. + /// + /// + /// The or is null. + /// + /// + /// The YubiKey does not support the previewSign extension. + /// + public void AddPreviewSignGenerateKeyExtension( + AuthenticatorInfo authenticatorInfo, + Cose.CoseAlgorithmIdentifier[] algorithms, + bool requireUv = false) + { + Guard.IsNotNull(authenticatorInfo, nameof(authenticatorInfo)); + Guard.IsNotNull(algorithms, nameof(algorithms)); + + if (!authenticatorInfo.IsExtensionSupported(Fido2.Extensions.PreviewSign)) + { + throw new NotSupportedException(ExceptionMessages.NotSupportedByYubiKeyVersion); + } + + byte[] encoded = PreviewSignExtension.EncodeGenerateKeyInput(algorithms, requireUv); + AddExtension(Fido2.Extensions.PreviewSign, encoded); + } + /// public override byte[] CborEncode() => new CborMapWriter() diff --git a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/PreviewSignDerivedKey.cs b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/PreviewSignDerivedKey.cs new file mode 100644 index 000000000..fb08cf3a3 --- /dev/null +++ b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/PreviewSignDerivedKey.cs @@ -0,0 +1,133 @@ +// Copyright 2025 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 Yubico.YubiKey.Cryptography; + +namespace Yubico.YubiKey.Fido2 +{ + /// + /// Represents a derived public key produced by ARKG-P256 derivation. + /// + /// + /// + /// This class contains the derived public key and handles needed for + /// authentication via the previewSign extension. Instances are obtained + /// by calling with + /// application-provided input keying material and a context string. + /// + /// + /// The derived public key can be used to verify signatures produced by the + /// YubiKey when signing with the corresponding ARKG key handle and context. + /// Use to validate signatures against this key. + /// + /// + /// To request a signature from the YubiKey using this derived key, pass this + /// object to . + /// + /// + public sealed class PreviewSignDerivedKey + { + /// + /// Gets the derived public key. + /// + public ReadOnlyMemory PublicKey { get; init; } + + /// + /// Gets the ARKG key handle. + /// + public ReadOnlyMemory ArkgKeyHandle { get; init; } + + /// + /// Gets the device key handle from the original registration. + /// + public ReadOnlyMemory DeviceKeyHandle { get; init; } + + /// + /// Gets the context string used for derivation. + /// + public ReadOnlyMemory Context { get; init; } + + /// + /// Initializes a new instance of the class. + /// + /// The derived public key. + /// The ARKG key handle. + /// The device key handle. + /// The context string. + internal PreviewSignDerivedKey( + ReadOnlyMemory publicKey, + ReadOnlyMemory arkgKeyHandle, + ReadOnlyMemory deviceKeyHandle, + ReadOnlyMemory context) + { + PublicKey = publicKey; + ArkgKeyHandle = arkgKeyHandle; + DeviceKeyHandle = deviceKeyHandle; + Context = context; + } + + /// + /// Verifies a signature against the derived public key. + /// + /// + /// + /// This method verifies that a signature produced by the YubiKey (obtained via + /// ) is valid for the + /// given message using the derived public key from ARKG-P256 derivation. + /// + /// + /// The signature must be in DER-encoded ECDSA format, as returned by the + /// YubiKey's previewSign extension. The message is the raw data that was + /// signed, not a hash. + /// + /// + /// + /// The message that was signed. This method will hash the message internally + /// before verifying the signature. + /// + /// + /// The DER-encoded ECDSA signature to verify, as returned by + /// . + /// + /// + /// true if the signature is valid for the message using the derived + /// public key; otherwise, false. + /// + /// + /// The or is null. + /// + public bool VerifySignature(byte[] message, byte[] signature) + { + if (message is null) + { + throw new ArgumentNullException(nameof(message)); + } + + if (signature is null) + { + throw new ArgumentNullException(nameof(signature)); + } + + // PublicKey is SEC1 uncompressed: 0x04 || X(32) || Y(32). + if (PublicKey.Length != 65 || PublicKey.Span[0] != 0x04) + { + return false; + } + + var verifier = new EcdsaVerify(PublicKey); + return verifier.VerifyData(message, signature, isStandardSignature: true); + } + } +} diff --git a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/PreviewSignExtension.cs b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/PreviewSignExtension.cs new file mode 100644 index 000000000..25ce2683b --- /dev/null +++ b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/PreviewSignExtension.cs @@ -0,0 +1,428 @@ +// Copyright 2025 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.Collections.Generic; +using System.Formats.Cbor; +using Yubico.YubiKey.Fido2.Cbor; +using Yubico.YubiKey.Fido2.Cose; + +namespace Yubico.YubiKey.Fido2 +{ + /// + /// CBOR encoder/decoder for the "previewSign" WebAuthn extension. + /// + /// + /// Wire format follows yubikit-swift release/1.3.0. The same integer key + /// can mean different things depending on whether it appears in a + /// MakeCredential extension input or a GetAssertion extension input — + /// per-context enums (, + /// ) keep the call sites readable. + /// + internal static class PreviewSignExtension + { + /// CTAP key on the MakeCredential RESPONSE map carrying unsigned extension outputs. + internal const int CtapUnsignedExtensionOutputsKey = 6; + + /// Flag bits encoded in the MakeCredential previewSign input (key 4). + internal const int FlagsRequireUserPresence = 0b001; + internal const int FlagsRequireUserVerification = 0b101; + + /// Map keys used by the MakeCredential previewSign input AND signed output. + internal enum MakeCredentialKey + { + Algorithm = 3, + Flags = 4, + AttestationObject = 7, + } + + /// Map keys used by the GetAssertion previewSign input and output. + internal enum GetAssertionKey + { + KeyHandle = 2, + TbsOrSignature = 6, + AdditionalArgs = 7, + } + + /// + /// Encode the MakeCredential extension input map: {3:[algs], 4:flags}. + /// + public static byte[] EncodeGenerateKeyInput( + ReadOnlySpan algorithms, + bool requireUv) + { + int flags = requireUv ? FlagsRequireUserVerification : FlagsRequireUserPresence; + + var cbor = new CborWriter(CborConformanceMode.Ctap2Canonical, convertIndefiniteLengthEncodings: true); + cbor.WriteStartMap(2); + + cbor.WriteInt32((int)MakeCredentialKey.Algorithm); + cbor.WriteStartArray(algorithms.Length); + for (int i = 0; i < algorithms.Length; i++) + { + cbor.WriteInt32((int)algorithms[i]); + } + + cbor.WriteEndArray(); + + cbor.WriteInt32((int)MakeCredentialKey.Flags); + cbor.WriteInt32(flags); + + cbor.WriteEndMap(); + return cbor.Encode(); + } + + /// + /// Encode the GetAssertion extension input as a flat map. + /// {2:keyHandle, 6:tbs, 7:additionalArgs?}. + /// + public static byte[] EncodeSignByCredentialInput( + ReadOnlyMemory keyHandle, + ReadOnlyMemory toBeSigned, + ReadOnlyMemory? additionalArgs) + { + int entries = additionalArgs.HasValue ? 3 : 2; + + var cbor = new CborWriter(CborConformanceMode.Ctap2Canonical, convertIndefiniteLengthEncodings: true); + cbor.WriteStartMap(entries); + + cbor.WriteInt32((int)GetAssertionKey.KeyHandle); + cbor.WriteByteString(keyHandle.Span); + + cbor.WriteInt32((int)GetAssertionKey.TbsOrSignature); + cbor.WriteByteString(toBeSigned.Span); + + if (additionalArgs.HasValue) + { + cbor.WriteInt32((int)GetAssertionKey.AdditionalArgs); + cbor.WriteByteString(additionalArgs.Value.Span); + } + + cbor.WriteEndMap(); + return cbor.Encode(); + } + + /// + /// Parse the unsigned extension outputs map from the MakeCredential + /// response (CTAP key 6). Returns name → encoded-value pairs. + /// + public static IReadOnlyDictionary> ParseUnsignedExtensionOutputs( + ReadOnlyMemory encodedMap) + { + var result = new Dictionary>(StringComparer.Ordinal); + var reader = new CborReader(encodedMap, CborConformanceMode.Ctap2Canonical); + int? entries = reader.ReadStartMap(); + int count = entries ?? int.MaxValue; + for (int i = 0; i < count; i++) + { + if (reader.PeekState() == CborReaderState.EndMap) + { + break; + } + + string name = reader.ReadTextString(); + byte[] value = reader.ReadEncodedValue().ToArray(); + result[name] = value; + } + + reader.ReadEndMap(); + return result; + } + + /// + /// Decode the previewSign generated-key payload from an unsigned-extension + /// output value. Per yubikit-swift, the outer map is + /// { 3: alg, 7: nested_attestation_object_bytes } and the nested + /// attestation object is { 1: fmt, 2: authData, 3: attStmt }. + /// We extract the algorithm and key handle from the outer map plus the + /// inner authData, and the COSE-encoded blinding/KEM public keys from + /// the inner authData's credentialPublicKey field. + /// + public static PreviewSignGeneratedKey? DecodeGeneratedKey(ReadOnlyMemory previewSignValue) + { + var reader = new CborReader(previewSignValue, CborConformanceMode.Ctap2Canonical); + if (reader.PeekState() != CborReaderState.StartMap) + { + return null; + } + + int? entries = reader.ReadStartMap(); + int count = entries ?? int.MaxValue; + + CoseAlgorithmIdentifier alg = CoseAlgorithmIdentifier.None; + byte[]? attestationObject = null; + + for (int i = 0; i < count; i++) + { + if (reader.PeekState() == CborReaderState.EndMap) + { + break; + } + + int key = (int)reader.ReadInt64(); + if (key == (int)MakeCredentialKey.Algorithm) + { + alg = (CoseAlgorithmIdentifier)reader.ReadInt32(); + } + else if (key == (int)MakeCredentialKey.AttestationObject) + { + attestationObject = reader.ReadByteString(); + } + else + { + reader.SkipValue(); + } + } + + reader.ReadEndMap(); + + if (attestationObject is null) + { + return null; + } + + (byte[] keyHandle, byte[] pkBl, byte[] pkKem) = ParseInnerAttestationObject(attestationObject); + return new PreviewSignGeneratedKey( + keyHandle, + pkBl, + pkKem, + alg); + } + + /// + /// Parse the signed previewSign extension output produced by the + /// authenticator after a GetAssertion (key 6 inside + /// authData.extensions["previewSign"]). The value is a CBOR map + /// containing a single byte-string entry whose value is the DER-encoded + /// ECDSA signature. + /// + public static byte[]? DecodeSignature(ReadOnlyMemory previewSignAuthDataValue) + { + var reader = new CborReader(previewSignAuthDataValue, CborConformanceMode.Ctap2Canonical); + if (reader.PeekState() != CborReaderState.StartMap) + { + return null; + } + + int? entries = reader.ReadStartMap(); + int count = entries ?? int.MaxValue; + + byte[]? signature = null; + for (int i = 0; i < count; i++) + { + if (reader.PeekState() == CborReaderState.EndMap) + { + break; + } + + int key = (int)reader.ReadInt64(); + if (key == (int)GetAssertionKey.TbsOrSignature) + { + signature = reader.ReadByteString(); + } + else + { + reader.SkipValue(); + } + } + + reader.ReadEndMap(); + return signature; + } + + /// + /// Inner attestation object decoder. Extracts the credential ID + /// (key handle) from authData and the ARKG public seed (pkBl, pkKem) + /// from the credentialPublicKey COSE map. + /// + private static (byte[] keyHandle, byte[] pkBl, byte[] pkKem) ParseInnerAttestationObject(byte[] encoded) + { + var reader = new CborReader(encoded, CborConformanceMode.Ctap2Canonical); + int? entries = reader.ReadStartMap(); + int count = entries ?? int.MaxValue; + + byte[]? authData = null; + for (int i = 0; i < count; i++) + { + if (reader.PeekState() == CborReaderState.EndMap) + { + break; + } + + int key = (int)reader.ReadInt64(); + if (key == 2) + { + authData = reader.ReadByteString(); + } + else + { + reader.SkipValue(); + } + } + + reader.ReadEndMap(); + + if (authData is null) + { + throw new System.Security.Cryptography.CryptographicException( + "previewSign attestation object missing authData (key 2)."); + } + + return ParseAuthDataForArkgSeed(authData); + } + + /// + /// Parse a CTAP authenticator-data buffer and return (credentialId, + /// pkBl, pkKem) extracted from the attested credential data + COSE key. + /// + private static (byte[] keyHandle, byte[] pkBl, byte[] pkKem) ParseAuthDataForArkgSeed(byte[] authData) + { + // CTAP authenticator-data layout: + // [0..32) rpIdHash + // [32] flags + // [33..37) signCount (big-endian uint32) + // [37..53) AAGUID (only present when AT flag is set) + // [53..55) credentialIdLength (big-endian uint16) + // [55..55+L) credentialId + // [55+L..) credentialPublicKey (CBOR) + // ... + const int FixedHeaderLength = 37; + const int AaguidLength = 16; + const byte AttestedCredentialDataFlag = 0x40; + + if (authData.Length < FixedHeaderLength + AaguidLength + 2) + { + throw new System.Security.Cryptography.CryptographicException( + "previewSign authData too short for attested credential data."); + } + + byte flags = authData[32]; + if ((flags & AttestedCredentialDataFlag) == 0) + { + throw new System.Security.Cryptography.CryptographicException( + "previewSign authData missing AT flag."); + } + + int credIdLengthOffset = FixedHeaderLength + AaguidLength; + int credIdLength = (authData[credIdLengthOffset] << 8) | authData[credIdLengthOffset + 1]; + int credIdOffset = credIdLengthOffset + 2; + if (authData.Length < credIdOffset + credIdLength) + { + throw new System.Security.Cryptography.CryptographicException( + "previewSign authData truncated in credentialId."); + } + + byte[] credentialId = new byte[credIdLength]; + Buffer.BlockCopy(authData, credIdOffset, credentialId, 0, credIdLength); + + int coseOffset = credIdOffset + credIdLength; + byte[] coseSlice = new byte[authData.Length - coseOffset]; + Buffer.BlockCopy(authData, coseOffset, coseSlice, 0, coseSlice.Length); + + (byte[] pkBl, byte[] pkKem) = ParseArkgCoseKey(coseSlice); + return (credentialId, pkBl, pkKem); + } + + /// + /// Parse the ARKG-P256 COSE key: + /// { 1: kty, 3: alg, -1: pkBl_cose_ec2, -2: pkKem_cose_ec2 } + /// where each EC2 sub-map is {1:2, 3:-9, -1:1, -2:x, -3:y}. + /// + private static (byte[] pkBl, byte[] pkKem) ParseArkgCoseKey(byte[] coseEncoded) + { + var reader = new CborReader(coseEncoded, CborConformanceMode.Ctap2Canonical); + int? entries = reader.ReadStartMap(); + int count = entries ?? int.MaxValue; + + byte[]? pkBl = null; + byte[]? pkKem = null; + + for (int i = 0; i < count; i++) + { + if (reader.PeekState() == CborReaderState.EndMap) + { + break; + } + + long key = reader.ReadInt64(); + if (key == -1) + { + pkBl = ReadEc2PointAsSec1(reader); + } + else if (key == -2) + { + pkKem = ReadEc2PointAsSec1(reader); + } + else + { + reader.SkipValue(); + } + } + + reader.ReadEndMap(); + + if (pkBl is null || pkKem is null) + { + throw new System.Security.Cryptography.CryptographicException( + "previewSign COSE key missing pkBl (-1) or pkKem (-2)."); + } + + return (pkBl, pkKem); + } + + private static byte[] ReadEc2PointAsSec1(CborReader reader) + { + int? subEntries = reader.ReadStartMap(); + int subCount = subEntries ?? int.MaxValue; + + byte[]? x = null; + byte[]? y = null; + for (int j = 0; j < subCount; j++) + { + if (reader.PeekState() == CborReaderState.EndMap) + { + break; + } + + long subKey = reader.ReadInt64(); + if (subKey == -2) + { + x = reader.ReadByteString(); + } + else if (subKey == -3) + { + y = reader.ReadByteString(); + } + else + { + reader.SkipValue(); + } + } + + reader.ReadEndMap(); + + if (x is null || y is null || x.Length != 32 || y.Length != 32) + { + throw new System.Security.Cryptography.CryptographicException( + "previewSign EC2 point coordinates must be 32 bytes each."); + } + + byte[] sec1 = new byte[65]; + sec1[0] = 0x04; + Buffer.BlockCopy(x, 0, sec1, 1, 32); + Buffer.BlockCopy(y, 0, sec1, 33, 32); + return sec1; + } + } +} diff --git a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/PreviewSignGeneratedKey.cs b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/PreviewSignGeneratedKey.cs new file mode 100644 index 000000000..6ddf57094 --- /dev/null +++ b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/PreviewSignGeneratedKey.cs @@ -0,0 +1,147 @@ +// Copyright 2025 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 Yubico.YubiKey.Fido2.Arkg; +using Yubico.YubiKey.Fido2.Cose; + +namespace Yubico.YubiKey.Fido2 +{ + /// + /// Represents the generated key material returned by the YubiKey during + /// previewSign extension registration. + /// + /// + /// + /// This class contains the key handle and public key components needed to + /// perform offline ARKG (Asynchronous Remote Key Generation) key derivation + /// via . + /// + /// + /// Instances of this class are obtained by calling + /// after creating + /// a credential with the previewSign extension enabled via + /// . + /// + /// + /// The generated key material enables offline derivation of multiple public + /// keys from a single credential, each identified by a unique context string. + /// The YubiKey can sign with any derived key when provided the corresponding + /// ARKG key handle and context. + /// + /// + public sealed class PreviewSignGeneratedKey + { + /// + /// Gets the key handle for the generated credential. + /// + public ReadOnlyMemory KeyHandle { get; init; } + + /// + /// Gets the blinding public key component. + /// + public ReadOnlyMemory BlindingPublicKey { get; init; } + + /// + /// Gets the KEM (Key Encapsulation Mechanism) public key component. + /// + public ReadOnlyMemory KemPublicKey { get; init; } + + /// + /// Gets the algorithm identifier for the derived key. + /// + public CoseAlgorithmIdentifier DerivedKeyAlgorithm { get; init; } + + /// + /// Initializes a new instance of the class. + /// + /// The key handle. + /// The blinding public key. + /// The KEM public key. + /// The derived key algorithm. + internal PreviewSignGeneratedKey( + ReadOnlyMemory keyHandle, + ReadOnlyMemory blindingPublicKey, + ReadOnlyMemory kemPublicKey, + CoseAlgorithmIdentifier derivedKeyAlgorithm) + { + KeyHandle = keyHandle; + BlindingPublicKey = blindingPublicKey; + KemPublicKey = kemPublicKey; + DerivedKeyAlgorithm = derivedKeyAlgorithm; + } + + /// + /// Derives a public key using the ARKG-P256 algorithm. + /// + /// + /// + /// This method performs offline key derivation using the ARKG-P256 algorithm. + /// The derived public key can be used to verify signatures created by the + /// YubiKey when provided with the corresponding ARKG key handle and context. + /// + /// + /// Multiple independent public keys can be derived from the same generated + /// key by using different context strings. Each context produces a unique + /// derived key pair. + /// + /// + /// To use the derived key for signing, pass the returned + /// to + /// . + /// The YubiKey will produce a signature that can be verified using + /// . + /// + /// + /// + /// Input keying material for HKDF derivation. This should be random data + /// unique to the derivation context. + /// + /// + /// Context string for domain separation. Different contexts produce + /// different derived keys from the same input keying material. + /// + /// + /// A containing the derived public key, + /// ARKG key handle, device key handle, and context. + /// + /// + /// The or is null. + /// + public PreviewSignDerivedKey DerivePublicKey(byte[] ikm, byte[] ctx) + { + if (ikm is null) + { + throw new ArgumentNullException(nameof(ikm)); + } + + if (ctx is null) + { + throw new ArgumentNullException(nameof(ctx)); + } + + (byte[] derivedPk, byte[] arkgKeyHandle) = ArkgP256.DerivePublicKey( + BlindingPublicKey.ToArray(), + KemPublicKey.ToArray(), + ikm, + ctx); + + return new PreviewSignDerivedKey( + derivedPk, + arkgKeyHandle, + KeyHandle, + ctx); + } + } +} diff --git a/Yubico.YubiKey/tests/integration/Yubico/YubiKey/Fido2/PreviewSignTests.cs b/Yubico.YubiKey/tests/integration/Yubico/YubiKey/Fido2/PreviewSignTests.cs new file mode 100644 index 000000000..c4a994d07 --- /dev/null +++ b/Yubico.YubiKey/tests/integration/Yubico/YubiKey/Fido2/PreviewSignTests.cs @@ -0,0 +1,129 @@ +// Copyright 2025 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 Xunit; +using Yubico.YubiKey.Fido2.Cose; +using Yubico.YubiKey.TestUtilities; + +namespace Yubico.YubiKey.Fido2 +{ + /// + /// Integration tests for the previewSign extension. + /// + /// + /// These tests require a physical YubiKey with previewSign support (5.8.0-beta or newer). + /// They will be skipped if no suitable device is found. + /// + public class PreviewSignTests : FidoSessionIntegrationTestBase + { + // Defeat the macOS YubiKeyDeviceListener startup race before the + // base class's instance ctor runs. See DeviceListenerCacheWarmup + // for the full rationale. + static PreviewSignTests() => DeviceListenerCacheWarmup.WaitForFirstDevice(); + + [SkippableFact(typeof(DeviceNotFoundException))] + public void MakeCredentialWithPreviewSign_ReturnsGeneratedKey() + { + Skip.IfNot( + Session.AuthenticatorInfo.IsExtensionSupported(Extensions.PreviewSign), + "YubiKey does not advertise previewSign extension"); + + MakeCredentialParameters.AddPreviewSignGenerateKeyExtension( + Session.AuthenticatorInfo, + new[] { CoseAlgorithmIdentifier.ArkgP256Esp256 }); + + var credData = Session.MakeCredential(MakeCredentialParameters); + var isValid = credData.VerifyAttestation(MakeCredentialParameters.ClientDataHash); + Assert.True(isValid); + + var generatedKey = credData.GetPreviewSignGeneratedKey(); + Assert.NotNull(generatedKey); + Assert.NotEmpty(generatedKey.KeyHandle.Span.ToArray()); + Assert.Equal(65, generatedKey.BlindingPublicKey.Length); + Assert.Equal(0x04, generatedKey.BlindingPublicKey.Span[0]); + Assert.Equal(65, generatedKey.KemPublicKey.Length); + Assert.Equal(0x04, generatedKey.KemPublicKey.Span[0]); + } + + [SkippableFact(typeof(DeviceNotFoundException))] + public void FullCeremony_RegisterDeriveSignVerify_RoundTrip() + { + Skip.IfNot( + Session.AuthenticatorInfo.IsExtensionSupported(Extensions.PreviewSign), + "YubiKey does not advertise previewSign extension"); + + // Step A: Register with previewSign (requires user presence - touch #1) + MakeCredentialParameters.AddPreviewSignGenerateKeyExtension( + Session.AuthenticatorInfo, + new[] { CoseAlgorithmIdentifier.ArkgP256Esp256 }); + + var credData = Session.MakeCredential(MakeCredentialParameters); + var generatedKey = credData.GetPreviewSignGeneratedKey(); + Assert.NotNull(generatedKey); + + // Step B: Offline derive public key + byte[] ikm = new byte[32]; + new Random(42).NextBytes(ikm); + byte[] ctx = System.Text.Encoding.ASCII.GetBytes("integration-test-ctx"); + + var derived = generatedKey.DerivePublicKey(ikm, ctx); + Assert.Equal(65, derived.PublicKey.Length); + Assert.NotEmpty(derived.ArkgKeyHandle.Span.ToArray()); + + // Step C: Sign with derived credential (requires user presence - touch #2) + byte[] message = System.Text.Encoding.ASCII.GetBytes("hello-previewsign-integration-test"); + + // sign-by-credential requires an allowList so the YubiKey knows + // which credential to use; the firmware rejects the GetAssertion + // at protocol level with "option or extension invalid" if it is + // missing. Mirror what the upstream demo does: pass the FIDO2 + // credential ID returned from MakeCredential. + byte[] credentialId = credData.AuthenticatorData.CredentialId!.Id.ToArray(); + GetAssertionParameters.AllowCredential(new CredentialId { Id = credentialId }); + + GetAssertionParameters.AddPreviewSignByCredentialExtension( + Session.AuthenticatorInfo, + derived, + message); + + var assertions = Session.GetAssertions(GetAssertionParameters); + var signature = assertions[0].AuthenticatorData.GetPreviewSignSignature(); + Assert.NotNull(signature); + + // Step D: Offline verify signature + bool verified = derived.VerifySignature(message, signature); + Assert.True(verified); + } + + [SkippableFact(typeof(DeviceNotFoundException))] + public void MakeCredentialWithUnsupportedAlgorithm_Fails() + { + Skip.IfNot( + Session.AuthenticatorInfo.IsExtensionSupported(Extensions.PreviewSign), + "YubiKey does not advertise previewSign extension"); + + // Request previewSign with ES256 (not Esp256) + // Hardware should reject this as unsupported for previewSign + MakeCredentialParameters.AddPreviewSignGenerateKeyExtension( + Session.AuthenticatorInfo, + new[] { CoseAlgorithmIdentifier.ES256 }); + + // YubiKey 5.8.0-beta rejects unsupported algorithms via + // Fido2Exception (wrapping the underlying CTAP error code). + Assert.Throws(() => Session.MakeCredential(MakeCredentialParameters)); + } + } +} diff --git a/Yubico.YubiKey/tests/unit/Yubico/YubiKey/Fido2/Arkg/ArkgP256Tests.cs b/Yubico.YubiKey/tests/unit/Yubico/YubiKey/Fido2/Arkg/ArkgP256Tests.cs new file mode 100644 index 000000000..08c249a1a --- /dev/null +++ b/Yubico.YubiKey/tests/unit/Yubico/YubiKey/Fido2/Arkg/ArkgP256Tests.cs @@ -0,0 +1,106 @@ +// Copyright 2025 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.Text; +using Xunit; + +namespace Yubico.YubiKey.Fido2.Arkg +{ + /// + /// KAT (Known Answer Tests) for ARKG-P256 derivation. + /// Vectors generated from the Rust reference implementation in + /// cnh-authenticator-rs-extension/native/crates/hid-test/src/arkg.rs + /// (see /tmp/gen_kat for the generator program). Vector A also matches the + /// embedded `arkg.rs::tests::test_arkg_derive_key` expected output. + /// + public class ArkgP256Tests + { + // Both vectors share these seeds, derived from ikm_bl=0x00..1F and + // ikm_kem=0x20..3F via the Rust reference's BL/KEM key-generation paths. + private static readonly byte[] PkBl = HexToBytes( + "046d3bdf31d0db48988f16d47048fdd24123cd286e42d0512daa9f726b4ecf18df" + + "65ed42169c69675f936ff7de5f9bd93adbc8ea73036b16e8d90adbfabdaddba7"); + + private static readonly byte[] PkKem = HexToBytes( + "04c38bbdd7286196733fa177e43b73cfd3d6d72cd11cc0bb2c9236cf85a42dcff5" + + "dfa339c1e07dfcdfda8d7be2a5a3c7382991f387dfe332b1dd8da6e0622cfb35"); + + // Vector A — matches arkg.rs::tests::test_arkg_derive_key. + private static readonly byte[] IkmA = HexToBytes( + "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f"); + private static readonly byte[] CtxA = Encoding.ASCII.GetBytes("ARKG-P256.test vectors"); + private static readonly byte[] ExpectedDerivedA = HexToBytes( + "04572a111ce5cfd2a67d56a0f7c684184b16ccd212490dc9c5b579df749647d107" + + "dac2a1b197cc10d2376559ad6df6bc107318d5cfb90def9f4a1f5347e086c2cd"); + private static readonly byte[] ExpectedHandleA = HexToBytes( + "27987995f184a44cfa548d104b0a461d" + // 16-byte HMAC tag + "0487fc739dbcdabc293ac5469221da91b220e04c681074ec4692a76ffacb9043" + + "dec2847ea9060fd42da267f66852e63589f0c00dc88f290d660c65a65a50c86361"); + + // Vector B — different IKM, same ctx as A. + private static readonly byte[] IkmB = HexToBytes( + "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f"); + private static readonly byte[] ExpectedDerivedB = HexToBytes( + "04aed80c70cc9e2fa6b2d22db62285e6e3af7dc7426ce9846a500723d82aa60cd0" + + "98168e98c4f437fc5d45986afaed5d5ce6e39de46fe4f61ae88541cb37687f8d"); + + // Vector C — same IKM as A, different ctx. + private static readonly byte[] CtxC = Encoding.ASCII.GetBytes("ARKG-P256.alt context"); + private static readonly byte[] ExpectedDerivedC = HexToBytes( + "04ccfc29c2d0f438642dae5153ccb4eda6be6ec8a0e654a009f2953ab4b52dc1eb" + + "3ffbbf91b3e46e8e68a3c38c7268b2ca42f6d19c44dd5ee15fa0d30e0c9eb326"); + + [Fact] + public void DerivePublicKey_AgainstRustKAT_ProducesExpectedPublicKey() + { + (byte[] derivedPk, byte[] arkgKeyHandle) = ArkgP256.DerivePublicKey(PkBl, PkKem, IkmA, CtxA); + + Assert.Equal(ExpectedDerivedA, derivedPk); + Assert.Equal(ExpectedHandleA, arkgKeyHandle); + } + + [Fact] + public void DerivePublicKey_DifferentIkm_ProducesDifferentKeys() + { + (byte[] derivedA, _) = ArkgP256.DerivePublicKey(PkBl, PkKem, IkmA, CtxA); + (byte[] derivedB, _) = ArkgP256.DerivePublicKey(PkBl, PkKem, IkmB, CtxA); + + Assert.NotEqual(derivedA, derivedB); + Assert.Equal(ExpectedDerivedA, derivedA); + Assert.Equal(ExpectedDerivedB, derivedB); + } + + [Fact] + public void DerivePublicKey_DifferentCtx_ProducesDifferentKeys() + { + (byte[] derivedA, _) = ArkgP256.DerivePublicKey(PkBl, PkKem, IkmA, CtxA); + (byte[] derivedC, _) = ArkgP256.DerivePublicKey(PkBl, PkKem, IkmA, CtxC); + + Assert.NotEqual(derivedA, derivedC); + Assert.Equal(ExpectedDerivedC, derivedC); + } + + private static byte[] HexToBytes(string hex) + { + byte[] bytes = new byte[hex.Length / 2]; + for (int i = 0; i < bytes.Length; i++) + { + bytes[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16); + } + + return bytes; + } + } +} diff --git a/Yubico.YubiKey/tests/unit/Yubico/YubiKey/Fido2/PreviewSignExtensionTests.cs b/Yubico.YubiKey/tests/unit/Yubico/YubiKey/Fido2/PreviewSignExtensionTests.cs new file mode 100644 index 000000000..b9f85a25d --- /dev/null +++ b/Yubico.YubiKey/tests/unit/Yubico/YubiKey/Fido2/PreviewSignExtensionTests.cs @@ -0,0 +1,486 @@ +// Copyright 2025 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.Formats.Cbor; +using System.Linq; +using System.Reflection; +using Xunit; +using Yubico.YubiKey.Fido2.Cose; + +namespace Yubico.YubiKey.Fido2 +{ + public class PreviewSignExtensionTests + { + // ------------------------------------------------------------------ + // CBOR encoder tests for the input maps + // ------------------------------------------------------------------ + + [Fact] + public void EncodeGenerateKeyInput_WritesAlgorithmsAndFlags_AsCanonicalCbor() + { + // Expected CBOR map: {3:[-9], 4:1}. + // Definite-length 2-entry map = 0xA2; key 3 = 0x03; array(1) = 0x81; + // -9 = 0x28; key 4 = 0x04; value 1 = 0x01. + byte[] expected = { 0xA2, 0x03, 0x81, 0x28, 0x04, 0x01 }; + + byte[] actual = PreviewSignExtension.EncodeGenerateKeyInput( + new[] { CoseAlgorithmIdentifier.Esp256 }, + requireUv: false); + + Assert.Equal(expected, actual); + } + + [Fact] + public void EncodeSignByCredentialInput_NoArgs_WritesFlatMap() + { + byte[] keyHandle = { 0xAA, 0xBB }; + byte[] tbs = { 0x11, 0x22, 0x33 }; + + // {2: h'AABB', 6: h'112233'} — definite-length 2-entry map. + byte[] expected = + { + 0xA2, + 0x02, 0x42, 0xAA, 0xBB, + 0x06, 0x43, 0x11, 0x22, 0x33, + }; + + byte[] actual = PreviewSignExtension.EncodeSignByCredentialInput(keyHandle, tbs, additionalArgs: null); + + Assert.Equal(expected, actual); + } + + [Fact] + public void EncodeSignByCredentialInput_WithAdditionalArgs_IncludesKey7() + { + byte[] keyHandle = { 0xAA }; + byte[] tbs = { 0x11 }; + byte[] additionalArgs = { 0xCC, 0xDD }; + + byte[] actual = PreviewSignExtension.EncodeSignByCredentialInput(keyHandle, tbs, additionalArgs); + + // Re-decode and assert structure rather than hand-crafting the + // canonical bytes — the encoder ordering plus map header tag is + // already covered by the no-args case. + var reader = new CborReader(actual, CborConformanceMode.Ctap2Canonical); + Assert.Equal(3, reader.ReadStartMap()); + Assert.Equal(2, reader.ReadInt32()); + Assert.Equal(keyHandle, reader.ReadByteString()); + Assert.Equal(6, reader.ReadInt32()); + Assert.Equal(tbs, reader.ReadByteString()); + Assert.Equal(7, reader.ReadInt32()); + Assert.Equal(additionalArgs, reader.ReadByteString()); + reader.ReadEndMap(); + } + + // ------------------------------------------------------------------ + // CTAP key 6 regression test (the headline of this whole port) + // ------------------------------------------------------------------ + + [Fact] + public void ParseGenerateKeyFromUnsignedExtensions_KeyAt6() + { + byte[] previewSignPayload = BuildSyntheticGeneratedKeyPayload(); + byte[] response = BuildMakeCredentialResponseWithUnsignedExtensions(previewSignPayload); + + var data = new MakeCredentialData(response); + + Assert.NotNull(data.UnsignedExtensionOutputs); + Assert.True(data.UnsignedExtensionOutputs!.ContainsKey(Extensions.PreviewSign)); + + PreviewSignGeneratedKey? generated = data.GetPreviewSignGeneratedKey(); + Assert.NotNull(generated); + Assert.Equal(CoseAlgorithmIdentifier.ArkgP256Esp256, generated!.DerivedKeyAlgorithm); + Assert.Equal(65, generated.BlindingPublicKey.Length); + Assert.Equal(65, generated.KemPublicKey.Length); + } + + [Fact] + public void ParseSignatureFromExtensionOutput_ReturnsByteString() + { + byte[] sigBytes = { 0x30, 0x06, 0x02, 0x01, 0x01, 0x02, 0x01, 0x02 }; + byte[] previewSignAuthDataValue = BuildSignatureMap(sigBytes); + + byte[]? recovered = PreviewSignExtension.DecodeSignature(previewSignAuthDataValue); + + Assert.NotNull(recovered); + Assert.Equal(sigBytes, recovered); + } + + // ------------------------------------------------------------------ + // Flags rule + // ------------------------------------------------------------------ + + [Fact] + public void Flags_DerivedFromRequireUv_TrueProduces0b101() + { + byte[] encoded = PreviewSignExtension.EncodeGenerateKeyInput( + new[] { CoseAlgorithmIdentifier.Esp256 }, + requireUv: true); + + int flags = ReadFlagsFromGenerateKeyInput(encoded); + Assert.Equal(0b101, flags); + } + + [Fact] + public void Flags_DerivedFromRequireUv_FalseProduces0b001() + { + byte[] encoded = PreviewSignExtension.EncodeGenerateKeyInput( + new[] { CoseAlgorithmIdentifier.Esp256 }, + requireUv: false); + + int flags = ReadFlagsFromGenerateKeyInput(encoded); + Assert.Equal(0b001, flags); + } + + // ------------------------------------------------------------------ + // IsExtensionSupported gates (regressions) + // ------------------------------------------------------------------ + + [Fact] + public void AddPreviewSignGenerateKey_ThrowsWhenExtensionUnsupported() + { + var info = BuildAuthenticatorInfoWithoutPreviewSign(); + var parameters = new MakeCredentialParameters( + new RelyingParty("rp.example"), + new UserEntity(new byte[] { 0x01 }) { Name = "u" }); + + _ = Assert.Throws( + () => parameters.AddPreviewSignGenerateKeyExtension( + info, + new[] { CoseAlgorithmIdentifier.Esp256 })); + } + + [Fact] + public void AddPreviewSignByCredential_ThrowsWhenExtensionUnsupported() + { + var info = BuildAuthenticatorInfoWithoutPreviewSign(); + var parameters = new GetAssertionParameters( + new RelyingParty("rp.example"), + new byte[32]); + + PreviewSignDerivedKey derivedKey = BuildDerivedKeyFixture(); + + _ = Assert.Throws( + () => parameters.AddPreviewSignByCredentialExtension( + info, + derivedKey, + new byte[] { 0xAA })); + } + + [Fact] + public void AddPreviewSignByCredential_ThrowsWhenAllowListEmpty() + { + var info = BuildAuthenticatorInfoWithPreviewSign(); + var parameters = new GetAssertionParameters( + new RelyingParty("rp.example"), + new byte[32]); + + PreviewSignDerivedKey derivedKey = BuildDerivedKeyFixture(); + + var ex = Assert.Throws( + () => parameters.AddPreviewSignByCredentialExtension( + info, + derivedKey, + new byte[] { 0xAA })); + + Assert.Contains("AllowCredential", ex.Message); + } + + // ================================================================== + // Helpers + // ================================================================== + + private static int ReadFlagsFromGenerateKeyInput(byte[] encoded) + { + var reader = new CborReader(encoded, CborConformanceMode.Ctap2Canonical); + int? entries = reader.ReadStartMap(); + int count = entries ?? int.MaxValue; + int flags = -1; + for (int i = 0; i < count; i++) + { + if (reader.PeekState() == CborReaderState.EndMap) + { + break; + } + + int key = reader.ReadInt32(); + if (key == 4) + { + flags = reader.ReadInt32(); + } + else + { + reader.SkipValue(); + } + } + + reader.ReadEndMap(); + return flags; + } + + private static byte[] BuildSignatureMap(byte[] sig) + { + var cbor = new CborWriter(CborConformanceMode.Ctap2Canonical, convertIndefiniteLengthEncodings: true); + cbor.WriteStartMap(1); + cbor.WriteInt32(6); + cbor.WriteByteString(sig); + cbor.WriteEndMap(); + return cbor.Encode(); + } + + // Builds a minimal-but-valid previewSign generated-key payload: + // { 3: ArkgP256Esp256, 7: } + private static byte[] BuildSyntheticGeneratedKeyPayload() + { + byte[] innerAttestation = BuildInnerAttestationObject(); + + var cbor = new CborWriter(CborConformanceMode.Ctap2Canonical, convertIndefiniteLengthEncodings: true); + cbor.WriteStartMap(2); + cbor.WriteInt32(3); + cbor.WriteInt32((int)CoseAlgorithmIdentifier.ArkgP256Esp256); + cbor.WriteInt32(7); + cbor.WriteByteString(innerAttestation); + cbor.WriteEndMap(); + return cbor.Encode(); + } + + private static byte[] BuildInnerAttestationObject() + { + byte[] authData = BuildSyntheticAuthDataWithArkgCoseKey(); + var cbor = new CborWriter(CborConformanceMode.Ctap2Canonical, convertIndefiniteLengthEncodings: true); + cbor.WriteStartMap(2); + cbor.WriteInt32(1); + cbor.WriteTextString("packed"); + cbor.WriteInt32(2); + cbor.WriteByteString(authData); + cbor.WriteEndMap(); + return cbor.Encode(); + } + + // Build authenticator-data: 32-byte rpIdHash || flags=0x40 || signCount(0) + // || AAGUID(16) || credIdLen(2) || credId(L) || COSE-encoded ARKG seed. + private static byte[] BuildSyntheticAuthDataWithArkgCoseKey() + { + byte[] rpIdHash = new byte[32]; + byte flags = 0x40; // AT bit + byte[] signCount = { 0, 0, 0, 0 }; + byte[] aaguid = new byte[16]; + byte[] credId = { 0xDE, 0xAD, 0xBE, 0xEF }; + byte[] cose = BuildArkgCoseKey(); + + int credIdLen = credId.Length; + byte[] result = new byte[32 + 1 + 4 + 16 + 2 + credIdLen + cose.Length]; + int offset = 0; + Array.Copy(rpIdHash, 0, result, offset, 32); + offset += 32; + result[offset++] = flags; + Array.Copy(signCount, 0, result, offset, 4); + offset += 4; + Array.Copy(aaguid, 0, result, offset, 16); + offset += 16; + result[offset++] = (byte)((credIdLen >> 8) & 0xFF); + result[offset++] = (byte)(credIdLen & 0xFF); + Array.Copy(credId, 0, result, offset, credIdLen); + offset += credIdLen; + Array.Copy(cose, 0, result, offset, cose.Length); + return result; + } + + // ARKG-P256 COSE key: { -1: pkBl_ec2, -2: pkKem_ec2 } + private static byte[] BuildArkgCoseKey() + { + byte[] x = Enumerable.Repeat((byte)0xAB, 32).ToArray(); + byte[] y = Enumerable.Repeat((byte)0xCD, 32).ToArray(); + + var cbor = new CborWriter(CborConformanceMode.Ctap2Canonical, convertIndefiniteLengthEncodings: true); + cbor.WriteStartMap(2); + + cbor.WriteInt32(-1); + WriteEc2Submap(cbor, x, y); + + cbor.WriteInt32(-2); + WriteEc2Submap(cbor, x, y); + + cbor.WriteEndMap(); + return cbor.Encode(); + } + + private static void WriteEc2Submap(CborWriter cbor, byte[] x, byte[] y) + { + cbor.WriteStartMap(2); + cbor.WriteInt32(-2); + cbor.WriteByteString(x); + cbor.WriteInt32(-3); + cbor.WriteByteString(y); + cbor.WriteEndMap(); + } + + // Build a synthetic MakeCredential response containing CTAP key 6 + // (UnsignedExtensionOutputs) with the previewSign payload. + private static byte[] BuildMakeCredentialResponseWithUnsignedExtensions(byte[] previewSignPayload) + { + byte[] hostAuthData = BuildAuthDataWithEs256CredentialPublicKey(); + + var cbor = new CborWriter(CborConformanceMode.Ctap2Canonical, convertIndefiniteLengthEncodings: true); + cbor.WriteStartMap(4); + + cbor.WriteInt32(1); + cbor.WriteTextString("packed"); + + cbor.WriteInt32(2); + cbor.WriteByteString(hostAuthData); + + cbor.WriteInt32(3); + cbor.WriteStartMap(2); + cbor.WriteTextString("alg"); + cbor.WriteInt32(-7); + cbor.WriteTextString("sig"); + cbor.WriteByteString(new byte[] { 0x01 }); + cbor.WriteEndMap(); + + cbor.WriteInt32(6); + cbor.WriteStartMap(1); + cbor.WriteTextString(Extensions.PreviewSign); + cbor.WriteEncodedValue(previewSignPayload); + cbor.WriteEndMap(); + + cbor.WriteEndMap(); + return cbor.Encode(); + } + + // The host AuthenticatorData ctor requires an Ec2 COSE credential public + // key (any key works — we just need MakeCredentialData to construct). + private static byte[] BuildAuthDataWithEs256CredentialPublicKey() + { + byte[] x = Enumerable.Repeat((byte)0x11, 32).ToArray(); + byte[] y = Enumerable.Repeat((byte)0x22, 32).ToArray(); + + var coseKey = new CborWriter(CborConformanceMode.Ctap2Canonical, convertIndefiniteLengthEncodings: true); + coseKey.WriteStartMap(5); + coseKey.WriteInt32(1); // kty + coseKey.WriteInt32(2); // EC2 + coseKey.WriteInt32(3); // alg + coseKey.WriteInt32(-7); // ES256 + coseKey.WriteInt32(-1); // crv + coseKey.WriteInt32(1); // P-256 + coseKey.WriteInt32(-2); // x + coseKey.WriteByteString(x); + coseKey.WriteInt32(-3); // y + coseKey.WriteByteString(y); + coseKey.WriteEndMap(); + byte[] coseEncoded = coseKey.Encode(); + + byte[] credId = { 0xCA, 0xFE }; + byte[] result = new byte[32 + 1 + 4 + 16 + 2 + credId.Length + coseEncoded.Length]; + int offset = 0; + offset += 32; // rpIdHash zeros + result[offset++] = 0x40; // AT flag + offset += 4; // signCount zeros + offset += 16; // AAGUID zeros + result[offset++] = 0x00; + result[offset++] = (byte)credId.Length; + Array.Copy(credId, 0, result, offset, credId.Length); + offset += credId.Length; + Array.Copy(coseEncoded, 0, result, offset, coseEncoded.Length); + return result; + } + + // Builds a synthetic AuthenticatorInfo with NO previewSign in its + // Extensions list. + private static AuthenticatorInfo BuildAuthenticatorInfoWithoutPreviewSign() + { + byte[] aaguid = new byte[16]; + var cbor = new CborWriter(CborConformanceMode.Ctap2Canonical, convertIndefiniteLengthEncodings: true); + cbor.WriteStartMap(3); + + cbor.WriteInt32(1); + cbor.WriteStartArray(1); + cbor.WriteTextString("FIDO_2_1"); + cbor.WriteEndArray(); + + cbor.WriteInt32(2); + cbor.WriteStartArray(1); + cbor.WriteTextString("credBlob"); // anything BUT previewSign + cbor.WriteEndArray(); + + cbor.WriteInt32(3); + cbor.WriteByteString(aaguid); + + cbor.WriteEndMap(); + return new AuthenticatorInfo(cbor.Encode()); + } + + // Builds a synthetic AuthenticatorInfo WITH previewSign in its + // Extensions list. + private static AuthenticatorInfo BuildAuthenticatorInfoWithPreviewSign() + { + byte[] aaguid = new byte[16]; + var cbor = new CborWriter(CborConformanceMode.Ctap2Canonical, convertIndefiniteLengthEncodings: true); + cbor.WriteStartMap(3); + + cbor.WriteInt32(1); + cbor.WriteStartArray(1); + cbor.WriteTextString("FIDO_2_1"); + cbor.WriteEndArray(); + + cbor.WriteInt32(2); + cbor.WriteStartArray(1); + cbor.WriteTextString(Extensions.PreviewSign); + cbor.WriteEndArray(); + + cbor.WriteInt32(3); + cbor.WriteByteString(aaguid); + + cbor.WriteEndMap(); + return new AuthenticatorInfo(cbor.Encode()); + } + + private static byte[] BuildSec1Generator() + { + // Fixed P-256 generator — valid SEC1 point used as a placeholder + // for tests that only need a syntactically valid PublicKey. + return new byte[] + { + 0x04, + 0x6B, 0x17, 0xD1, 0xF2, 0xE1, 0x2C, 0x42, 0x47, + 0xF8, 0xBC, 0xE6, 0xE5, 0x63, 0xA4, 0x40, 0xF2, + 0x77, 0x03, 0x7D, 0x81, 0x2D, 0xEB, 0x33, 0xA0, + 0xF4, 0xA1, 0x39, 0x45, 0xD8, 0x98, 0xC2, 0x96, + 0x4F, 0xE3, 0x42, 0xE2, 0xFE, 0x1A, 0x7F, 0x9B, + 0x8E, 0xE7, 0xEB, 0x4A, 0x7C, 0x0F, 0x9E, 0x16, + 0x2B, 0xCE, 0x33, 0x57, 0x6B, 0x31, 0x5E, 0xCE, + 0xCB, 0xB6, 0x40, 0x68, 0x37, 0xBF, 0x51, 0xF5, + }; + } + + // PreviewSignDerivedKey has an internal ctor — invoke via reflection + // so the test can build a fixture without running the full ARKG path. + private static PreviewSignDerivedKey BuildDerivedKeyFixture() + { + return (PreviewSignDerivedKey)Activator.CreateInstance( + typeof(PreviewSignDerivedKey), + BindingFlags.Instance | BindingFlags.NonPublic, + binder: null, + args: new object[] + { + (ReadOnlyMemory)BuildSec1Generator(), + (ReadOnlyMemory)new byte[] { 0x01 }, + (ReadOnlyMemory)new byte[] { 0x02 }, + (ReadOnlyMemory)new byte[] { 0x03 }, + }, + culture: null)!; + } + } +} diff --git a/Yubico.YubiKey/tests/utilities/Yubico/YubiKey/TestUtilities/DeviceListenerCacheWarmup.cs b/Yubico.YubiKey/tests/utilities/Yubico/YubiKey/TestUtilities/DeviceListenerCacheWarmup.cs new file mode 100644 index 000000000..b4723a91a --- /dev/null +++ b/Yubico.YubiKey/tests/utilities/Yubico/YubiKey/TestUtilities/DeviceListenerCacheWarmup.cs @@ -0,0 +1,72 @@ +// Copyright 2025 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.Linq; +using System.Threading; + +namespace Yubico.YubiKey.TestUtilities +{ + /// + /// Workaround for a YubiKeyDeviceListener startup race observed on macOS: + /// MacOSHidDeviceListener arms its IOKit run loop on a background thread, + /// so the first call to YubiKeyDeviceListener.Update() (which runs + /// synchronously in the listener's constructor) can complete before the + /// HID layer has fired its initial arrival callbacks. The result is a + /// transient empty cache for ~hundreds of milliseconds after the listener + /// is created, which makes test base classes that enumerate in their + /// instance constructor see DeviceNotFoundException even when a device is + /// plugged in. + /// + /// Intended use: invoke from a test class's static constructor (runs once, + /// before any [Fact] body) so the listener has time to populate before + /// instance construction kicks off. + /// + /// SDK fix tracked separately. Until then, every Fido2/Hid integration + /// test class that doesn't already do its own warm-up should call this. + /// + public static class DeviceListenerCacheWarmup + { + /// + /// Poll YubiKeyDevice.FindAll() until it returns at least one device, + /// or until the timeout elapses. Returns silently in either case; + /// downstream tests are expected to SKIP cleanly via + /// DeviceNotFoundException if no device materialises. + /// + /// Total wall-clock budget. Default 5s. + /// Poll interval. Default 100ms. + public static void WaitForFirstDevice( + int timeoutMilliseconds = 5000, + int pollIntervalMilliseconds = 100) + { + try + { + int iterations = timeoutMilliseconds / pollIntervalMilliseconds; + for (int i = 0; i < iterations; i++) + { + if (YubiKeyDevice.FindAll().Any()) + { + return; + } + + Thread.Sleep(pollIntervalMilliseconds); + } + } + catch + { + // Swallow — if enumeration throws, downstream test skip path + // (DeviceNotFoundException) handles user feedback. + } + } + } +} diff --git a/Yubico.YubiKey/tests/utilities/Yubico/YubiKey/TestUtilities/IntegrationTestDeviceEnumeration.cs b/Yubico.YubiKey/tests/utilities/Yubico/YubiKey/TestUtilities/IntegrationTestDeviceEnumeration.cs index 3bec2a9de..1633fa1f8 100644 --- a/Yubico.YubiKey/tests/utilities/Yubico/YubiKey/TestUtilities/IntegrationTestDeviceEnumeration.cs +++ b/Yubico.YubiKey/tests/utilities/Yubico/YubiKey/TestUtilities/IntegrationTestDeviceEnumeration.cs @@ -29,7 +29,10 @@ namespace Yubico.YubiKey.TestUtilities /// /// The user needs to add their Yubikeys serial numbers to the allow-list file which is located at /// %LOCALAPPDATA%\Yubico\YUBIKEY_INTEGRATIONTEST_ALLOWEDKEYS.txt for Windows users - /// and /Users/<username>/.local/share/Yubico/YUBIKEY_INTEGRATIONTEST_ALLOWEDKEYS.txt for macOS users. + /// and ~/Library/Application Support/Yubico/YUBIKEY_INTEGRATIONTEST_ALLOWEDKEYS.txt for macOS users + /// (~/.local/share/Yubico/... for Linux users). The path resolves from + /// , which on .NET 8 macOS + /// maps to ~/Library/Application Support — not the older XDG default. /// The SDK attempts to create the file if it doesn't already exist. /// /// The allow-list file contains the serial numbers of YubiKeys to be allowed to run integration tests.