diff --git a/.github/workflows/build-nativeshims.yml b/.github/workflows/build-nativeshims.yml
index 24e8eafc6..9cc233bbb 100644
--- a/.github/workflows/build-nativeshims.yml
+++ b/.github/workflows/build-nativeshims.yml
@@ -78,6 +78,20 @@ jobs:
if %FAILED%==1 exit /b 1
echo All Windows builds verified: no VC++ Redistributable required
exit /b 0
+ - name: Verify export tables match canonical symbol list
+ shell: pwsh
+ run: |
+ # Set up VC++ environment so dumpbin is on PATH for arm64 inspection
+ & "${env:ProgramFiles}\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\Launch-VsDevShell.ps1" -Arch amd64
+ $script = "$PWD\Yubico.NativeShims\tests\check_exports.ps1"
+ $failed = $false
+ foreach ($arch in @('win-x64', 'win-x86', 'win-arm64')) {
+ Write-Host "=== Checking $arch\Yubico.NativeShims.dll ==="
+ & $script "$PWD\Yubico.NativeShims\$arch\Yubico.NativeShims.dll"
+ if ($LASTEXITCODE -ne 0) { $failed = $true }
+ }
+ if ($failed) { exit 1 }
+ Write-Host "All Windows export tables match canonical symbol list."
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: win-x64
@@ -253,6 +267,10 @@ jobs:
readelf -V *.so | grep GLIBC_2 | sort -u
echo "✅ Binary compatible with Debian 10 (glibc 2.28)"
'
+ - name: Verify export table matches canonical symbol list
+ working-directory: Yubico.NativeShims
+ run: |
+ bash tests/check_exports.sh linux-x64/libYubico.NativeShims.so
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: linux-x64
@@ -414,6 +432,11 @@ jobs:
readelf -V *.so | grep GLIBC_2 | sort -u
echo "✅ ARM64 binary compatible with Debian 10 (glibc 2.28)"
'
+ - name: Verify export table matches canonical symbol list
+ working-directory: Yubico.NativeShims
+ run: |
+ # nm reads ELF metadata regardless of target arch — works on x86_64 host inspecting aarch64 .so
+ bash tests/check_exports.sh linux-arm64/libYubico.NativeShims.so
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: linux-arm64
@@ -440,6 +463,14 @@ jobs:
else
sh ./build-macOS.sh
fi
+ - name: Verify export tables match canonical symbol list
+ working-directory: Yubico.NativeShims
+ run: |
+ set -e
+ for arch in osx-x64 osx-arm64; do
+ echo "=== Checking $arch/libYubico.NativeShims.dylib ==="
+ bash tests/check_exports.sh "$arch/libYubico.NativeShims.dylib"
+ done
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: osx-x64
diff --git a/Yubico.Core/src/Yubico.Core.csproj b/Yubico.Core/src/Yubico.Core.csproj
index 04b65acef..757927c63 100644
--- a/Yubico.Core/src/Yubico.Core.csproj
+++ b/Yubico.Core/src/Yubico.Core.csproj
@@ -129,7 +129,7 @@ limitations under the License. -->
-
+
diff --git a/Yubico.YubiKey/src/Yubico/YubiKey/Cryptography/HkdfUtilities.cs b/Yubico.Core/src/Yubico/Core/Cryptography/HkdfUtilities.cs
similarity index 74%
rename from Yubico.YubiKey/src/Yubico/YubiKey/Cryptography/HkdfUtilities.cs
rename to Yubico.Core/src/Yubico/Core/Cryptography/HkdfUtilities.cs
index 27fb1ca4f..3995dd06f 100644
--- a/Yubico.YubiKey/src/Yubico/YubiKey/Cryptography/HkdfUtilities.cs
+++ b/Yubico.Core/src/Yubico/Core/Cryptography/HkdfUtilities.cs
@@ -13,10 +13,11 @@
// limitations under the License.
using System;
+using System.Security.Cryptography;
-namespace Yubico.YubiKey.Cryptography;
+namespace Yubico.Core.Cryptography;
-internal static class HkdfUtilities
+public static class HkdfUtilities
{
private const int Sha256HashByteLength = 32; // SHA-256 hash length in bytes
@@ -24,6 +25,12 @@ internal static class HkdfUtilities
/// Derives a key using the HKDF (HMAC-based Key Derivation Function)
/// as specified in RFC 5869 using SHA-256.
///
+ ///
+ /// Uses BCL HMACSHA256 directly. The .ToArray() calls on Span inputs are
+ /// required by the BCL HMAC.Key setter and ComputeHash API — they only
+ /// accept byte[], not Span. The intermediate pseudo-random key (PRK) is
+ /// zeroed via CryptographicOperations.ZeroMemory after use.
+ ///
/// The input key material (IKM).
/// Optional salt value. If not provided, a zero-length
/// salt will be used.
@@ -47,19 +54,27 @@ public static Memory DeriveKey(
throw new ArgumentOutOfRangeException(nameof(length), "Length exceeds maximum output size.");
}
- var pseudoRandomKey = HkdfExtract(inputKeyMaterial, salt);
- return HkdfExpand(pseudoRandomKey, contextInfo, length);
+ byte[] pseudoRandomKey = HkdfExtract(inputKeyMaterial, salt);
+ try
+ {
+ return HkdfExpand(pseudoRandomKey, contextInfo, length);
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(pseudoRandomKey);
+ }
}
- private static ReadOnlyMemory HkdfExtract(ReadOnlySpan inputKeyMaterial, ReadOnlySpan salt)
+ private static byte[] HkdfExtract(ReadOnlySpan inputKeyMaterial, ReadOnlySpan salt)
{
- using var hmac = CryptographyProviders.HmacCreator("HMACSHA256");
- hmac.Key = salt.IsEmpty ? new byte[Sha256HashByteLength] : salt.ToArray();
+ // BCL HMACSHA256 requires byte[] for key — .ToArray() is unavoidable here
+ byte[] saltBytes = salt.IsEmpty ? new byte[Sha256HashByteLength] : salt.ToArray();
+ using var hmac = new HMACSHA256(saltBytes);
return hmac.ComputeHash(inputKeyMaterial.ToArray());
}
private static Memory HkdfExpand(
- ReadOnlyMemory pseudoRandomKey,
+ ReadOnlySpan pseudoRandomKey,
ReadOnlySpan contextInfo,
int length)
{
@@ -67,10 +82,9 @@ private static Memory HkdfExpand(
byte[] outputKeyMaterial = new byte[length];
Span previousBlock = Array.Empty();
- using var hmac = CryptographyProviders.HmacCreator("HMACSHA256");
+ // BCL HMACSHA256 requires byte[] for key — .ToArray() is unavoidable here
+ using var hmac = new HMACSHA256(pseudoRandomKey.ToArray());
- hmac.Key = pseudoRandomKey.ToArray();
-
for (byte index = 1; index <= numberOfBlocks; index++)
{
hmac.Initialize();
@@ -94,7 +108,7 @@ private static Memory HkdfExpand(
currentBlock
.AsSpan(0, bytesToCopy)
.CopyTo(outputKeyMaterial.AsSpan(blockOffset));
-
+
previousBlock = currentBlock;
}
diff --git a/Yubico.Core/src/Yubico/PlatformInterop/Desktop/Cryptography/EcPoint.Interop.cs b/Yubico.Core/src/Yubico/PlatformInterop/Desktop/Cryptography/EcPoint.Interop.cs
index becb2efb6..6cdf8a2f2 100644
--- a/Yubico.Core/src/Yubico/PlatformInterop/Desktop/Cryptography/EcPoint.Interop.cs
+++ b/Yubico.Core/src/Yubico/PlatformInterop/Desktop/Cryptography/EcPoint.Interop.cs
@@ -84,5 +84,13 @@ public static int EcPointMul(
q,
m,
IntPtr.Zero);
+
+ // int EC_POINT_is_on_curve(const EC_GROUP* group, const EC_POINT* point, BN_CTX* ctx);
+ [DllImport(Libraries.NativeShims, EntryPoint = "Native_EC_POINT_is_on_curve", ExactSpelling = true, CharSet = CharSet.Ansi)]
+ [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)]
+ private static extern int EcPointIsOnCurve(IntPtr group, IntPtr point, IntPtr ctx);
+
+ public static int EcPointIsOnCurve(SafeEcGroup group, SafeEcPoint point) =>
+ EcPointIsOnCurve(group.DangerousGetHandle(), point.DangerousGetHandle(), IntPtr.Zero);
}
}
diff --git a/Yubico.YubiKey/tests/unit/Yubico/YubiKey/Cryptography/HkdfUtilitiesTests.cs b/Yubico.Core/tests/unit/Yubico/Core/Cryptography/HkdfUtilitiesTests.cs
similarity index 98%
rename from Yubico.YubiKey/tests/unit/Yubico/YubiKey/Cryptography/HkdfUtilitiesTests.cs
rename to Yubico.Core/tests/unit/Yubico/Core/Cryptography/HkdfUtilitiesTests.cs
index 12586c3aa..6eae91405 100644
--- a/Yubico.YubiKey/tests/unit/Yubico/YubiKey/Cryptography/HkdfUtilitiesTests.cs
+++ b/Yubico.Core/tests/unit/Yubico/Core/Cryptography/HkdfUtilitiesTests.cs
@@ -16,8 +16,9 @@
using System.Linq;
using System.Security.Cryptography;
using Xunit;
+using Yubico.Core.Cryptography;
-namespace Yubico.YubiKey.Cryptography;
+namespace Yubico.Core.Cryptography;
public class HkdfUtilitiesTests
{
[Fact]
diff --git a/Yubico.Core/tests/unit/Yubico/PlatformInterop/Cryptography/BigNumInteropTests.cs b/Yubico.Core/tests/unit/Yubico/PlatformInterop/Cryptography/BigNumInteropTests.cs
new file mode 100644
index 000000000..e5b741bb0
--- /dev/null
+++ b/Yubico.Core/tests/unit/Yubico/PlatformInterop/Cryptography/BigNumInteropTests.cs
@@ -0,0 +1,184 @@
+// 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.
+
+// Purpose
+// -------
+// Direct P/Invoke functional tests for the OpenSSL BIGNUM marshaling layer
+// exposed by Yubico.NativeShims (Native_BN_*). These wrappers move arbitrary-
+// precision integers across the C#/C boundary; subtle bugs in length handling,
+// padding, or leading-zero behavior surface as silent corruption in EC point
+// coordinates and ARKG primitives that build on top.
+//
+// What this validates
+// -------------------
+// * bin -> BIGNUM -> bin round-trip preserves bytes for sizes 1, 16, 32, 256.
+// * Native_BN_num_bytes returns the canonical length (leading zeros stripped,
+// matching OpenSSL semantics).
+// * Native_BN_bn2binpad left-pads to a fixed width without truncating.
+// * Lifecycle: Native_BN_new, Native_BN_clear_free do not crash or leak under
+// repeated allocate/free.
+//
+// References
+// ----------
+// * OpenSSL BN(3) man page — https://docs.openssl.org/master/man3/BN_new/
+// (authoritative for BN_new, BN_bin2bn, BN_bn2bin, BN_bn2binpad,
+// BN_num_bytes, BN_clear_free behavior).
+// * No formal standards-track spec exists for the BIGNUM API; round-trip
+// and boundary tests are self-consistent against the OpenSSL contract.
+
+using System;
+using System.Linq;
+using Xunit;
+using Yubico.PlatformInterop;
+
+namespace Yubico.PlatformInterop.Cryptography
+{
+ public class BigNumInteropTests
+ {
+ [Fact]
+ public void BnBinaryToBigNum_RoundTrip_SingleByte_ReturnsOriginal()
+ {
+ byte[] original = { 0x42 };
+
+ using SafeBigNum bn = NativeMethods.BnBinaryToBigNum(original);
+ byte[] buffer = new byte[1];
+ int written = NativeMethods.BnBigNumToBinary(bn, buffer);
+
+ Assert.Equal(1, written);
+ Assert.Equal(original, buffer);
+ }
+
+ [Fact]
+ public void BnBinaryToBigNum_RoundTrip_16Bytes_ReturnsOriginal()
+ {
+ byte[] original = Enumerable.Range(1, 16).Select(i => (byte)i).ToArray();
+
+ using SafeBigNum bn = NativeMethods.BnBinaryToBigNum(original);
+ byte[] buffer = new byte[16];
+ int written = NativeMethods.BnBigNumToBinary(bn, buffer);
+
+ Assert.Equal(16, written);
+ Assert.Equal(original, buffer);
+ }
+
+ [Fact]
+ public void BnBinaryToBigNum_RoundTrip_32Bytes_ReturnsOriginal()
+ {
+ byte[] original = Enumerable.Range(0, 32).Select(i => (byte)((i * 7) + 13)).ToArray();
+
+ using SafeBigNum bn = NativeMethods.BnBinaryToBigNum(original);
+ byte[] buffer = new byte[32];
+ int written = NativeMethods.BnBigNumToBinary(bn, buffer);
+
+ Assert.Equal(32, written);
+ Assert.Equal(original, buffer);
+ }
+
+ [Fact]
+ public void BnBinaryToBigNum_RoundTrip_256Bytes_ReturnsOriginal()
+ {
+ // Start with 0x01 to avoid leading-zero stripping
+ byte[] original = Enumerable.Range(1, 256).Select(i => (byte)i).ToArray();
+
+ using SafeBigNum bn = NativeMethods.BnBinaryToBigNum(original);
+ byte[] buffer = new byte[256];
+ int written = NativeMethods.BnBigNumToBinary(bn, buffer);
+
+ Assert.Equal(256, written);
+ Assert.Equal(original, buffer);
+ }
+
+ [Fact]
+ public void BnBinaryToBigNum_LeadingZero_StripsLeadingZeros()
+ {
+ // OpenSSL BIGNUMs strip leading zeros
+ byte[] original = { 0x00, 0x00, 0x01, 0x23, 0x45 };
+ byte[] expected = { 0x01, 0x23, 0x45 };
+
+ using SafeBigNum bn = NativeMethods.BnBinaryToBigNum(original);
+ byte[] buffer = new byte[5];
+ int written = NativeMethods.BnBigNumToBinary(bn, buffer);
+
+ Assert.Equal(3, written);
+ Assert.Equal(expected, buffer.Take(written).ToArray());
+ }
+
+ [Fact]
+ public void BnBinaryToBigNum_AllZeros_HandlesGracefully()
+ {
+ byte[] original = { 0x00, 0x00, 0x00 };
+
+ using SafeBigNum bn = NativeMethods.BnBinaryToBigNum(original);
+ byte[] buffer = new byte[3];
+ int written = NativeMethods.BnBigNumToBinary(bn, buffer);
+
+ // All zeros represents the number 0, which OpenSSL represents as zero bytes
+ Assert.Equal(0, written);
+ }
+
+ [Fact]
+ public void BnBigNumToBinaryWithPadding_PadsTo32Bytes()
+ {
+ byte[] original = { 0x12, 0x34 };
+
+ using SafeBigNum bn = NativeMethods.BnBinaryToBigNum(original);
+ byte[] buffer = new byte[32];
+ int written = NativeMethods.BnBigNumToBinaryWithPadding(bn, buffer);
+
+ Assert.Equal(32, written);
+ // Padding should be zero-bytes on the left (big-endian)
+ byte[] expected = new byte[32];
+ expected[30] = 0x12;
+ expected[31] = 0x34;
+ Assert.Equal(expected, buffer);
+ }
+
+ [Fact]
+ public void BnBigNumToBinaryWithPadding_PadsTo16Bytes()
+ {
+ byte[] original = { 0xAB };
+
+ using SafeBigNum bn = NativeMethods.BnBinaryToBigNum(original);
+ byte[] buffer = new byte[16];
+ int written = NativeMethods.BnBigNumToBinaryWithPadding(bn, buffer);
+
+ Assert.Equal(16, written);
+ byte[] expected = new byte[16];
+ expected[15] = 0xAB;
+ Assert.Equal(expected, buffer);
+ }
+
+ [Fact]
+ public void BnNew_CreatesValidHandle()
+ {
+ using SafeBigNum bn = NativeMethods.BnNew();
+
+ Assert.NotNull(bn);
+ Assert.False(bn.IsInvalid);
+ }
+
+ [Fact]
+ public void BnBinaryToBigNum_EmptyArray_HandlesGracefully()
+ {
+ byte[] original = Array.Empty();
+
+ using SafeBigNum bn = NativeMethods.BnBinaryToBigNum(original);
+ byte[] buffer = new byte[16];
+ int written = NativeMethods.BnBigNumToBinary(bn, buffer);
+
+ // Empty input = zero
+ Assert.Equal(0, written);
+ }
+ }
+}
diff --git a/Yubico.Core/tests/unit/Yubico/PlatformInterop/Cryptography/CmacInteropTests.cs b/Yubico.Core/tests/unit/Yubico/PlatformInterop/Cryptography/CmacInteropTests.cs
new file mode 100644
index 000000000..d98e0164b
--- /dev/null
+++ b/Yubico.Core/tests/unit/Yubico/PlatformInterop/Cryptography/CmacInteropTests.cs
@@ -0,0 +1,271 @@
+// 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.
+
+// Purpose
+// -------
+// Direct P/Invoke functional tests for the AES-128-CMAC (Cipher-based MAC)
+// EVP MAC wrappers exposed by Yubico.NativeShims (Native_CMAC_EVP_MAC_*).
+// CMAC is consumed by SCP03 / PIV / OATH session authentication paths; an
+// off-by-one in update chunking or a wrong subkey derivation results in
+// silent authentication failures against real YubiKeys.
+//
+// What this validates
+// -------------------
+// * MAC context lifecycle: Native_CMAC_EVP_MAC_CTX_new / Native_EVP_MAC_CTX_free.
+// * Native_CMAC_EVP_MAC_init binds the AES-128 key.
+// * Native_CMAC_EVP_MAC_update accepts variable-length chunks.
+// * Native_CMAC_EVP_MAC_final emits the 16-byte tag.
+// * RFC 4493 §4 published test vectors (AES-128) for messages of length
+// 0, 16, 40, and 64 bytes — pins the wire-level contract.
+// * Multi-update equivalence: update(A) followed by update(B) produces the
+// same tag as update(A || B). Catches buffer-management regressions in
+// the C side.
+//
+// References
+// ----------
+// * RFC 4493 — The AES-CMAC Algorithm, §2 (Specification), §4 (Test Vectors).
+// https://datatracker.ietf.org/doc/html/rfc4493
+// * NIST SP 800-38B — Recommendation for Block Cipher Modes of Operation:
+// The CMAC Mode for Authentication.
+// https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-38B.pdf
+// * FIPS 197 — Advanced Encryption Standard (AES).
+// https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.197.pdf
+// * OpenSSL EVP_MAC(3) — https://docs.openssl.org/master/man3/EVP_MAC/
+
+using System;
+using System.Linq;
+using Xunit;
+using Yubico.PlatformInterop;
+
+namespace Yubico.PlatformInterop.Cryptography
+{
+ public class CmacInteropTests
+ {
+ // Algorithm constants (from Cmac.Interop.cs comment)
+ private const int Aes128Cbc = 1;
+ private const int Aes192Cbc = 2;
+ private const int Aes256Cbc = 3;
+
+ // RFC 4493 §4 test vectors for AES-128-CMAC
+ // Reference: https://www.rfc-editor.org/rfc/rfc4493.html#section-4
+ private static readonly byte[] RFC4493_Key = new byte[]
+ {
+ 0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6,
+ 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, 0x4f, 0x3c,
+ };
+
+ // Example 1: Empty message
+ private static readonly byte[] RFC4493_Example1_Message = Array.Empty();
+ private static readonly byte[] RFC4493_Example1_MAC = new byte[]
+ {
+ 0xbb, 0x1d, 0x69, 0x29, 0xe9, 0x59, 0x37, 0x28,
+ 0x7f, 0xa3, 0x7d, 0x12, 0x9b, 0x75, 0x67, 0x46,
+ };
+
+ // Example 2: 16-byte message
+ private static readonly byte[] RFC4493_Example2_Message = new byte[]
+ {
+ 0x6b, 0xc1, 0xbe, 0xe2, 0x2e, 0x40, 0x9f, 0x96,
+ 0xe9, 0x3d, 0x7e, 0x11, 0x73, 0x93, 0x17, 0x2a,
+ };
+ private static readonly byte[] RFC4493_Example2_MAC = new byte[]
+ {
+ 0x07, 0x0a, 0x16, 0xb4, 0x6b, 0x4d, 0x41, 0x44,
+ 0xf7, 0x9b, 0xdd, 0x9d, 0xd0, 0x4a, 0x28, 0x7c,
+ };
+
+ // Example 3: 40-byte message
+ private static readonly byte[] RFC4493_Example3_Message = new byte[]
+ {
+ 0x6b, 0xc1, 0xbe, 0xe2, 0x2e, 0x40, 0x9f, 0x96,
+ 0xe9, 0x3d, 0x7e, 0x11, 0x73, 0x93, 0x17, 0x2a,
+ 0xae, 0x2d, 0x8a, 0x57, 0x1e, 0x03, 0xac, 0x9c,
+ 0x9e, 0xb7, 0x6f, 0xac, 0x45, 0xaf, 0x8e, 0x51,
+ 0x30, 0xc8, 0x1c, 0x46, 0xa3, 0x5c, 0xe4, 0x11,
+ };
+ private static readonly byte[] RFC4493_Example3_MAC = new byte[]
+ {
+ 0xdf, 0xa6, 0x67, 0x47, 0xde, 0x9a, 0xe6, 0x30,
+ 0x30, 0xca, 0x32, 0x61, 0x14, 0x97, 0xc8, 0x27,
+ };
+
+ // Example 4: 64-byte message
+ private static readonly byte[] RFC4493_Example4_Message = new byte[]
+ {
+ 0x6b, 0xc1, 0xbe, 0xe2, 0x2e, 0x40, 0x9f, 0x96,
+ 0xe9, 0x3d, 0x7e, 0x11, 0x73, 0x93, 0x17, 0x2a,
+ 0xae, 0x2d, 0x8a, 0x57, 0x1e, 0x03, 0xac, 0x9c,
+ 0x9e, 0xb7, 0x6f, 0xac, 0x45, 0xaf, 0x8e, 0x51,
+ 0x30, 0xc8, 0x1c, 0x46, 0xa3, 0x5c, 0xe4, 0x11,
+ 0xe5, 0xfb, 0xc1, 0x19, 0x1a, 0x0a, 0x52, 0xef,
+ 0xf6, 0x9f, 0x24, 0x45, 0xdf, 0x4f, 0x9b, 0x17,
+ 0xad, 0x2b, 0x41, 0x7b, 0xe6, 0x6c, 0x37, 0x10,
+ };
+ private static readonly byte[] RFC4493_Example4_MAC = new byte[]
+ {
+ 0x51, 0xf0, 0xbe, 0xbf, 0x7e, 0x3b, 0x9d, 0x92,
+ 0xfc, 0x49, 0x74, 0x17, 0x79, 0x36, 0x3c, 0xfe,
+ };
+
+ [Fact]
+ public void CmacEvpMacCtxNew_CreatesValidContext()
+ {
+ using SafeEvpCmacCtx ctx = NativeMethods.CmacEvpMacCtxNew();
+
+ Assert.NotNull(ctx);
+ Assert.False(ctx.IsInvalid);
+ }
+
+ [Fact]
+ public void CmacEvpMacInit_ValidParameters_ReturnsSuccess()
+ {
+ using SafeEvpCmacCtx ctx = NativeMethods.CmacEvpMacCtxNew();
+
+ int result = NativeMethods.CmacEvpMacInit(ctx, Aes128Cbc, RFC4493_Key, RFC4493_Key.Length);
+
+ Assert.Equal(1, result);
+ }
+
+ [Fact]
+ public void Cmac_RFC4493_Example1_EmptyMessage_MatchesExpectedMAC()
+ {
+ // RFC 4493 §4 Example 1: empty message
+ using SafeEvpCmacCtx ctx = NativeMethods.CmacEvpMacCtxNew();
+
+ int initResult = NativeMethods.CmacEvpMacInit(ctx, Aes128Cbc, RFC4493_Key, RFC4493_Key.Length);
+ Assert.Equal(1, initResult);
+
+ byte[] mac = new byte[16];
+ int finalResult = NativeMethods.CmacEvpMacFinal(ctx, mac, mac.Length, out int macLen);
+ Assert.Equal(1, finalResult);
+ Assert.Equal(16, macLen);
+
+ Assert.Equal(RFC4493_Example1_MAC, mac);
+ }
+
+ [Fact]
+ public void Cmac_RFC4493_Example2_16ByteMessage_MatchesExpectedMAC()
+ {
+ // RFC 4493 §4 Example 2: 16-byte message (one block)
+ using SafeEvpCmacCtx ctx = NativeMethods.CmacEvpMacCtxNew();
+
+ int initResult = NativeMethods.CmacEvpMacInit(ctx, Aes128Cbc, RFC4493_Key, RFC4493_Key.Length);
+ Assert.Equal(1, initResult);
+
+ int updateResult = NativeMethods.CmacEvpMacUpdate(ctx, RFC4493_Example2_Message, RFC4493_Example2_Message.Length);
+ Assert.Equal(1, updateResult);
+
+ byte[] mac = new byte[16];
+ int finalResult = NativeMethods.CmacEvpMacFinal(ctx, mac, mac.Length, out int macLen);
+ Assert.Equal(1, finalResult);
+ Assert.Equal(16, macLen);
+
+ Assert.Equal(RFC4493_Example2_MAC, mac);
+ }
+
+ [Fact]
+ public void Cmac_RFC4493_Example3_40ByteMessage_MatchesExpectedMAC()
+ {
+ // RFC 4493 §4 Example 3: 40-byte message (non-block-aligned)
+ using SafeEvpCmacCtx ctx = NativeMethods.CmacEvpMacCtxNew();
+
+ int initResult = NativeMethods.CmacEvpMacInit(ctx, Aes128Cbc, RFC4493_Key, RFC4493_Key.Length);
+ Assert.Equal(1, initResult);
+
+ int updateResult = NativeMethods.CmacEvpMacUpdate(ctx, RFC4493_Example3_Message, RFC4493_Example3_Message.Length);
+ Assert.Equal(1, updateResult);
+
+ byte[] mac = new byte[16];
+ int finalResult = NativeMethods.CmacEvpMacFinal(ctx, mac, mac.Length, out int macLen);
+ Assert.Equal(1, finalResult);
+ Assert.Equal(16, macLen);
+
+ Assert.Equal(RFC4493_Example3_MAC, mac);
+ }
+
+ [Fact]
+ public void Cmac_RFC4493_Example4_64ByteMessage_MatchesExpectedMAC()
+ {
+ // RFC 4493 §4 Example 4: 64-byte message (block-aligned, multiple blocks)
+ using SafeEvpCmacCtx ctx = NativeMethods.CmacEvpMacCtxNew();
+
+ int initResult = NativeMethods.CmacEvpMacInit(ctx, Aes128Cbc, RFC4493_Key, RFC4493_Key.Length);
+ Assert.Equal(1, initResult);
+
+ int updateResult = NativeMethods.CmacEvpMacUpdate(ctx, RFC4493_Example4_Message, RFC4493_Example4_Message.Length);
+ Assert.Equal(1, updateResult);
+
+ byte[] mac = new byte[16];
+ int finalResult = NativeMethods.CmacEvpMacFinal(ctx, mac, mac.Length, out int macLen);
+ Assert.Equal(1, finalResult);
+ Assert.Equal(16, macLen);
+
+ Assert.Equal(RFC4493_Example4_MAC, mac);
+ }
+
+ [Fact]
+ public void Cmac_MultiUpdate_EquivalentToSingleUpdate()
+ {
+ // update(A) + update(B) should equal update(A||B)
+ byte[] messageA = RFC4493_Example4_Message.Take(32).ToArray();
+ byte[] messageB = RFC4493_Example4_Message.Skip(32).ToArray();
+
+ // Single update
+ byte[] macSingle;
+ using (SafeEvpCmacCtx ctx = NativeMethods.CmacEvpMacCtxNew())
+ {
+ NativeMethods.CmacEvpMacInit(ctx, Aes128Cbc, RFC4493_Key, RFC4493_Key.Length);
+ NativeMethods.CmacEvpMacUpdate(ctx, RFC4493_Example4_Message, RFC4493_Example4_Message.Length);
+ macSingle = new byte[16];
+ NativeMethods.CmacEvpMacFinal(ctx, macSingle, macSingle.Length, out _);
+ }
+
+ // Multi update
+ byte[] macMulti;
+ using (SafeEvpCmacCtx ctx = NativeMethods.CmacEvpMacCtxNew())
+ {
+ NativeMethods.CmacEvpMacInit(ctx, Aes128Cbc, RFC4493_Key, RFC4493_Key.Length);
+ NativeMethods.CmacEvpMacUpdate(ctx, messageA, messageA.Length);
+ NativeMethods.CmacEvpMacUpdate(ctx, messageB, messageB.Length);
+ macMulti = new byte[16];
+ NativeMethods.CmacEvpMacFinal(ctx, macMulti, macMulti.Length, out _);
+ }
+
+ Assert.Equal(macSingle, macMulti);
+ Assert.Equal(RFC4493_Example4_MAC, macMulti);
+ }
+
+ [Fact]
+ public void Cmac_MultiUpdate_ThreeChunks_MatchesRFC()
+ {
+ // Verify multi-update with three arbitrary chunks of Example 3 (40 bytes)
+ byte[] chunk1 = RFC4493_Example3_Message.Take(10).ToArray();
+ byte[] chunk2 = RFC4493_Example3_Message.Skip(10).Take(20).ToArray();
+ byte[] chunk3 = RFC4493_Example3_Message.Skip(30).ToArray();
+
+ using SafeEvpCmacCtx ctx = NativeMethods.CmacEvpMacCtxNew();
+
+ NativeMethods.CmacEvpMacInit(ctx, Aes128Cbc, RFC4493_Key, RFC4493_Key.Length);
+ NativeMethods.CmacEvpMacUpdate(ctx, chunk1, chunk1.Length);
+ NativeMethods.CmacEvpMacUpdate(ctx, chunk2, chunk2.Length);
+ NativeMethods.CmacEvpMacUpdate(ctx, chunk3, chunk3.Length);
+
+ byte[] mac = new byte[16];
+ NativeMethods.CmacEvpMacFinal(ctx, mac, mac.Length, out int macLen);
+
+ Assert.Equal(16, macLen);
+ Assert.Equal(RFC4493_Example3_MAC, mac);
+ }
+ }
+}
diff --git a/Yubico.Core/tests/unit/Yubico/PlatformInterop/Cryptography/EcPointInteropTests.cs b/Yubico.Core/tests/unit/Yubico/PlatformInterop/Cryptography/EcPointInteropTests.cs
new file mode 100644
index 000000000..aa443f420
--- /dev/null
+++ b/Yubico.Core/tests/unit/Yubico/PlatformInterop/Cryptography/EcPointInteropTests.cs
@@ -0,0 +1,251 @@
+// 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.
+
+// Purpose
+// -------
+// Direct P/Invoke functional tests for the OpenSSL EC group / EC point
+// marshaling layer exposed by Yubico.NativeShims (Native_EC_GROUP_*,
+// Native_EC_POINT_*). These wrappers underpin every ECC operation in the SDK
+// (ECDH, ARKG-P256 on-curve validation, FIDO2 key handling); marshaling
+// regressions cascade silently into wrong shared secrets or accepted
+// invalid-curve points.
+//
+// What this validates
+// -------------------
+// * Group/point lifecycle on NIST P-256 (curve NID 415).
+// * Native_EC_POINT_set_affine_coordinates + get_affine_coordinates
+// round-trips the SEC2 P-256 generator G unchanged.
+// * Native_EC_POINT_mul: G·1 = G; G·n (n = group order) = point at infinity
+// (get_affine subsequently fails as expected).
+// * Native_EC_POINT_is_on_curve: returns 1 for the valid generator,
+// 0 for a Y-bit-flipped off-curve candidate. Required for SEC 1 §3.2.2
+// public-key validation in ARKG-P256.
+//
+// References
+// ----------
+// * SEC 2: Recommended Elliptic Curve Domain Parameters, v2.0 §2.4.2
+// (secp256r1 / NIST P-256 generator and group order)
+// https://www.secg.org/sec2-v2.pdf
+// * SEC 1: Elliptic Curve Cryptography, v2.0 §3.2.2 (Public Key Validation)
+// https://www.secg.org/sec1-v2.pdf
+// * NIST SP 800-186 §3.2.1.3 (Curve P-256) — current authoritative source
+// for NIST P-256 domain parameters (the FIPS 186-5 revision moved curve
+// definitions out of FIPS 186 into SP 800-186).
+// https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-186.pdf
+// * OpenSSL EC_POINT(3) man page —
+// https://docs.openssl.org/master/man3/EC_POINT_new/
+
+using System;
+using Xunit;
+using Yubico.PlatformInterop;
+
+namespace Yubico.PlatformInterop.Cryptography
+{
+ public class EcPointInteropTests
+ {
+ // P-256 curve NID (OpenSSL constant for X9.62 prime256v1)
+ private const int NidP256 = 415;
+
+ // P-256 generator G (SEC1 uncompressed: 0x04 || Gx || Gy).
+ // Reference: SEC2 v2 §2.4.2.
+ private static readonly byte[] P256GeneratorX =
+ {
+ 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,
+ };
+
+ private static readonly byte[] P256GeneratorY =
+ {
+ 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,
+ };
+
+ // P-256 group order (SEC2 v2 §2.4.2)
+ private static readonly byte[] P256Order =
+ {
+ 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xBC, 0xE6, 0xFA, 0xAD, 0xA7, 0x17, 0x9E, 0x84,
+ 0xF3, 0xB9, 0xCA, 0xC2, 0xFC, 0x63, 0x25, 0x51,
+ };
+
+ [Fact]
+ public void EcGroupNewByCurveName_P256_CreatesValidGroup()
+ {
+ using SafeEcGroup group = NativeMethods.EcGroupNewByCurveName(NidP256);
+
+ Assert.NotNull(group);
+ Assert.False(group.IsInvalid);
+ }
+
+ [Fact]
+ public void EcPointNew_ValidGroup_CreatesValidPoint()
+ {
+ using SafeEcGroup group = NativeMethods.EcGroupNewByCurveName(NidP256);
+ using SafeEcPoint point = NativeMethods.EcPointNew(group);
+
+ Assert.NotNull(point);
+ Assert.False(point.IsInvalid);
+ }
+
+ [Fact]
+ public void EcPointIsOnCurve_P256Generator_ReturnsTrue()
+ {
+ // SEC2 §2.4.2 P-256 generator G is on the curve
+ using SafeEcGroup group = NativeMethods.EcGroupNewByCurveName(NidP256);
+ using SafeEcPoint point = NativeMethods.EcPointNew(group);
+ using SafeBigNum x = NativeMethods.BnBinaryToBigNum(P256GeneratorX);
+ using SafeBigNum y = NativeMethods.BnBinaryToBigNum(P256GeneratorY);
+
+ int setResult = NativeMethods.EcPointSetAffineCoordinates(group, point, x, y);
+ Assert.Equal(1, setResult);
+
+ int isOnCurve = NativeMethods.EcPointIsOnCurve(group, point);
+ Assert.Equal(1, isOnCurve);
+ }
+
+ [Fact]
+ public void EcPointIsOnCurve_OffCurvePoint_ReturnsFalse()
+ {
+ // Flip the lowest bit of Y to create a point not on the curve
+ byte[] offCurveY = (byte[])P256GeneratorY.Clone();
+ offCurveY[31] ^= 0x01;
+
+ using SafeEcGroup group = NativeMethods.EcGroupNewByCurveName(NidP256);
+ using SafeEcPoint point = NativeMethods.EcPointNew(group);
+ using SafeBigNum x = NativeMethods.BnBinaryToBigNum(P256GeneratorX);
+ using SafeBigNum y = NativeMethods.BnBinaryToBigNum(offCurveY);
+
+ // set_affine_coordinates might fail for invalid points, but if it succeeds,
+ // is_on_curve must return 0
+ int setResult = NativeMethods.EcPointSetAffineCoordinates(group, point, x, y);
+ if (setResult == 1)
+ {
+ int isOnCurve = NativeMethods.EcPointIsOnCurve(group, point);
+ Assert.Equal(0, isOnCurve);
+ }
+ // If set fails, the point is invalid - that's also acceptable behavior
+ }
+
+ [Fact]
+ public void EcPointGetAffineCoordinates_RoundTrip_MatchesOriginal()
+ {
+ // G·1 round-trips back to G via set/get affine coordinates
+ using SafeEcGroup group = NativeMethods.EcGroupNewByCurveName(NidP256);
+ using SafeEcPoint point = NativeMethods.EcPointNew(group);
+ using SafeBigNum xIn = NativeMethods.BnBinaryToBigNum(P256GeneratorX);
+ using SafeBigNum yIn = NativeMethods.BnBinaryToBigNum(P256GeneratorY);
+
+ int setResult = NativeMethods.EcPointSetAffineCoordinates(group, point, xIn, yIn);
+ Assert.Equal(1, setResult);
+
+ using SafeBigNum xOut = NativeMethods.BnNew();
+ using SafeBigNum yOut = NativeMethods.BnNew();
+
+ int getResult = NativeMethods.EcPointGetAffineCoordinates(group, point, xOut, yOut);
+ Assert.Equal(1, getResult);
+
+ byte[] xBytes = new byte[32];
+ byte[] yBytes = new byte[32];
+ int xLen = NativeMethods.BnBigNumToBinaryWithPadding(xOut, xBytes);
+ int yLen = NativeMethods.BnBigNumToBinaryWithPadding(yOut, yBytes);
+
+ Assert.Equal(32, xLen);
+ Assert.Equal(32, yLen);
+ Assert.Equal(P256GeneratorX, xBytes);
+ Assert.Equal(P256GeneratorY, yBytes);
+ }
+
+ [Fact]
+ public void EcPointMul_GeneratorTimesOne_ReturnsGenerator()
+ {
+ // G·1 = G
+ byte[] scalarOne = new byte[32];
+ scalarOne[31] = 1;
+
+ using SafeEcGroup group = NativeMethods.EcGroupNewByCurveName(NidP256);
+ using SafeEcPoint generatorPoint = NativeMethods.EcPointNew(group);
+ using SafeBigNum xGen = NativeMethods.BnBinaryToBigNum(P256GeneratorX);
+ using SafeBigNum yGen = NativeMethods.BnBinaryToBigNum(P256GeneratorY);
+
+ int setResult = NativeMethods.EcPointSetAffineCoordinates(group, generatorPoint, xGen, yGen);
+ Assert.Equal(1, setResult);
+
+ using SafeBigNum scalarBn = NativeMethods.BnBinaryToBigNum(scalarOne);
+ using SafeEcPoint result = NativeMethods.EcPointNew(group);
+
+ // EC_POINT_mul(group, r, n, q, m, ctx) computes r = n·G + m·q
+ // To compute q·scalar, pass n=0, q=generatorPoint, m=scalar
+ int mulResult = NativeMethods.EcPointMul(
+ group,
+ result,
+ IntPtr.Zero, // n = NULL (don't add generator multiple)
+ generatorPoint.DangerousGetHandle(), // q
+ scalarBn.DangerousGetHandle()); // m
+
+ Assert.Equal(1, mulResult);
+
+ using SafeBigNum xResult = NativeMethods.BnNew();
+ using SafeBigNum yResult = NativeMethods.BnNew();
+
+ int getResult = NativeMethods.EcPointGetAffineCoordinates(group, result, xResult, yResult);
+ Assert.Equal(1, getResult);
+
+ byte[] xBytes = new byte[32];
+ byte[] yBytes = new byte[32];
+ NativeMethods.BnBigNumToBinaryWithPadding(xResult, xBytes);
+ NativeMethods.BnBigNumToBinaryWithPadding(yResult, yBytes);
+
+ Assert.Equal(P256GeneratorX, xBytes);
+ Assert.Equal(P256GeneratorY, yBytes);
+ }
+
+ [Fact]
+ public void EcPointMul_GeneratorTimesOrder_ReturnsPointAtInfinity()
+ {
+ // G·n where n = P-256 group order → point at infinity
+ // Point at infinity cannot have affine coordinates extracted
+ using SafeEcGroup group = NativeMethods.EcGroupNewByCurveName(NidP256);
+ using SafeEcPoint generatorPoint = NativeMethods.EcPointNew(group);
+ using SafeBigNum xGen = NativeMethods.BnBinaryToBigNum(P256GeneratorX);
+ using SafeBigNum yGen = NativeMethods.BnBinaryToBigNum(P256GeneratorY);
+
+ int setResult = NativeMethods.EcPointSetAffineCoordinates(group, generatorPoint, xGen, yGen);
+ Assert.Equal(1, setResult);
+
+ using SafeBigNum orderBn = NativeMethods.BnBinaryToBigNum(P256Order);
+ using SafeEcPoint result = NativeMethods.EcPointNew(group);
+
+ int mulResult = NativeMethods.EcPointMul(
+ group,
+ result,
+ IntPtr.Zero,
+ generatorPoint.DangerousGetHandle(),
+ orderBn.DangerousGetHandle());
+
+ Assert.Equal(1, mulResult);
+
+ using SafeBigNum xResult = NativeMethods.BnNew();
+ using SafeBigNum yResult = NativeMethods.BnNew();
+
+ // Attempting to get affine coordinates of point at infinity should fail
+ int getResult = NativeMethods.EcPointGetAffineCoordinates(group, result, xResult, yResult);
+ Assert.Equal(0, getResult);
+ }
+ }
+}
diff --git a/Yubico.Core/tests/unit/Yubico/PlatformInterop/Cryptography/GcmEvpInteropTests.cs b/Yubico.Core/tests/unit/Yubico/PlatformInterop/Cryptography/GcmEvpInteropTests.cs
new file mode 100644
index 000000000..c8ece1c17
--- /dev/null
+++ b/Yubico.Core/tests/unit/Yubico/PlatformInterop/Cryptography/GcmEvpInteropTests.cs
@@ -0,0 +1,337 @@
+// 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.
+
+// Purpose
+// -------
+// Direct P/Invoke functional tests for the AES-256-GCM (Galois/Counter Mode)
+// EVP cipher wrappers exposed by Yubico.NativeShims (Native_EVP_*). GCM is an
+// authenticated cipher: tag verification is the only thing standing between a
+// caller and accepting tampered ciphertext, so these tests must validate not
+// just round-trips but also that tag mismatch causes Final_ex to return 0
+// (decryption rejected).
+//
+// What this validates
+// -------------------
+// * Cipher context lifecycle: Native_EVP_CIPHER_CTX_new / _free.
+// * Native_EVP_Aes256Gcm_Init for both encrypt and decrypt direction.
+// * Native_EVP_Update for AAD (output=null) and plaintext / ciphertext.
+// * Native_EVP_Final_ex computes / verifies the tag.
+// * Native_EVP_CIPHER_CTX_ctrl with EVP_CTRL_AEAD_GET_TAG (16) /
+// EVP_CTRL_AEAD_SET_TAG (17) — note: numeric values shared between C# and
+// C without a header, so this test pins the contract.
+// * NIST SP 800-38D Test Cases (256-bit key) — encrypt/decrypt against
+// known answers.
+// * AAD round-trip and tag tamper detection (single-bit flip causes
+// Final_ex == 0 on decrypt).
+//
+// References
+// ----------
+// * NIST SP 800-38D — Recommendation for Block Cipher Modes of Operation:
+// Galois/Counter Mode (GCM) and GMAC, §7.1 (Authenticated Encryption),
+// §7.2 (Authenticated Decryption).
+// https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-38d.pdf
+// * NIST GCM Test Vectors (CAVP) —
+// https://csrc.nist.gov/projects/cryptographic-algorithm-validation-program/cavp-testing-block-cipher-modes
+// * FIPS 197 — Advanced Encryption Standard (AES).
+// https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.197.pdf
+// * OpenSSL EVP_EncryptInit(3) — https://docs.openssl.org/master/man3/EVP_EncryptInit/
+
+using System;
+using System.Linq;
+using Xunit;
+using Yubico.PlatformInterop;
+using static Yubico.PlatformInterop.NativeMethods;
+
+namespace Yubico.PlatformInterop.Cryptography
+{
+ public class GcmEvpInteropTests
+ {
+ // NIST SP 800-38D Test Case 13: 256-bit key, empty plaintext, empty AAD
+ // Reference: https://csrc.nist.gov/CSRC/media/Projects/Cryptographic-Algorithm-Validation-Program/documents/mac/gcmtestvectors.zip
+ private static readonly byte[] Nist_TC13_Key = new byte[]
+ {
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ };
+
+ private static readonly byte[] Nist_TC13_Nonce = new byte[]
+ {
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00,
+ };
+
+ private static readonly byte[] Nist_TC13_Tag = new byte[]
+ {
+ 0x53, 0x0F, 0x8A, 0xFB, 0xC7, 0x45, 0x36, 0xB9,
+ 0xA9, 0x63, 0xB4, 0xF1, 0xC4, 0xCB, 0x73, 0x8B,
+ };
+
+ // NIST SP 800-38D Test Case 14: 256-bit key, 16-byte plaintext, empty AAD
+ private static readonly byte[] Nist_TC14_Key = new byte[]
+ {
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ };
+
+ private static readonly byte[] Nist_TC14_Nonce = new byte[]
+ {
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00,
+ };
+
+ private static readonly byte[] Nist_TC14_Plaintext = new byte[]
+ {
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ };
+
+ private static readonly byte[] Nist_TC14_Ciphertext = new byte[]
+ {
+ 0xCE, 0xA7, 0x40, 0x3D, 0x4D, 0x60, 0x6B, 0x6E,
+ 0x07, 0x4E, 0xC5, 0xD3, 0xBA, 0xF3, 0x9D, 0x18,
+ };
+
+ private static readonly byte[] Nist_TC14_Tag = new byte[]
+ {
+ 0xD0, 0xD1, 0xC8, 0xA7, 0x99, 0x99, 0x6B, 0xF0,
+ 0x26, 0x5B, 0x98, 0xB5, 0xD4, 0x8A, 0xB9, 0x19,
+ };
+
+ [Fact]
+ public void EvpCipherCtxNew_CreatesValidContext()
+ {
+ using SafeEvpCipherCtx ctx = NativeMethods.EvpCipherCtxNew();
+
+ Assert.NotNull(ctx);
+ Assert.False(ctx.IsInvalid);
+ }
+
+ [Fact]
+ public void EvpAes256GcmInit_ValidParameters_ReturnsSuccess()
+ {
+ using SafeEvpCipherCtx ctx = NativeMethods.EvpCipherCtxNew();
+
+ int result = NativeMethods.EvpAes256GcmInit(true, ctx, Nist_TC13_Key, Nist_TC13_Nonce);
+
+ Assert.Equal(1, result);
+ }
+
+ [Fact]
+ public void Encrypt_EmptyPlaintext_MatchesNistTC13Tag()
+ {
+ // NIST SP 800-38D Test Case 13: empty plaintext, verify tag
+ using SafeEvpCipherCtx ctx = NativeMethods.EvpCipherCtxNew();
+
+ int initResult = NativeMethods.EvpAes256GcmInit(true, ctx, Nist_TC13_Key, Nist_TC13_Nonce);
+ Assert.Equal(1, initResult);
+
+ byte[] output = new byte[16];
+ int finalResult = NativeMethods.EvpFinal(ctx, output, out int outLen);
+ Assert.Equal(1, finalResult);
+ Assert.Equal(0, outLen); // No ciphertext for empty plaintext
+
+ byte[] tag = new byte[16];
+ int tagResult = NativeMethods.EvpCipherCtxCtrl(ctx, CtrlFlag.GetTag, 16, tag);
+ Assert.Equal(1, tagResult);
+
+ Assert.Equal(Nist_TC13_Tag, tag);
+ }
+
+ [Fact]
+ public void Encrypt_16BytePlaintext_MatchesNistTC14()
+ {
+ // NIST SP 800-38D Test Case 14: 16-byte plaintext, verify ciphertext + tag
+ using SafeEvpCipherCtx ctx = NativeMethods.EvpCipherCtxNew();
+
+ int initResult = NativeMethods.EvpAes256GcmInit(true, ctx, Nist_TC14_Key, Nist_TC14_Nonce);
+ Assert.Equal(1, initResult);
+
+ byte[] ciphertext = new byte[16];
+ int updateResult = NativeMethods.EvpUpdate(ctx, ciphertext, out int ctLen, Nist_TC14_Plaintext, Nist_TC14_Plaintext.Length);
+ Assert.Equal(1, updateResult);
+ Assert.Equal(16, ctLen);
+
+ byte[] finalBuffer = new byte[16];
+ int finalResult = NativeMethods.EvpFinal(ctx, finalBuffer, out int finalLen);
+ Assert.Equal(1, finalResult);
+ Assert.Equal(0, finalLen);
+
+ byte[] tag = new byte[16];
+ int tagResult = NativeMethods.EvpCipherCtxCtrl(ctx, CtrlFlag.GetTag, 16, tag);
+ Assert.Equal(1, tagResult);
+
+ Assert.Equal(Nist_TC14_Ciphertext, ciphertext);
+ Assert.Equal(Nist_TC14_Tag, tag);
+ }
+
+ [Fact]
+ public void RoundTrip_WithAAD_DecryptsSuccessfully()
+ {
+ byte[] key = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray();
+ byte[] nonce = Enumerable.Range(0, 12).Select(i => (byte)(i * 11)).ToArray();
+ byte[] plaintext = new byte[] { 0x48, 0x65, 0x6C, 0x6C, 0x6F, 0x20, 0x57, 0x6F, 0x72, 0x6C, 0x64 }; // "Hello World"
+ byte[] aad = new byte[] { 0xAA, 0xBB, 0xCC, 0xDD };
+
+ // Encrypt
+ byte[] ciphertext = new byte[plaintext.Length];
+ byte[] tag;
+
+ using (SafeEvpCipherCtx encCtx = NativeMethods.EvpCipherCtxNew())
+ {
+ int initResult = NativeMethods.EvpAes256GcmInit(true, encCtx, key, nonce);
+ Assert.Equal(1, initResult);
+
+ // Add AAD (output = null → input is AAD)
+ int aadResult = NativeMethods.EvpUpdate(encCtx, null, out int aadLen, aad, aad.Length);
+ Assert.Equal(1, aadResult);
+
+ // Encrypt plaintext
+ int updateResult = NativeMethods.EvpUpdate(encCtx, ciphertext, out int ctLen, plaintext, plaintext.Length);
+ Assert.Equal(1, updateResult);
+ Assert.Equal(plaintext.Length, ctLen);
+
+ byte[] finalBuffer = new byte[16];
+ int finalResult = NativeMethods.EvpFinal(encCtx, finalBuffer, out int finalLen);
+ Assert.Equal(1, finalResult);
+
+ tag = new byte[16];
+ int tagResult = NativeMethods.EvpCipherCtxCtrl(encCtx, CtrlFlag.GetTag, 16, tag);
+ Assert.Equal(1, tagResult);
+ }
+
+ // Decrypt
+ byte[] decrypted = new byte[ciphertext.Length];
+
+ using (SafeEvpCipherCtx decCtx = NativeMethods.EvpCipherCtxNew())
+ {
+ int initResult = NativeMethods.EvpAes256GcmInit(false, decCtx, key, nonce);
+ Assert.Equal(1, initResult);
+
+ // Add AAD
+ int aadResult = NativeMethods.EvpUpdate(decCtx, null, out int aadLen, aad, aad.Length);
+ Assert.Equal(1, aadResult);
+
+ // Decrypt ciphertext
+ int updateResult = NativeMethods.EvpUpdate(decCtx, decrypted, out int ptLen, ciphertext, ciphertext.Length);
+ Assert.Equal(1, updateResult);
+ Assert.Equal(ciphertext.Length, ptLen);
+
+ // Set expected tag before finalize
+ int setTagResult = NativeMethods.EvpCipherCtxCtrl(decCtx, CtrlFlag.SetTag, tag.Length, tag);
+ Assert.Equal(1, setTagResult);
+
+ byte[] finalBuffer = new byte[16];
+ int finalResult = NativeMethods.EvpFinal(decCtx, finalBuffer, out int finalLen);
+ Assert.Equal(1, finalResult); // Tag verification succeeded
+ }
+
+ Assert.Equal(plaintext, decrypted);
+ }
+
+ [Fact]
+ public void Decrypt_TamperedTag_FailsAuthentication()
+ {
+ byte[] key = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray();
+ byte[] nonce = Enumerable.Range(0, 12).Select(i => (byte)(i * 11)).ToArray();
+ byte[] plaintext = new byte[] { 0x48, 0x65, 0x6C, 0x6C, 0x6F };
+ byte[] aad = new byte[] { 0xAA, 0xBB };
+
+ // Encrypt
+ byte[] ciphertext = new byte[plaintext.Length];
+ byte[] tag;
+
+ using (SafeEvpCipherCtx encCtx = NativeMethods.EvpCipherCtxNew())
+ {
+ NativeMethods.EvpAes256GcmInit(true, encCtx, key, nonce);
+ NativeMethods.EvpUpdate(encCtx, null, out _, aad, aad.Length);
+ NativeMethods.EvpUpdate(encCtx, ciphertext, out _, plaintext, plaintext.Length);
+ byte[] finalBuffer = new byte[16];
+ NativeMethods.EvpFinal(encCtx, finalBuffer, out _);
+
+ tag = new byte[16];
+ NativeMethods.EvpCipherCtxCtrl(encCtx, CtrlFlag.GetTag, 16, tag);
+ }
+
+ // Tamper with tag
+ tag[0] ^= 0x01;
+
+ // Decrypt with tampered tag
+ byte[] decrypted = new byte[ciphertext.Length];
+
+ using (SafeEvpCipherCtx decCtx = NativeMethods.EvpCipherCtxNew())
+ {
+ NativeMethods.EvpAes256GcmInit(false, decCtx, key, nonce);
+ NativeMethods.EvpUpdate(decCtx, null, out _, aad, aad.Length);
+ NativeMethods.EvpUpdate(decCtx, decrypted, out _, ciphertext, ciphertext.Length);
+ NativeMethods.EvpCipherCtxCtrl(decCtx, CtrlFlag.SetTag, tag.Length, tag);
+
+ byte[] finalBuffer = new byte[16];
+ int finalResult = NativeMethods.EvpFinal(decCtx, finalBuffer, out _);
+
+ // Tag verification must fail
+ Assert.Equal(0, finalResult);
+ }
+ }
+
+ [Fact]
+ public void Decrypt_ModifiedAAD_FailsAuthentication()
+ {
+ byte[] key = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray();
+ byte[] nonce = Enumerable.Range(0, 12).Select(i => (byte)(i * 11)).ToArray();
+ byte[] plaintext = new byte[] { 0x48, 0x65, 0x6C };
+ byte[] aad = new byte[] { 0xAA, 0xBB, 0xCC };
+
+ // Encrypt
+ byte[] ciphertext = new byte[plaintext.Length];
+ byte[] tag;
+
+ using (SafeEvpCipherCtx encCtx = NativeMethods.EvpCipherCtxNew())
+ {
+ NativeMethods.EvpAes256GcmInit(true, encCtx, key, nonce);
+ NativeMethods.EvpUpdate(encCtx, null, out _, aad, aad.Length);
+ NativeMethods.EvpUpdate(encCtx, ciphertext, out _, plaintext, plaintext.Length);
+ byte[] finalBuffer = new byte[16];
+ NativeMethods.EvpFinal(encCtx, finalBuffer, out _);
+
+ tag = new byte[16];
+ NativeMethods.EvpCipherCtxCtrl(encCtx, CtrlFlag.GetTag, 16, tag);
+ }
+
+ // Modify AAD
+ byte[] modifiedAad = (byte[])aad.Clone();
+ modifiedAad[0] ^= 0x01;
+
+ // Decrypt with modified AAD
+ byte[] decrypted = new byte[ciphertext.Length];
+
+ using (SafeEvpCipherCtx decCtx = NativeMethods.EvpCipherCtxNew())
+ {
+ NativeMethods.EvpAes256GcmInit(false, decCtx, key, nonce);
+ NativeMethods.EvpUpdate(decCtx, null, out _, modifiedAad, modifiedAad.Length);
+ NativeMethods.EvpUpdate(decCtx, decrypted, out _, ciphertext, ciphertext.Length);
+ NativeMethods.EvpCipherCtxCtrl(decCtx, CtrlFlag.SetTag, tag.Length, tag);
+
+ byte[] finalBuffer = new byte[16];
+ int finalResult = NativeMethods.EvpFinal(decCtx, finalBuffer, out _);
+
+ // AAD verification must fail
+ Assert.Equal(0, finalResult);
+ }
+ }
+ }
+}
diff --git a/Yubico.NativeShims/build-macOS-local.sh b/Yubico.NativeShims/build-macOS-local.sh
new file mode 100755
index 000000000..960d003e2
--- /dev/null
+++ b/Yubico.NativeShims/build-macOS-local.sh
@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+# Local arm64 macOS NativeShims build for development — bypasses vcpkg.
+# Uses brew OpenSSL@3 instead of vcpkg-bundled OpenSSL.
+# Replaces the dylib in the consumed NuGet cache so Phase 3+ P/Invoke
+# calls resolve the latest exports without waiting for a NuGet release.
+#
+# Reverts: mv "$CACHE.original" "$CACHE"
+# Re-apply: re-run this script.
+
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+NS_DIR="$REPO_ROOT/Yubico.NativeShims"
+
+# Currently consumed NuGet version (kept in sync with Yubico.Core.csproj).
+VERSION="$(sed -En 's/.*Yubico\.NativeShims" Version="([^"]+)".*/\1/p' "$REPO_ROOT/Yubico.Core/src/Yubico.Core.csproj" | head -1)"
+if [ -z "$VERSION" ]; then
+ echo "ERROR: could not detect consumed NativeShims version from Yubico.Core.csproj" >&2
+ exit 1
+fi
+echo "Consumed NativeShims version: $VERSION"
+
+CACHE="$HOME/.nuget/packages/yubico.nativeshims/$VERSION/runtimes/osx-arm64/native/libYubico.NativeShims.dylib"
+if [ ! -f "$CACHE" ]; then
+ echo "ERROR: NuGet cache dylib not found at $CACHE" >&2
+ echo "Run 'dotnet restore' first." >&2
+ exit 1
+fi
+
+# Configure + build
+cd "$NS_DIR"
+rm -rf build-local-arm64
+cmake -S . -B build-local-arm64 \
+ -DCMAKE_BUILD_TYPE=Release \
+ -DCMAKE_OSX_ARCHITECTURES=arm64 \
+ -DOPENSSL_ROOT_DIR=/opt/homebrew/opt/openssl@3 \
+ -DOPENSSL_USE_STATIC_LIBS=FALSE
+cmake --build build-local-arm64 -j
+
+DYLIB="$NS_DIR/build-local-arm64/libYubico.NativeShims.dylib"
+
+# Export-table parity check — fail fast if exports.llvm drifted from impl
+bash "$NS_DIR/tests/check_exports.sh" "$DYLIB"
+
+# Backup once, then override
+if [ ! -f "$CACHE.original" ]; then
+ cp "$CACHE" "$CACHE.original"
+ echo "Backup created: $CACHE.original"
+fi
+cp "$DYLIB" "$CACHE"
+echo "Override applied: $CACHE"
+
+# Sanity report
+echo "--- new dylib ---"
+file "$DYLIB"
+shasum -a 256 "$DYLIB"
diff --git a/Yubico.NativeShims/exports.gnu b/Yubico.NativeShims/exports.gnu
index cacb72d45..8712915fc 100644
--- a/Yubico.NativeShims/exports.gnu
+++ b/Yubico.NativeShims/exports.gnu
@@ -10,6 +10,7 @@
Native_EC_GROUP_new_by_curve_name;
Native_EC_POINT_free;
Native_EC_POINT_get_affine_coordinates;
+ Native_EC_POINT_is_on_curve;
Native_EC_POINT_mul;
Native_EC_POINT_new;
Native_EC_POINT_set_affine_coordinates;
diff --git a/Yubico.NativeShims/exports.llvm b/Yubico.NativeShims/exports.llvm
index 949b70e4f..52139dbf8 100644
--- a/Yubico.NativeShims/exports.llvm
+++ b/Yubico.NativeShims/exports.llvm
@@ -8,6 +8,7 @@ _Native_EC_GROUP_free
_Native_EC_GROUP_new_by_curve_name
_Native_EC_POINT_free
_Native_EC_POINT_get_affine_coordinates
+_Native_EC_POINT_is_on_curve
_Native_EC_POINT_mul
_Native_EC_POINT_new
_Native_EC_POINT_set_affine_coordinates
diff --git a/Yubico.NativeShims/exports.msvc b/Yubico.NativeShims/exports.msvc
index bd91bae85..382d662b4 100644
--- a/Yubico.NativeShims/exports.msvc
+++ b/Yubico.NativeShims/exports.msvc
@@ -9,6 +9,7 @@ EXPORTS
Native_EC_GROUP_new_by_curve_name
Native_EC_POINT_free
Native_EC_POINT_get_affine_coordinates
+ Native_EC_POINT_is_on_curve
Native_EC_POINT_mul
Native_EC_POINT_new
Native_EC_POINT_set_affine_coordinates
diff --git a/Yubico.NativeShims/ssl.ecpoint.c b/Yubico.NativeShims/ssl.ecpoint.c
index 41249902a..0d337a406 100644
--- a/Yubico.NativeShims/ssl.ecpoint.c
+++ b/Yubico.NativeShims/ssl.ecpoint.c
@@ -64,3 +64,19 @@ Native_EC_POINT_mul(
{
return EC_POINT_mul(group, r, n, q, m, ctx);
}
+
+// Validates that an EC_POINT lies on the curve defined by the EC_GROUP.
+// Returns 1 if the point is on the curve, 0 if not, -1 on error.
+// Required for ARKG-P256 input validation: untrusted public keys (pkBl, pkKem)
+// received from authenticator responses MUST be validated before use to prevent
+// invalid-curve attacks.
+int32_t
+NATIVEAPI
+Native_EC_POINT_is_on_curve(
+ const Native_EC_GROUP group,
+ const Native_EC_POINT point,
+ Native_BN_CTX ctx
+ )
+{
+ return EC_POINT_is_on_curve(group, point, ctx);
+}
diff --git a/Yubico.NativeShims/tests/check_exports.ps1 b/Yubico.NativeShims/tests/check_exports.ps1
new file mode 100644
index 000000000..66a414cff
--- /dev/null
+++ b/Yubico.NativeShims/tests/check_exports.ps1
@@ -0,0 +1,76 @@
+# Validate that a built Yubico.NativeShims.dll exports exactly the canonical
+# set of symbols defined in expected_symbols.txt.
+#
+# Usage: pwsh check_exports.ps1
+#
+# Requires: dumpbin.exe on PATH (provided by VC++ Build Tools / vcvars).
+# Catches: symbols dropped from exports.msvc, drift between the .def file and
+# the actual implementation. Works on cross-compiled binaries (arm64 DLLs
+# inspected from x64 host) because dumpbin reads file metadata.
+#
+# Exits non-zero on any mismatch (missing or extra symbol).
+
+param(
+ [Parameter(Mandatory = $true, Position = 0)]
+ [string]$LibraryPath
+)
+
+$ErrorActionPreference = 'Stop'
+
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
+$expectedFile = Join-Path $scriptDir 'expected_symbols.txt'
+
+if (-not (Test-Path $LibraryPath)) {
+ Write-Error "shared library not found: $LibraryPath"
+ exit 2
+}
+if (-not (Test-Path $expectedFile)) {
+ Write-Error "expected_symbols.txt not found at $expectedFile"
+ exit 2
+}
+
+# Load expected symbols (strip comments + blanks)
+$expected = Get-Content $expectedFile |
+ Where-Object { $_ -notmatch '^\s*#' -and $_.Trim() -ne '' } |
+ ForEach-Object { $_.Trim() } |
+ Sort-Object -Unique
+
+# Extract exported names from the DLL via dumpbin /exports.
+# Output format includes a header and a "name" column at the end of each
+# export line. We grep for lines containing a Native_* token.
+$dumpbinOutput = & dumpbin /exports $LibraryPath 2>&1
+if ($LASTEXITCODE -ne 0) {
+ Write-Error "dumpbin failed (exit $LASTEXITCODE). Make sure VC++ Build Tools are on PATH (run vcvars*.bat first)."
+ exit 2
+}
+
+$actual = $dumpbinOutput |
+ Select-String -Pattern '\bNative_\w+' -AllMatches |
+ ForEach-Object { $_.Matches.Value } |
+ Sort-Object -Unique
+
+$missing = $expected | Where-Object { $actual -notcontains $_ }
+$extra = $actual | Where-Object { $expected -notcontains $_ }
+
+Write-Host "Library: $LibraryPath"
+Write-Host "Expected: $($expected.Count) symbols"
+Write-Host "Actual: $($actual.Count) Native_* symbols"
+
+$status = 0
+if ($missing) {
+ Write-Host ""
+ Write-Host "FAIL: symbols listed in expected_symbols.txt but NOT exported by the binary:"
+ $missing | ForEach-Object { Write-Host " - $_" }
+ $status = 1
+}
+if ($extra) {
+ Write-Host ""
+ Write-Host "FAIL: Native_* symbols exported by the binary but NOT in expected_symbols.txt:"
+ $extra | ForEach-Object { Write-Host " - $_" }
+ $status = 1
+}
+
+if ($status -eq 0) {
+ Write-Host "PASS: export table matches expected symbol list"
+}
+exit $status
diff --git a/Yubico.NativeShims/tests/check_exports.sh b/Yubico.NativeShims/tests/check_exports.sh
new file mode 100755
index 000000000..f3351159b
--- /dev/null
+++ b/Yubico.NativeShims/tests/check_exports.sh
@@ -0,0 +1,81 @@
+#!/usr/bin/env bash
+# Validate that a built Yubico.NativeShims shared library exports exactly the
+# canonical set of symbols defined in expected_symbols.txt.
+#
+# Usage: check_exports.sh
+#
+# Catches: symbols dropped from exports.gnu / exports.llvm, accidental static
+# qualifier on a Native_* function, regressions where the export-file list
+# drifts from the actual implementation. Works on cross-compiled binaries
+# because nm operates on file metadata, not runtime loading.
+#
+# Exits non-zero on any mismatch (missing or extra symbol).
+
+set -euo pipefail
+
+if [ "$#" -ne 1 ]; then
+ echo "usage: $0 " >&2
+ exit 2
+fi
+
+LIB="$1"
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+EXPECTED_FILE="$SCRIPT_DIR/expected_symbols.txt"
+
+if [ ! -f "$LIB" ]; then
+ echo "ERROR: shared library not found: $LIB" >&2
+ exit 2
+fi
+if [ ! -f "$EXPECTED_FILE" ]; then
+ echo "ERROR: expected_symbols.txt not found at $EXPECTED_FILE" >&2
+ exit 2
+fi
+
+# Strip comments + blank lines from expected list
+EXPECTED=$(grep -v '^[[:space:]]*#' "$EXPECTED_FILE" | grep -v '^[[:space:]]*$' | sort -u)
+
+# Extract Native_* symbols from the binary.
+# macOS: nm -gU lists external defined; symbols carry leading underscore.
+# Linux: nm -D --defined-only lists dynamic-section defined symbols.
+UNAME="$(uname -s)"
+case "$UNAME" in
+ Darwin)
+ ACTUAL=$(nm -gU "$LIB" | awk '{print $NF}' | sed 's/^_//' | grep '^Native_' | sort -u)
+ ;;
+ Linux)
+ ACTUAL=$(nm -D --defined-only "$LIB" | awk '{print $NF}' | grep '^Native_' | sort -u)
+ ;;
+ *)
+ echo "ERROR: unsupported host OS '$UNAME' (expected Darwin or Linux)" >&2
+ exit 2
+ ;;
+esac
+
+MISSING=$(comm -23 <(echo "$EXPECTED") <(echo "$ACTUAL") || true)
+EXTRA=$(comm -13 <(echo "$EXPECTED") <(echo "$ACTUAL") || true)
+
+EXPECTED_COUNT=$(echo "$EXPECTED" | wc -l | tr -d ' ')
+ACTUAL_COUNT=$(echo "$ACTUAL" | wc -l | tr -d ' ')
+
+echo "Library: $LIB"
+echo "Expected: $EXPECTED_COUNT symbols"
+echo "Actual: $ACTUAL_COUNT Native_* symbols"
+
+STATUS=0
+if [ -n "$MISSING" ]; then
+ echo ""
+ echo "FAIL: symbols listed in expected_symbols.txt but NOT exported by the binary:"
+ echo "$MISSING" | sed 's/^/ - /'
+ STATUS=1
+fi
+if [ -n "$EXTRA" ]; then
+ echo ""
+ echo "FAIL: Native_* symbols exported by the binary but NOT in expected_symbols.txt:"
+ echo "$EXTRA" | sed 's/^/ - /'
+ STATUS=1
+fi
+
+if [ $STATUS -eq 0 ]; then
+ echo "PASS: export table matches expected symbol list"
+fi
+exit $STATUS
diff --git a/Yubico.NativeShims/tests/expected_symbols.txt b/Yubico.NativeShims/tests/expected_symbols.txt
new file mode 100644
index 000000000..1ae1e75dd
--- /dev/null
+++ b/Yubico.NativeShims/tests/expected_symbols.txt
@@ -0,0 +1,58 @@
+# Canonical list of symbols Yubico.NativeShims must export.
+# Source of truth — when adding/removing a Native_* function, update this list
+# AND the per-platform export files (exports.gnu, exports.llvm, exports.msvc).
+#
+# Format: one symbol name per line (no underscore prefix). Lines starting with
+# '#' and blank lines are ignored.
+#
+# Consumed by tests/check_exports.sh (POSIX) and tests/check_exports.ps1
+# (Windows) to validate that every built shared library exports exactly this
+# set — no missing entries, no extras.
+
+# --- BIGNUM (ssl.bignum.c) ---
+Native_BN_new
+Native_BN_bin2bn
+Native_BN_bn2bin
+Native_BN_bn2binpad
+Native_BN_clear_free
+Native_BN_num_bytes
+
+# --- EC group (ssl.ecgroup.c) ---
+Native_EC_GROUP_free
+Native_EC_GROUP_new_by_curve_name
+
+# --- EC point (ssl.ecpoint.c) ---
+Native_EC_POINT_free
+Native_EC_POINT_get_affine_coordinates
+Native_EC_POINT_is_on_curve
+Native_EC_POINT_mul
+Native_EC_POINT_new
+Native_EC_POINT_set_affine_coordinates
+
+# --- AES-256-GCM via EVP (ssl.gcmevp.c) ---
+Native_EVP_CIPHER_CTX_new
+Native_EVP_CIPHER_CTX_free
+Native_EVP_Aes256Gcm_Init
+Native_EVP_Update
+Native_EVP_Final_ex
+Native_EVP_CIPHER_CTX_ctrl
+
+# --- CMAC via EVP MAC (ssl.cmac.c) ---
+Native_CMAC_EVP_MAC_CTX_new
+Native_EVP_MAC_CTX_free
+Native_CMAC_EVP_MAC_init
+Native_CMAC_EVP_MAC_update
+Native_CMAC_EVP_MAC_final
+
+# --- PC/SC smart card (pcsc.c) ---
+Native_SCardBeginTransaction
+Native_SCardCancel
+Native_SCardConnect
+Native_SCardDisconnect
+Native_SCardEndTransaction
+Native_SCardEstablishContext
+Native_SCardGetStatusChange
+Native_SCardListReaders
+Native_SCardReconnect
+Native_SCardReleaseContext
+Native_SCardTransmit
diff --git a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/AuthenticatorInfo.cs b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/AuthenticatorInfo.cs
index 2a74c484c..0503bd328 100644
--- a/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/AuthenticatorInfo.cs
+++ b/Yubico.YubiKey/src/Yubico/YubiKey/Fido2/AuthenticatorInfo.cs
@@ -18,6 +18,7 @@
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
+using Yubico.Core.Cryptography;
using Yubico.YubiKey.Cryptography;
using Yubico.YubiKey.Fido2.Cbor;
using Yubico.YubiKey.Fido2.Cose;