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;