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.