From 25fb1da444249229383ca12b393c7d2bf037344b Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Wed, 4 Oct 2023 17:03:53 +0900 Subject: [PATCH] Init commit --- .gitattributes | 63 +++++ .gitignore | 188 ++++++++++++++ BTCPayServer.BoltCardTools.sln | 28 +++ src/AESKey.cs | 191 ++++++++++++++ src/AesCmac.cs | 277 ++++++++++++++++++++ src/BoltCard.cs | 32 +++ src/BoltCardTools.csproj | 15 ++ src/Extensions.cs | 74 ++++++ src/FileSettings.cs | 265 +++++++++++++++++++ src/Helpers.cs | 123 +++++++++ src/IAPDUTransport.cs | 14 ++ src/Ntag424.cs | 420 +++++++++++++++++++++++++++++++ src/NtagCommands.cs | 191 ++++++++++++++ src/NtagResponse.cs | 19 ++ src/PCSCAPDUTransport.cs | 53 ++++ src/PICCData.cs | 28 +++ src/Program.cs | 54 ++++ src/Properties.cs | 8 + src/UnexpectedStatusException.cs | 29 +++ tests/BoltCardTools.Tests.csproj | 33 +++ tests/CardReaderContext.cs | 39 +++ tests/GlobalUsings.cs | 1 + tests/UnitTest1.cs | 196 +++++++++++++++ tests/xunit.runner.json | 4 + 24 files changed, 2345 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 BTCPayServer.BoltCardTools.sln create mode 100644 src/AESKey.cs create mode 100644 src/AesCmac.cs create mode 100644 src/BoltCard.cs create mode 100644 src/BoltCardTools.csproj create mode 100644 src/Extensions.cs create mode 100644 src/FileSettings.cs create mode 100644 src/Helpers.cs create mode 100644 src/IAPDUTransport.cs create mode 100644 src/Ntag424.cs create mode 100644 src/NtagCommands.cs create mode 100644 src/NtagResponse.cs create mode 100644 src/PCSCAPDUTransport.cs create mode 100644 src/PICCData.cs create mode 100644 src/Program.cs create mode 100644 src/Properties.cs create mode 100644 src/UnexpectedStatusException.cs create mode 100644 tests/BoltCardTools.Tests.csproj create mode 100644 tests/CardReaderContext.cs create mode 100644 tests/GlobalUsings.cs create mode 100644 tests/UnitTest1.cs create mode 100644 tests/xunit.runner.json diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1ff0c42 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,63 @@ +############################################################################### +# Set default behavior to automatically normalize line endings. +############################################################################### +* text=auto + +############################################################################### +# Set default behavior for command prompt diff. +# +# This is need for earlier builds of msysgit that does not have it on by +# default for csharp files. +# Note: This is only used by command line +############################################################################### +#*.cs diff=csharp + +############################################################################### +# Set the merge driver for project and solution files +# +# Merging from the command prompt will add diff markers to the files if there +# are conflicts (Merging from VS is not affected by the settings below, in VS +# the diff markers are never inserted). Diff markers may cause the following +# file extensions to fail to load in VS. An alternative would be to treat +# these files as binary and thus will always conflict and require user +# intervention with every merge. To do so, just uncomment the entries below +############################################################################### +#*.sln merge=binary +#*.csproj merge=binary +#*.vbproj merge=binary +#*.vcxproj merge=binary +#*.vcproj merge=binary +#*.dbproj merge=binary +#*.fsproj merge=binary +#*.lsproj merge=binary +#*.wixproj merge=binary +#*.modelproj merge=binary +#*.sqlproj merge=binary +#*.wwaproj merge=binary + +############################################################################### +# behavior for image files +# +# image files are treated as binary by default. +############################################################################### +#*.jpg binary +#*.png binary +#*.gif binary + +############################################################################### +# diff behavior for common document formats +# +# Convert binary document formats to text before diffing them. This feature +# is only available from the command line. Turn it on by uncommenting the +# entries below. +############################################################################### +#*.doc diff=astextplain +#*.DOC diff=astextplain +#*.docx diff=astextplain +#*.DOCX diff=astextplain +#*.dot diff=astextplain +#*.DOT diff=astextplain +#*.pdf diff=astextplain +#*.PDF diff=astextplain +#*.rtf diff=astextplain +#*.RTF diff=astextplain diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd05f11 --- /dev/null +++ b/.gitignore @@ -0,0 +1,188 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.sln.docstates +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +x64/ +bld/ +[Bb]in/ +[Oo]bj/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +#NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding addin-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +*.ncrunch* +_NCrunch_* +.*crunch*.local.xml + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml + +# NuGet Packages Directory +packages/ +*.nupkg +NBitcoin.Mono.nuspec +NBitcoin.nuspec + +## TODO: If the tool you use requires repositories.config uncomment the next line +#!packages/repositories.config + +# Enable "build/" folder in the NuGet Packages folder since NuGet packages use it for MSBuild targets +# This line needs to be after the ignore of the build folder (and the packages folder if the line above has been uncommented) +!packages/build/ + +# Windows Azure Build Output +csx/ +*.build.csdef + +# Windows Store app package directory +AppPackages/ + +# Others +sql/ +*.Cache +ClientBin/ +[Ss]tyle[Cc]op.* +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.pfx +*.publishsettings +node_modules/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file to a newer +# Visual Studio version. Backup files are not needed, because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ +/.vs/config/applicationhost.config +/NBitcoin.Portable/NBitcoin(MonoAndroid).csproj.bak +/NBitcoin.Portable/NBitcoin(Mono).csproj.bak + +# .NET Core projects +.vs/ +project.lock.json +.vscode/ +*.zip +.idea diff --git a/BTCPayServer.BoltCardTools.sln b/BTCPayServer.BoltCardTools.sln new file mode 100644 index 0000000..485ad66 --- /dev/null +++ b/BTCPayServer.BoltCardTools.sln @@ -0,0 +1,28 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BoltCardTools", "src\BoltCardTools.csproj", "{1AF51732-C36B-48DE-BEE5-4C5AA6EB0DAB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BoltCardTools.Tests", "tests\BoltCardTools.Tests.csproj", "{4ABD12CC-3B7C-4C8C-8576-19F32C402584}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {1AF51732-C36B-48DE-BEE5-4C5AA6EB0DAB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1AF51732-C36B-48DE-BEE5-4C5AA6EB0DAB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1AF51732-C36B-48DE-BEE5-4C5AA6EB0DAB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1AF51732-C36B-48DE-BEE5-4C5AA6EB0DAB}.Release|Any CPU.Build.0 = Release|Any CPU + {4ABD12CC-3B7C-4C8C-8576-19F32C402584}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4ABD12CC-3B7C-4C8C-8576-19F32C402584}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4ABD12CC-3B7C-4C8C-8576-19F32C402584}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4ABD12CC-3B7C-4C8C-8576-19F32C402584}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/src/AESKey.cs b/src/AESKey.cs new file mode 100644 index 0000000..c9f8769 --- /dev/null +++ b/src/AESKey.cs @@ -0,0 +1,191 @@ +using System; +using System.Diagnostics.Metrics; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using static BoltCardTools.Helpers; + +namespace BoltCardTools +{ + public class AESKey + { + public const int BLOCK_SIZE = 16; + byte[] _bytes; + public byte[] ToBytes() => _bytes.ToArray(); + public static AESKey Parse(string hex) + { + return new AESKey(hex.HexToBytes()); + } + AESKey(byte[] bytes) + { + AssertKeySize(bytes); + _bytes = bytes; + } + public AESKey(ReadOnlySpan bytes) + { + AssertKeySize(bytes); + _bytes = bytes.ToArray(); + } + + private static void AssertKeySize(ReadOnlySpan bytes) + { + if (bytes.Length != BLOCK_SIZE) + throw new ArgumentException($"AES key must be {BLOCK_SIZE} bytes long"); + } + + public AESKey Derive(byte[] input) + { + return new AESKey(CMac(input)); + } + public byte[] Decrypt(ReadOnlySpan cypherText, byte[]? iv = null) + { + iv ??= new byte[BLOCK_SIZE]; + using MemoryStream ms = new MemoryStream(cypherText.ToArray()); + using var aes = Aes.Create(); + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.None; + using var cs = new CryptoStream(ms, aes.CreateDecryptor(_bytes, iv), CryptoStreamMode.Read); + var output = new byte[cypherText.Length]; + cs.Read(output); + return output; + } + public byte[] Encrypt(byte[] clearText, byte[]? iv = null, CipherMode mode = CipherMode.CBC) + { + return AesEncrypt(_bytes, iv ?? new byte[BLOCK_SIZE], clearText, mode); + } + public byte[] GetSunMac(PICCData piccData, byte[]? payload = null) + { + return GetSunMac(piccData.Uid, piccData.Counter, payload); + } + public byte[] GetSunMac(byte[]? uid, int? counter, byte[]? payload = null) + { + payload ??= Array.Empty(); + var derived = SesSDMFileReadMACKey(uid, counter); + var cmac = derived.CMac(payload); + return Truncate(cmac); + } + public PICCData DecryptSun(byte[] data) + { + return PICCData.Create(Decrypt(data)); + } + AESKey SesSDMFileReadMACKey(byte[]? uid, int? counter) + { + int i = 0; + var sv2 = new byte[16]; + sv2[i++] = 0x3c; + sv2[i++] = 0xc3; + sv2[i++] = 0x00; + sv2[i++] = 0x01; + sv2[i++] = 0x00; + sv2[i++] = 0x80; + if (uid is byte[]) + { + sv2[i++] = uid[0]; + sv2[i++] = uid[1]; + sv2[i++] = uid[2]; + sv2[i++] = uid[3]; + sv2[i++] = uid[4]; + sv2[i++] = uid[5]; + sv2[i++] = uid[6]; + } + if (counter is int) + { + sv2[i++] = (byte)counter; + sv2[i++] = (byte)(counter >> 8); + sv2[i++] = (byte)(counter >> 16); + } + return Derive(sv2); + } + //AESKey SesSDMFileReadENCKey(AESKey key, byte[] uid, int counter) + //{ + // byte[] sv1 = + // { + // 0xc3, 0x3c, 0x00, 0x01, 0x00, 0x80, + // uid[0], uid[1], uid[2], uid[3], uid[4], uid[5], uid[6], + // ((byte)counter), (byte)(counter >> 8), (byte)(counter >> 16) + // }; + // return key.Derive(sv1); + //} + private static byte[] AesEncrypt(byte[] key, byte[] iv, byte[] data, CipherMode mode = CipherMode.CBC) + { + using MemoryStream ms = new MemoryStream(); + using var aes = Aes.Create(); + aes.Mode = mode; + aes.Padding = PaddingMode.None; + + using var cs = new CryptoStream(ms, aes.CreateEncryptor(key, iv), CryptoStreamMode.Write); + cs.Write(data, 0, data.Length); + cs.FlushFinalBlock(); + + return ms.ToArray(); + } + + public byte[] CMac(byte[] data) + { + var key = _bytes; + // SubKey generation + // step 1, AES-128 with key K is applied to an all-zero input block. + byte[] L = AesEncrypt(key, new byte[16], new byte[16]); + + // step 2, K1 is derived through the following operation: + byte[] + FirstSubkey = + RotateLeft(L); //If the most significant bit of L is equal to 0, K1 is the left-shift of L by 1 bit. + if ((L[0] & 0x80) == 0x80) + FirstSubkey[15] ^= + 0x87; // Otherwise, K1 is the exclusive-OR of const_Rb and the left-shift of L by 1 bit. + + // step 3, K2 is derived through the following operation: + byte[] + SecondSubkey = + RotateLeft(FirstSubkey); // If the most significant bit of K1 is equal to 0, K2 is the left-shift of K1 by 1 bit. + if ((FirstSubkey[0] & 0x80) == 0x80) + SecondSubkey[15] ^= + 0x87; // Otherwise, K2 is the exclusive-OR of const_Rb and the left-shift of K1 by 1 bit. + + // MAC computing + if (((data.Length != 0) && (data.Length % 16 == 0)) == true) + { + // If the size of the input message block is equal to a positive multiple of the block size (namely, 128 bits), + // the last block shall be exclusive-OR'ed with K1 before processing + for (int j = 0; j < FirstSubkey.Length; j++) + data[data.Length - 16 + j] ^= FirstSubkey[j]; + } + else + { + // Otherwise, the last block shall be padded with 10^i + byte[] padding = new byte[16 - data.Length % 16]; + padding[0] = 0x80; + + data = data.Concat(padding.AsEnumerable()).ToArray(); + + // and exclusive-OR'ed with K2 + for (int j = 0; j < SecondSubkey.Length; j++) + data[data.Length - 16 + j] ^= SecondSubkey[j]; + } + + // The result of the previous process will be the input of the last encryption. + byte[] encResult = AesEncrypt(key, new byte[16], data); + + byte[] HashValue = new byte[16]; + Array.Copy(encResult, encResult.Length - HashValue.Length, HashValue, 0, HashValue.Length); + + return HashValue; + } + + static byte[] RotateLeft(byte[] b) + { + byte[] r = new byte[b.Length]; + byte carry = 0; + + for (int i = b.Length - 1; i >= 0; i--) + { + ushort u = (ushort)(b[i] << 1); + r[i] = (byte)((u & 0xff) + carry); + carry = (byte)((u & 0xff00) >> 8); + } + + return r; + } + } +} diff --git a/src/AesCmac.cs b/src/AesCmac.cs new file mode 100644 index 0000000..b4245ae --- /dev/null +++ b/src/AesCmac.cs @@ -0,0 +1,277 @@ +// SPDX-FileCopyrightText: 2022 Frans van Dorsselaer +// +// SPDX-License-Identifier: MIT +// https://github.com/dorssel/dotnet-aes-extra/ + +using System; +using System.Security.Cryptography; + +namespace BoltCardTools +{ + /// + /// Computes a Cipher-based Message Authentication Code (CMAC) by using the symmetric key AES block cipher. + /// + public sealed class AesCmac + : KeyedHashAlgorithm + { + const int BLOCKSIZE = 16; // bytes + + /// + /// This static override defaults to . + public static new KeyedHashAlgorithm Create() => new AesCmac(); + + /// + public static new KeyedHashAlgorithm? Create(string algorithmName) + { + if (algorithmName == null) + { + throw new ArgumentNullException(nameof(algorithmName)); + } + return algorithmName == nameof(AesCmac) ? Create() : null; + } + + /// + /// Initializes a new instance of the class with a randomly generated key. + /// + public AesCmac() + { + AesEcb = Aes.Create(); + AesEcb.Mode = CipherMode.ECB; // DevSkim: ignore DS187371 + AesEcb.Padding = PaddingMode.None; + CryptoTransform = AesEcb.CreateEncryptor(); + HashSizeValue = BLOCKSIZE * 8; + } + + /// + /// Initializes a new instance of the class with the specified key data. + /// + /// The secret key for AES-CMAC algorithm. + public AesCmac(byte[] key) + : this() + { + Key = key; + } + + void ZeroizeState() + { + CryptographicOperations.ZeroMemory(C); + CryptographicOperations.ZeroMemory(Partial); + } + + #region IDisposable + bool IsDisposed; + + /// + protected override void Dispose(bool disposing) + { + if (!IsDisposed) + { + if (disposing) + { + CryptoTransform.Dispose(); + AesEcb.Dispose(); + ZeroizeState(); + } + IsDisposed = true; + } + base.Dispose(disposing); + } + #endregion + + /// + public override byte[] Key + { + get => AesEcb.Key; + set + { + CryptoTransform.Dispose(); + AesEcb.Key = value; + CryptoTransform = AesEcb.CreateEncryptor(); + } + } + + readonly Aes AesEcb; + ICryptoTransform CryptoTransform; + + // See: NIST SP 800-38B, Section 6.2, Step 5 + readonly byte[] C = new byte[BLOCKSIZE]; + + // See: NIST SP 800-38B, Section 4.2.2 + // + // In-place: X = CIPH_K(X) + void CIPH_K_InPlace(byte[] X_Base, int X_Offset = 0) + { + CryptoTransform.TransformBlock(X_Base, X_Offset, BLOCKSIZE, X_Base, X_Offset); + } + + // See: NIST SP 800-38B, Section 6.1 + // + // Returns: first ? K1 : K2 + byte[] SUBK(bool first) + { + var X = new byte[BLOCKSIZE]; + // Step 1: X has the role of L + CIPH_K_InPlace(X); + // Step 2: X has the role of K1 + dbl_InPlace(X); + if (first) + { + // Step 4: return K1 + return X; + } + // Step 3: X has the role of K1 + dbl_InPlace(X); + // Step 4: return K2 + return X; + } + + /// + public override void Initialize() + { + // See: NIST SP 800-38B, Section 6.2, Step 5 + ZeroizeState(); + + PartialLength = 0; + } + + readonly byte[] Partial = new byte[BLOCKSIZE]; + int PartialLength; + + // See: NIST SP 800-38B, Section 6.2, Step 6 + void AddBlock(byte[] blockBase, int blockOffset = 0) + { + xor_InPlace(C, 0, blockBase, blockOffset, BLOCKSIZE); + CIPH_K_InPlace(C); + } + + /// + protected override void HashCore(byte[] array, int ibStart, int cbSize) + { + if (cbSize == 0) + { + return; + } + + // If we have a non-empty && non-full Partial block already -> append to that first. + if ((0 < PartialLength) && (PartialLength < BLOCKSIZE)) + { + var count = Math.Min(cbSize, BLOCKSIZE - PartialLength); + Array.Copy(array, ibStart, Partial, PartialLength, count); + PartialLength += count; + if (count == cbSize) + { + // No more data supplied, we're done. Even if we filled up Partial completely, + // because we don't know if it will be the final block. + return; + } + ibStart += count; + cbSize -= count; + } + + // We get here only if Partial is either empty or full (i.e. we are block-aligned) && there is more to "hash". + if (PartialLength == BLOCKSIZE) + { + // Since there is more to hash, this is not the final block. + // See: NIST SP 800-38B, Section 6.2, Steps 3 and 6 + AddBlock(Partial); + PartialLength = 0; + } + + // We get here only if Partial is empty && there is more to "hash". + // Add complete, non-final blocks. Never add the last block given in this call since we don't know if that will be the final block. + for (int i = 0, nonFinalBlockCount = (cbSize - 1) / BLOCKSIZE; i < nonFinalBlockCount; i++) + { + // See: NIST SP 800-38B, Section 6.2, Steps 3 and 6 + AddBlock(array, ibStart); + ibStart += BLOCKSIZE; + cbSize -= BLOCKSIZE; + } + + // Save what we have left (we always have some, by construction). + Array.Copy(array, ibStart, Partial, 0, cbSize); + PartialLength = cbSize; + } + + /// + protected override byte[] HashFinal() + { + // Partial now has the role of Mn* + if (PartialLength == BLOCKSIZE) + { + // See: NIST SP 800-38B, Section 6.2, Step 1: K1 + var K1 = SUBK(true); + xor_InPlace(Partial, 0, K1, 0, BLOCKSIZE); + // Partial now has the role of Mn + } + else + { + // Add padding + Partial[PartialLength] = 0x80; + for (var i = PartialLength + 1; i < BLOCKSIZE; ++i) + { + Partial[i] = 0x00; + } + // See: NIST SP 800-38B, Section 6.2, Step 1: K2 + var K2 = SUBK(false); + xor_InPlace(Partial, 0, K2, 0, BLOCKSIZE); + // Partial now has the role of Mn + } + // See: NIST SP 800-38B, Section 6.2, Steps 4 and 6 + AddBlock(Partial); + PartialLength = 0; + + // NOTE: KeyedHashAlgorithm exposes the returned array reference as the + // Hash property, so we must *not* return C itself as it may be reused. + var cmac = new byte[BLOCKSIZE]; + C.CopyTo(cmac, 0); + + ZeroizeState(); + + return cmac; + } + + static void xor_InPlace(byte[] X_Base, int X_Offset, byte[] Y_Base, int Y_Offset, int count) + { + for (var i = 0; i < count; ++i) + { + X_Base[X_Offset + i] ^= Y_Base[Y_Offset + i]; + } + } + // See: NIST SP 800-38B, Section 6.1 + // See: RFC 5297, Section 2.1 + // + // In place: S = dbl(S) + static void dbl_InPlace(byte[] S) + { + // See: NIST SP 800-38B, Section 5.3 + // See: RFC 5297, Section 2.3 + const int Rb = 0b10000111; + + // See: NIST SP 800-38B, Section 6.1, Step 2/3 + if (LeftShiftOne_InPlace(S)) + { + S[BLOCKSIZE - 1] ^= Rb; + } + } + + // See: NIST SP 800-38B, Section 4.2 + // + // In place: X = (X << 1) + // Returns final carry. + static bool LeftShiftOne_InPlace(byte[] X) + { + var carry = false; + for (var i = X.Length - 1; i >= 0; --i) + { + var nextCarry = (X[i] & 0x80) != 0; + _ = unchecked(X[i] <<= 1); + if (carry) + { + X[i] |= 1; + } + carry = nextCarry; + } + return carry; + } + } +} diff --git a/src/BoltCard.cs b/src/BoltCard.cs new file mode 100644 index 0000000..d40c22d --- /dev/null +++ b/src/BoltCard.cs @@ -0,0 +1,32 @@ +using NdefLibrary.Ndef; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace BoltCardTools +{ + public class BoltCard + { + public BoltCard(Ntag424 ntag) + { + Ntag = ntag; + } + public Ntag424 Ntag { get; } + + + private static byte[] CreateNDefMessage(string lnUrl) + { + var queryString = "p=00000000000000000000000000000000&c=0000000000000000"; + var templateUrl = lnUrl.Contains("?", StringComparison.OrdinalIgnoreCase) ? + $"{lnUrl}?{queryString}" : + $"{lnUrl}&{queryString}"; + var message = new NdefLibrary.Ndef.NdefMessage + { + new NdefUriRecord() { Uri = templateUrl } + }; + return message.ToByteArray(); + } + } +} diff --git a/src/BoltCardTools.csproj b/src/BoltCardTools.csproj new file mode 100644 index 0000000..e238613 --- /dev/null +++ b/src/BoltCardTools.csproj @@ -0,0 +1,15 @@ + + + + Exe + net7.0 + enable + + + + + + + + + diff --git a/src/Extensions.cs b/src/Extensions.cs new file mode 100644 index 0000000..bec2902 --- /dev/null +++ b/src/Extensions.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading.Tasks; +using static System.Runtime.InteropServices.JavaScript.JSType; + +namespace BoltCardTools +{ + internal static class Extensions + { + public static bool IsSame(this byte[] arr1, byte[] arr2) + { + if (arr1.Length != arr2.Length) + return false; + for (int i = 0; i < arr1.Length; i++) + if (arr1[i] != arr2[i]) + return false; + return true; + } + public static string ToHex(this byte[] data) + { + return Convert.ToHexString(data, 0, data.Length).ToLowerInvariant(); + } + public static byte[] HexToBytes(this string hex) + { + if (hex == null) + throw new ArgumentNullException(nameof(hex)); + if (hex.Length % 2 == 1) + throw new FormatException("Invalid Hex String"); + if (hex.Length < (hex.Length >> 1)) + throw new ArgumentException("output should be bigger", nameof(hex)); + var output = new byte[hex.Length / 2]; + try + { + for (int i = 0, j = 0; i < hex.Length; i += 2, j++) + { + var a = IsDigitCore(hex[i]); + var b = IsDigitCore(hex[i + 1]); + if (a == 0xff || b == 0xff) + throw new FormatException("Invalid Hex String"); + output[j] = (byte)(((uint)a << 4) | (uint)b); + } + } + catch (IndexOutOfRangeException) { throw new FormatException("Invalid Hex String"); } + return output; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static byte IsDigitCore(char c) + { + return CharToHexLookup[c]; + } + static byte[] CharToHexLookup => new byte[] + { + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // 15 + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // 31 + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // 47 + 0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // 63 + 0xFF, 0xA, 0xB, 0xC, 0xD, 0xE, 0xF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // 79 + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // 95 + 0xFF, 0xa, 0xb, 0xc, 0xd, 0xe, 0xf, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // 111 + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // 127 + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // 143 + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // 159 + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // 175 + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // 191 + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // 207 + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // 223 + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // 239 + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF // 255 +}; + } +} diff --git a/src/FileSettings.cs b/src/FileSettings.cs new file mode 100644 index 0000000..7982c5a --- /dev/null +++ b/src/FileSettings.cs @@ -0,0 +1,265 @@ +using System; +using System.Collections.Generic; + +namespace BoltCardTools +{ + public enum AccessCondition + { + Key0 = 0, Key1 = 1, Key2 = 2, Key3 = 3, Key4 = 4, + Free = 0x0E, Never = 0x0F + } + public enum AccessRight + { + Read, + Write, + Change + } + + public record SDMAccessRights + { + public SDMAccessRights() + { + MetaRead = AccessCondition.Never; + FileRead = AccessCondition.Never; + CtrRet = AccessCondition.Never; + } + public SDMAccessRights(ReadOnlySpan conditions) + { + MetaRead = (AccessCondition)((byte)(conditions[1] & 0b1111_0000) >> 4); + FileRead = (AccessCondition)(conditions[1] & 0b0000_1111); + CtrRet = (AccessCondition)(conditions[0] & 0b0000_1111); + } + public AccessCondition MetaRead { get; set; } + public AccessCondition FileRead { get; set; } + public AccessCondition CtrRet { get; set; } + public byte[] ToBytes() + { + var fileSettings = new byte[2]; + fileSettings[1] = (byte)(((byte)MetaRead << 4) | ((byte)FileRead & 0b0000_1111)); + fileSettings[0] = (byte)(((byte)CtrRet & 0b0000_1111) | (byte)0xf0); + return fileSettings; + } + } + public record AccessRights + { + public AccessRights(ReadOnlySpan conditions) + { + Read = (AccessCondition)((byte)(conditions[1] & 0b1111_0000) >> 4); + Write = (AccessCondition)(conditions[1] & 0b0000_1111); + ReadWrite = (AccessCondition)((byte)(conditions[0] & 0b1111_0000) >> 4); + Change = (AccessCondition)(conditions[0] & 0b0000_1111); + } + public AccessRights() : this(DataFile.NDEF) + { + + } + public AccessRights(DataFile file) + { + if (file == DataFile.CC) + { + Read = AccessCondition.Free; + Write = AccessCondition.Key0; + ReadWrite = AccessCondition.Key0; + Change = AccessCondition.Key0; + } + else if (file == DataFile.NDEF) + { + Read = AccessCondition.Free; + Write = AccessCondition.Free; + ReadWrite = AccessCondition.Free; + Change = AccessCondition.Key0; + } + else + { + Read = AccessCondition.Key2; + Write = AccessCondition.Key3; + ReadWrite = AccessCondition.Key3; + Change = AccessCondition.Key0; + } + } + + public bool IsAllowed(int keyNo, AccessRight right) + { + var keyno = (AccessCondition)keyNo; + if (right == AccessRight.Change) + return Change == keyno || Change == AccessCondition.Free; + if (ReadWrite == keyno || ReadWrite == AccessCondition.Free) + return true; + if (right == AccessRight.Write) + return Write == keyno || Write == AccessCondition.Free; + if (right == AccessRight.Read) + return Read == keyno || Read == AccessCondition.Free; + return false; + } + + public AccessCondition Write { get; set; } + public AccessCondition ReadWrite { get; set; } + public AccessCondition Read { get; set; } + public AccessCondition Change { get; set; } + public byte[] ToBytes() + { + var fileSettings = new byte[2]; + fileSettings[1] = (byte)(((byte)Read << 4) | ((byte)Write & 0b0000_1111)); + fileSettings[0] = (byte)(((byte)ReadWrite << 4) | ((byte)Change & 0b0000_1111)); + return fileSettings; + } + } + + public record FileSettings + { + public FileSettings(DataFile file) + { + SDMMirroring = false; + CommMode = CommMode.Plain; + AccessRights = new AccessRights(file); + } + public FileSettings(byte[] fileSettings, bool update) + { + int i = 0; + if (!update) + { + i++; + } + SDMMirroring = (fileSettings[i] & 0b0100_0000) != 0; + CommMode = (fileSettings[i] & 0b0000_0011) switch + { + 0b01 => CommMode.MAC, + 0b11 => CommMode.Full, + _ => CommMode.Plain + }; + i++; + AccessRights = new AccessRights(fileSettings[i..(i + 2)]); + i += 2; + if (!update) + { + i += 3; // Size + } + if (!SDMMirroring) + return; + var sdmOptions = fileSettings[i]; + SDMUID = (0b1000_0000 & sdmOptions) != 0; + SDMReadCtr = (0b0100_0000 & sdmOptions) != 0; + SDMReadCtrLimit = (0b0010_0000 & sdmOptions) != 0; + SDMENCFileData = (0b0001_0000 & sdmOptions) != 0; + i++; + SDMAccessRights = new SDMAccessRights(fileSettings[i..(i + 2)]); + i += 2; + if (SDMUID && SDMAccessRights.MetaRead == AccessCondition.Free) + { + UIDOffset = Helpers.BytesToUIntLE(fileSettings[i..(i + 3)]); + i += 3; + } + if (SDMReadCtr && SDMAccessRights.MetaRead == AccessCondition.Free) + { + SDMReadCtrOffset = Helpers.BytesToUIntLE(fileSettings[i..(i + 3)]); + i += 3; + } + if (SDMAccessRights.MetaRead != AccessCondition.Free && SDMAccessRights.MetaRead != AccessCondition.Never) + { + PICCDataOffset = Helpers.BytesToUIntLE(fileSettings[i..(i + 3)]); + i += 3; + } + if (SDMAccessRights.FileRead != AccessCondition.Never) + { + SDMMACInputOffset = Helpers.BytesToUIntLE(fileSettings[i..(i + 3)]); + i += 3; + } + if (SDMAccessRights.FileRead != AccessCondition.Never && SDMENCFileData) + { + SDMENCOffset = Helpers.BytesToUIntLE(fileSettings[i..(i + 3)]); + i += 3; + SDMENCLength = Helpers.BytesToUIntLE(fileSettings[i..(i + 3)]); + i += 3; + } + if (SDMAccessRights.FileRead != AccessCondition.Never) + { + SDMMACOffset = Helpers.BytesToUIntLE(fileSettings[i..(i + 3)]); + i += 3; + } + if (SDMReadCtrLimit) + { + SDMReadCtrLimitValue = Helpers.BytesToUIntLE(fileSettings[i..(i + 3)]); + i += 3; + } + } + + public bool SDMUID { get; set; } + public bool SDMReadCtr { get; set; } + public bool SDMReadCtrLimit { get; set; } + public int SDMReadCtrLimitValue { get; set; } + public bool SDMENCFileData { get; set; } + public SDMAccessRights SDMAccessRights { get; set; } = new SDMAccessRights(); + + public byte[] ToBytes() + { + List output = new List(); + byte fileOptions = CommMode switch + { + CommMode.MAC => 0b01, + CommMode.Full => 0b11, + _ => 0b00 + }; + if (SDMMirroring) + fileOptions |= 0b0100_0000; + output.Add(fileOptions); + output.AddRange(AccessRights.ToBytes()); + if (!SDMMirroring) + return output.ToArray(); + + var sdmOptions = 0x01; + if (SDMUID) + sdmOptions |= 0b1000_0000; + if (SDMReadCtr) + sdmOptions |= 0b0100_0000; + if (SDMReadCtrLimit) + sdmOptions |= 0b0010_0000; + if (SDMENCFileData) + sdmOptions |= 0b0001_0000; + output.Add((byte)sdmOptions); + output.AddRange(SDMAccessRights.ToBytes()); + if (SDMUID && SDMAccessRights.MetaRead == AccessCondition.Free) + { + output.AddRange(Helpers.UIntTo3BytesLE(UIDOffset)); + } + if (SDMReadCtr && SDMAccessRights.MetaRead == AccessCondition.Free) + { + output.AddRange(Helpers.UIntTo3BytesLE(SDMReadCtrOffset)); + } + if (SDMAccessRights.MetaRead != AccessCondition.Free && SDMAccessRights.MetaRead != AccessCondition.Never) + { + output.AddRange(Helpers.UIntTo3BytesLE(PICCDataOffset)); + } + if (SDMAccessRights.FileRead != AccessCondition.Never) + { + output.AddRange(Helpers.UIntTo3BytesLE(SDMMACInputOffset)); + } + if (SDMAccessRights.FileRead != AccessCondition.Never && SDMENCFileData) + { + output.AddRange(Helpers.UIntTo3BytesLE(SDMENCOffset)); + output.AddRange(Helpers.UIntTo3BytesLE(SDMENCLength)); + } + if (SDMAccessRights.FileRead != AccessCondition.Never) + { + output.AddRange(Helpers.UIntTo3BytesLE(SDMMACOffset)); + } + if (SDMReadCtrLimit) + { + output.AddRange(Helpers.UIntTo3BytesLE(SDMReadCtrLimitValue)); + } + return output.ToArray(); + } + public int SDMMACOffset { get; set; } + public int SDMMACInputOffset { get; set; } + public int SDMENCLength { get; set; } + public int SDMENCOffset { get; set; } + public int PICCDataOffset { get; set; } + public int SDMReadCtrOffset { get; set; } + public int UIDOffset { get; set; } + public AccessRights AccessRights { get; set; } + + public bool IsAllowed(int keyNo, AccessRight right) => AccessRights.IsAllowed(keyNo, right); + + public bool SDMMirroring { get; set; } + public CommMode CommMode { get; set; } + } +} diff --git a/src/Helpers.cs b/src/Helpers.cs new file mode 100644 index 0000000..827083d --- /dev/null +++ b/src/Helpers.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace BoltCardTools +{ + internal class Helpers + { + internal static byte[] Truncate(byte[] cmac) + { + var halfMac = new byte[cmac.Length / 2]; + for (var i = 1; i < cmac.Length; i += 2) + { + halfMac[i >> 1] = cmac[i]; + } + return halfMac; + } + public static byte[] RotateLeft(byte[] bytesArr) + { + byte first = bytesArr[0]; + byte[] rotatedArray = new byte[bytesArr.Length]; + Array.Copy(bytesArr, 1, rotatedArray, 0, bytesArr.Length - 1); + rotatedArray[bytesArr.Length - 1] = first; + return rotatedArray; + } + internal static byte[] XOR(byte[] a1, byte[] a2) + { + byte[] res = new byte[a1.Length]; + for (int i = 0; i < a1.Length; i++) + res[i] = (byte)(a1[i] ^ a2[i]); + return res; + } + + internal static uint BytesToUIntLE(ReadOnlySpan bytes) + { + return bytes[0] + ((uint)bytes[1] << 8) + ((uint)bytes[2] << 16) + ((uint)bytes[3] << 24); + } + internal static byte[] UIntToBytesLE(uint value) + { + return new byte[] { (byte)(value & 0xff), (byte)((value >> 8) & 0xff), (byte)((value >> 16) & 0xff), (byte)((value >> 24) & 0xff) }; + } + public static int BytesToUIntLE(byte[] value) + { + return value[0] + ((int)value[1] << 8) + ((int)value[2] << 16); + } + public static byte[] UIntTo3BytesLE(int value) + { + return new byte[] { (byte)(value & 0xff), (byte)((value >> 8) & 0xff), (byte)((value >> 16) & 0xff) }; + } + public static byte[] UIntTo3BytesBE(int value) + { + return new byte[] { (byte)((value >> 16) & 0xff), (byte)((value >> 8) & 0xff), (byte)(value & 0xff) }; + } + internal static byte[] UShortToBytesLE(int value) + { + if (value > ushort.MaxValue) + return new byte[] { 0xFF, 0xFF }; + return new byte[] { (byte)(value & 0xff), (byte)((value >> 8) & 0xff) }; + } + + internal static byte[] Concat(params byte[]?[] arrays) + { + var res = new byte[arrays.Sum(a => a?.Length ?? 0)]; + int offset = 0; + foreach (var a in arrays) + { + if (a is null) + continue; + Array.Copy(a, 0, res, offset, a.Length); + offset += a.Length; + } + return res; + } + + //https://github.com/alexgorbatchev/crc/blob/master/src/calculators/crcjam.ts + + readonly static uint[] TABLE = new uint[]{ + 0x00000000u, 0x77073096u, 0xee0e612cu, 0x990951bau, 0x076dc419u, 0x706af48fu, 0xe963a535u, 0x9e6495a3u, + 0x0edb8832u, 0x79dcb8a4u, 0xe0d5e91eu, 0x97d2d988u, 0x09b64c2bu, 0x7eb17cbdu, 0xe7b82d07u, 0x90bf1d91u, + 0x1db71064u, 0x6ab020f2u, 0xf3b97148u, 0x84be41deu, 0x1adad47du, 0x6ddde4ebu, 0xf4d4b551u, 0x83d385c7u, + 0x136c9856u, 0x646ba8c0u, 0xfd62f97au, 0x8a65c9ecu, 0x14015c4fu, 0x63066cd9u, 0xfa0f3d63u, 0x8d080df5u, + 0x3b6e20c8u, 0x4c69105eu, 0xd56041e4u, 0xa2677172u, 0x3c03e4d1u, 0x4b04d447u, 0xd20d85fdu, 0xa50ab56bu, + 0x35b5a8fau, 0x42b2986cu, 0xdbbbc9d6u, 0xacbcf940u, 0x32d86ce3u, 0x45df5c75u, 0xdcd60dcfu, 0xabd13d59u, + 0x26d930acu, 0x51de003au, 0xc8d75180u, 0xbfd06116u, 0x21b4f4b5u, 0x56b3c423u, 0xcfba9599u, 0xb8bda50fu, + 0x2802b89eu, 0x5f058808u, 0xc60cd9b2u, 0xb10be924u, 0x2f6f7c87u, 0x58684c11u, 0xc1611dabu, 0xb6662d3du, + 0x76dc4190u, 0x01db7106u, 0x98d220bcu, 0xefd5102au, 0x71b18589u, 0x06b6b51fu, 0x9fbfe4a5u, 0xe8b8d433u, + 0x7807c9a2u, 0x0f00f934u, 0x9609a88eu, 0xe10e9818u, 0x7f6a0dbbu, 0x086d3d2du, 0x91646c97u, 0xe6635c01u, + 0x6b6b51f4u, 0x1c6c6162u, 0x856530d8u, 0xf262004eu, 0x6c0695edu, 0x1b01a57bu, 0x8208f4c1u, 0xf50fc457u, + 0x65b0d9c6u, 0x12b7e950u, 0x8bbeb8eau, 0xfcb9887cu, 0x62dd1ddfu, 0x15da2d49u, 0x8cd37cf3u, 0xfbd44c65u, + 0x4db26158u, 0x3ab551ceu, 0xa3bc0074u, 0xd4bb30e2u, 0x4adfa541u, 0x3dd895d7u, 0xa4d1c46du, 0xd3d6f4fbu, + 0x4369e96au, 0x346ed9fcu, 0xad678846u, 0xda60b8d0u, 0x44042d73u, 0x33031de5u, 0xaa0a4c5fu, 0xdd0d7cc9u, + 0x5005713cu, 0x270241aau, 0xbe0b1010u, 0xc90c2086u, 0x5768b525u, 0x206f85b3u, 0xb966d409u, 0xce61e49fu, + 0x5edef90eu, 0x29d9c998u, 0xb0d09822u, 0xc7d7a8b4u, 0x59b33d17u, 0x2eb40d81u, 0xb7bd5c3bu, 0xc0ba6cadu, + 0xedb88320u, 0x9abfb3b6u, 0x03b6e20cu, 0x74b1d29au, 0xead54739u, 0x9dd277afu, 0x04db2615u, 0x73dc1683u, + 0xe3630b12u, 0x94643b84u, 0x0d6d6a3eu, 0x7a6a5aa8u, 0xe40ecf0bu, 0x9309ff9du, 0x0a00ae27u, 0x7d079eb1u, + 0xf00f9344u, 0x8708a3d2u, 0x1e01f268u, 0x6906c2feu, 0xf762575du, 0x806567cbu, 0x196c3671u, 0x6e6b06e7u, + 0xfed41b76u, 0x89d32be0u, 0x10da7a5au, 0x67dd4accu, 0xf9b9df6fu, 0x8ebeeff9u, 0x17b7be43u, 0x60b08ed5u, + 0xd6d6a3e8u, 0xa1d1937eu, 0x38d8c2c4u, 0x4fdff252u, 0xd1bb67f1u, 0xa6bc5767u, 0x3fb506ddu, 0x48b2364bu, + 0xd80d2bdau, 0xaf0a1b4cu, 0x36034af6u, 0x41047a60u, 0xdf60efc3u, 0xa867df55u, 0x316e8eefu, 0x4669be79u, + 0xcb61b38cu, 0xbc66831au, 0x256fd2a0u, 0x5268e236u, 0xcc0c7795u, 0xbb0b4703u, 0x220216b9u, 0x5505262fu, + 0xc5ba3bbeu, 0xb2bd0b28u, 0x2bb45a92u, 0x5cb36a04u, 0xc2d7ffa7u, 0xb5d0cf31u, 0x2cd99e8bu, 0x5bdeae1du, + 0x9b64c2b0u, 0xec63f226u, 0x756aa39cu, 0x026d930au, 0x9c0906a9u, 0xeb0e363fu, 0x72076785u, 0x05005713u, + 0x95bf4a82u, 0xe2b87a14u, 0x7bb12baeu, 0x0cb61b38u, 0x92d28e9bu, 0xe5d5be0du, 0x7cdcefb7u, 0x0bdbdf21u, + 0x86d3d2d4u, 0xf1d4e242u, 0x68ddb3f8u, 0x1fda836eu, 0x81be16cdu, 0xf6b9265bu, 0x6fb077e1u, 0x18b74777u, + 0x88085ae6u, 0xff0f6a70u, 0x66063bcau, 0x11010b5cu, 0x8f659effu, 0xf862ae69u, 0x616bffd3u, 0x166ccf45u, + 0xa00ae278u, 0xd70dd2eeu, 0x4e048354u, 0x3903b3c2u, 0xa7672661u, 0xd06016f7u, 0x4969474du, 0x3e6e77dbu, + 0xaed16a4au, 0xd9d65adcu, 0x40df0b66u, 0x37d83bf0u, 0xa9bcae53u, 0xdebb9ec5u, 0x47b2cf7fu, 0x30b5ffe9u, + 0xbdbdf21cu, 0xcabac28au, 0x53b39330u, 0x24b4a3a6u, 0xbad03605u, 0xcdd70693u, 0x54de5729u, 0x23d967bfu, + 0xb3667a2eu, 0xc4614ab8u, 0x5d681b02u, 0x2a6f2b94u, 0xb40bbe37u, 0xc30c8ea1u, 0x5a05df1bu, 0x2d02ef8du, + }; + public static uint CRCJam(byte[] data) + { + uint crc = 0xffffffff; + for (int index = 0; index < data.Length; index++) + { + crc = TABLE[(crc ^ data[index]) & 0xff] ^ (crc >> 8); + } + return crc; + } + } +} diff --git a/src/IAPDUTransport.cs b/src/IAPDUTransport.cs new file mode 100644 index 0000000..50f94c9 --- /dev/null +++ b/src/IAPDUTransport.cs @@ -0,0 +1,14 @@ +using PCSC.Iso7816; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace BoltCardTools +{ + public interface IAPDUTransport + { + Task SendAPDU(NTagCommand apdu); + } +} diff --git a/src/Ntag424.cs b/src/Ntag424.cs new file mode 100644 index 0000000..87dae77 --- /dev/null +++ b/src/Ntag424.cs @@ -0,0 +1,420 @@ +using Microsoft.VisualBasic; +using NdefLibrary.Ndef; +using PCSC.Iso7816; +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using System.Net; +using System.Net.Http.Headers; +using System.Runtime.CompilerServices; +using System.Security; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using static BoltCardTools.Helpers; +using static System.Runtime.InteropServices.JavaScript.JSType; + +namespace BoltCardTools +{ + public enum ISOLevel + { + PICC, // MF + Application // DF + } + public enum DataFile + { + CC = 0xE103, + NDEF = 0xE104, + Proprietary = 0xE105 + } + + public class Ntag424 + { + public record Session(int KeyNo, AESKey ENCKey, AESKey MACKey, byte[] TransactionId) + { + public int Counter { get; set; } + + public byte[] EncryptCommand(byte[] data) + { + if (data.Length == 0) + return data; + data = PaddingForEnc(data); + var iv = ENCKey.Encrypt( + Concat( + "A55A".HexToBytes(), + TransactionId, + UShortToBytesLE(Counter), + "0000000000000000".HexToBytes() + ), null, CipherMode.ECB + ); + return ENCKey.Encrypt(data, iv); + } + + internal static byte[] PaddingForEnc(byte[] data) + { + var len = data.Length; + var paddingLen = 16 - (len % 16); + var padded = new byte[len + paddingLen]; + Array.Copy(data, padded, len); + padded[len] = 0x80; + return padded; + } + + + public byte[] DecryptResponse(byte rc, byte[] data, CommMode commMode) + { + if (commMode is CommMode.Plain || data.Length == 0) + return data; + var mac = data[^8..]; + data = data[..^8]; + var expectedMac = this.GetMac(rc, data); + if (!expectedMac.IsSame(mac)) + throw new UnexpectedResponseException("Invalid MAC"); + if (data.Length == 0 || commMode == CommMode.MAC) + return data; + var iv = ENCKey.Encrypt( + Concat( + "5AA5".HexToBytes(), + TransactionId, + UShortToBytesLE(Counter), + "0000000000000000".HexToBytes() + ), null, CipherMode.ECB + ); + var decrypted = ENCKey.Decrypt(data, iv); + var paddingStart = Array.LastIndexOf(decrypted, (byte)0x80); + return decrypted[0..paddingStart]; + } + + public byte[] GetMac(byte ins, byte[]? data) + { + var macValue = Concat( + new byte[] { ins }, + UShortToBytesLE(Counter), + TransactionId, + data ?? Array.Empty()); + var mac = MACKey.CMac(macValue); + return Truncate(mac); + } + } + readonly IAPDUTransport Transport; + public Ntag424(IAPDUTransport transport) + { + Transport = transport; + } + public Session? CurrentSession { get; private set; } + + public async Task IsoSelectFile(ISOLevel level) + { + await SendAPDU(NtagCommands.ISOSelectFile with + { + P1 = 0x04, + P2 = 0x00, + Data = (level switch + { + ISOLevel.PICC => "d2760000850100", + ISOLevel.Application => "d2760000850101", + _ => throw new ArgumentException(nameof(level)) + }).HexToBytes() + }); + } + public async Task IsoSelectFile(DataFile file) + { + await SendAPDU(NtagCommands.ISOSelectFile with + { + P1 = 0x00, + P2 = 0x00, + Data = new byte[] { (byte)((int)file >> 8), (byte)file }, + Le = 0 + }); + } + + public Task AuthenticateEV2NonFirst(int keyNo, AESKey key) + { + return AuthenticateEV2(keyNo, key, false); + } + public Task AuthenticateEV2First(int keyNo, AESKey key) + { + return AuthenticateEV2(keyNo, key, true); + } + public async Task AuthenticateEV2(int keyNo, AESKey key, bool first) + { + int sessionCounter = CurrentSession?.Counter ?? 0; + if (first) + { + await IsoSelectFile(ISOLevel.Application); + sessionCounter = 0; + } + else + { + if (CurrentSession is null) + throw new InvalidOperationException("Authentication required for AuthenticateEV2NonFirst"); + sessionCounter = CurrentSession.Counter; + } + + NtagResponse resp; + if (first) + { + resp = await SendAPDU(NtagCommands.AuthenticateEV2FirstPart1 with + { + Data = $"{(byte)keyNo:x2}03000000".HexToBytes() + }); + } + else + { + resp = await SendAPDU(NtagCommands.AuthenticateEV2NonFirstPart1 with + { + Data = new byte[] { (byte)keyNo } + }); + } + var rndB = key.Decrypt(resp.Data); + var rndBp = RotateLeft(rndB); + var rndA = RandomNumberGenerator.GetBytes(16); + var encRnd = key.Encrypt(Concat(rndA, rndBp)); + var secondPart = first ? NtagCommands.AuthenticateEV2FirstPart2 : NtagCommands.AuthenticateEV2NonFirstPart2; + resp = await SendAPDU(secondPart with + { + Data = encRnd + }); + + var data = key.Decrypt(resp.Data); + var rndAp = RotateLeft(rndA); + byte[] tid; + byte[] actualRndAp; + if (first) + { + tid = data[0..4]; + actualRndAp = data[4..20]; + } + else + { + tid = CurrentSession!.TransactionId; + actualRndAp = data[0..16]; + } + if (!rndAp.IsSame(actualRndAp)) + throw new UnexpectedResponseException("Invalid RndAp response"); + var rndMix = Concat( + rndA[0..2], + XOR(rndA[2..8], rndB[0..6]), + rndB[6..16], + rndA[8..16]); + var encKey = key.Derive(Concat( + "A55A00010080".HexToBytes(), + rndMix)); + var macKey = key.Derive(Concat( + "5AA500010080".HexToBytes(), + rndMix)); + var session = new Session(keyNo, encKey, macKey, tid) + { + Counter = sessionCounter + }; + CurrentSession = session; + return session; + } + + private async Task SendAPDU(NTagCommand command) + { + CommMode commandMode; + if (command.CommMode is CommMode m) + { + commandMode = m; + } + else + { + commandMode = CurrentSession is null ? CommMode.Plain : CommMode.Full; + command = command with + { + CommMode = commandMode + }; + } + if (commandMode is not CommMode.Plain) + { + if (CurrentSession is null) + throw new InvalidOperationException("Authentication required"); + command = command.Encode(CurrentSession); + } + if (CurrentSession is not null) + CurrentSession.Counter++; + + var resp = await Transport.SendAPDU(command); + command.ThrowIfUnexpected(resp); + if (commandMode is not CommMode.Plain && CurrentSession is not null) + { + resp = resp.Decode(CurrentSession, commandMode); + } + return resp; + } + + public async Task GetCardUID() + { + return (await SendAPDU(NtagCommands.GetCardUID)).Data; + } + + public async Task GetFileSettings(DataFile file = DataFile.NDEF) + { + return new FileSettings((await SendAPDU(NtagCommands.GetFileSettings with + { + Data = GetFileNo(file) + })).Data, false); + } + public async Task ChangeFileSettings(DataFile file = DataFile.NDEF, FileSettings? fileSettings = null) + { + fileSettings ??= new FileSettings(file); + await SendAPDU(NtagCommands.ChangeFileSettings with + { + Data = Concat( + GetFileNo(file), + fileSettings.ToBytes() + ) + }); + } + + public async Task ReadNDef() + { + await IsoSelectFile(ISOLevel.Application); + await IsoSelectFile(DataFile.NDEF); + var size = (await SendAPDU(NtagCommands.ISOReadBinary with + { + P1 = 0, + P2 = 0, + Le = 2 + })).Data[1]; + var data = (await SendAPDU(NtagCommands.ISOReadBinary with + { + P1 = 0, + P2 = 2, + Le = size + })).Data; + return NdefMessage.FromByteArray(data); + } + + public async Task ReadFile(DataFile file, int offset, int length) + { + var commMode = await GetCommMode(file, AccessRight.Read); + return (await SendAPDU(NtagCommands.ReadData with + { + CommMode = commMode, + CommandHeaderSize = 7, + Data = Concat( + GetFileNo(file), + UIntTo3BytesLE(offset), + UIntTo3BytesLE(length) + ) + })).Data; + } + + private async Task GetCommMode(DataFile file, AccessRight requiredRight) + { + if (CurrentSession is null) + return CommMode.Plain; + var settings = await GetFileSettings(file); + if (!settings.IsAllowed(CurrentSession.KeyNo, requiredRight)) + throw new SecurityException($"The key {CurrentSession.KeyNo} doesn't have the necessary permissions"); + return settings.CommMode; + } + + private static byte[] GetFileNo(DataFile file) + { + return new byte[] { file switch + { + DataFile.CC => 0x01, + DataFile.NDEF => 0x02, + DataFile.Proprietary => 0x03, + _ => throw new ArgumentException(nameof(file)) + } }; + } + + public async Task WriteNDef(NdefMessage message) + { + var ndefMessageBytes = message.ToByteArray(); + var content = new byte[220]; // Normally we have 256 bytes, but APDU has a size limit we need some margin + content[0] = (byte)(ndefMessageBytes.Length >> 8); + content[1] = (byte)ndefMessageBytes.Length; + Array.Copy(ndefMessageBytes, 0, content, 2, Math.Min(content.Length - 2, ndefMessageBytes.Length)); + await SendAPDU(NtagCommands.WriteData with + { + CommMode = await GetCommMode(DataFile.NDEF, AccessRight.Write), + Data = Concat( + GetFileNo(DataFile.NDEF), + new byte[] { 0x00, 0x00, 0x00 }, + UIntTo3BytesLE(content.Length), + content + ) + }); + } + + public async Task ChangeKey(int keyNo, AESKey newKey, AESKey? oldKey = null, int version = 0) + { + if (CurrentSession is null || CurrentSession.KeyNo != 0) + throw new InvalidOperationException("Authentication required with KeyNo 0"); + + byte[] data; + if (keyNo == 0) + { + data = Concat( + newKey.ToBytes(), + new byte[] { (byte)version } + ); + } + else + { + oldKey ??= new AESKey(new byte[16]); + data = Concat( + XOR(newKey.ToBytes(), oldKey.ToBytes()), + new byte[] { (byte)version }, + UIntToBytesLE(CRCJam(newKey.ToBytes()))); + } + + await SendAPDU(NtagCommands.ChangeKey with + { + Data = Concat( + new byte[] { (byte)keyNo }, + data + ) + }); + } + + public async Task SetupBoltcard(string lnurlw) + { + if (!lnurlw.Contains('?', StringComparison.OrdinalIgnoreCase)) + lnurlw += "?"; + else + lnurlw += "&"; + lnurlw += "p=00000000000000000000000000000000&c=0000000000000000"; + + var ndef = new NdefMessage + { + new NdefUriRecord() { Uri = lnurlw } + }; + await WriteNDef(ndef); + var ndefBytes = ndef.ToByteArray(); + var pIndex = Array.LastIndexOf(ndefBytes, (byte)'p') + 4; + var cIndex = Array.LastIndexOf(ndefBytes, (byte)'c') + 4; + + var settings = new FileSettings(DataFile.NDEF) + { + AccessRights = new () + { + ReadWrite = AccessCondition.Key0, + Change = AccessCondition.Key0, + Write = AccessCondition.Key0, + Read = AccessCondition.Free + }, + SDMMirroring = true, + SDMUID = true, + SDMReadCtr = true, + SDMAccessRights = new () + { + MetaRead = AccessCondition.Key1, + FileRead = AccessCondition.Key2, + CtrRet = AccessCondition.Never + }, + SDMMACInputOffset = cIndex, + SDMMACOffset = cIndex, + PICCDataOffset = pIndex + }; + await ChangeFileSettings(fileSettings: settings); + + } + } +} diff --git a/src/NtagCommands.cs b/src/NtagCommands.cs new file mode 100644 index 0000000..11025ec --- /dev/null +++ b/src/NtagCommands.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.Metrics; +using System.Linq; +using System.Security.AccessControl; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using System.Transactions; +using static BoltCardTools.Helpers; +using static System.Runtime.InteropServices.JavaScript.JSType; + +namespace BoltCardTools +{ + public enum CommMode + { + Plain, + Full, + MAC + } + public record NTagError(ushort sw1sw2, string Code, string Description) + { + public override string ToString() + { + return $"{Code} ({sw1sw2:x4}): {Description}"; + } + } + public record NTagCommand(string Name, byte CLA, byte INS, byte? P1, byte? P2, byte? Lc, byte[]? Data, byte? Le, ushort ExpectedStatus, CommMode? CommMode, int CommandHeaderSize = 0) + { + public List ErrorCodes = new List(); + static List DefaultErrorCodes = new List() + { + new NTagError(0x9100, "OPERATION_OK", "Successful operation."), + new NTagError(0x911C, "ILLEGAL_COMMAND_CODE", "Command code not supported."), + new NTagError(0x911E, "INTEGRITY_ERROR", "CRC or MAC does not match data. Padding bytes not valid."), + new NTagError(0x9140, "NO_SUCH_KEY", "Invalid key number specified."), + new NTagError(0x917E, "LENGTH_ERROR", "Length of command string invalid."), + new NTagError(0x919D, "PERMISSION_DENIED", "Current configuration / status does not allow the requested command."), + new NTagError(0x919E, "PARAMETER_ERROR", "Value of the parameter(s) invalid."), + new NTagError(0x91AD, "AUTHENTICATION_DELAY", "Currently not allowed to authenticate. Keep trying until full delay is spent."), + new NTagError(0x91AE, "AUTHENTICATION_ERROR", "Current authentication status does not allow the requested command."), + new NTagError(0x91AF, "ADDITIONAL_FRAME", "Additional data frame is expected to be sent."), + new NTagError(0x91BE, "BOUNDARY_ERROR", "Attempt to read/write data from/to beyond the file’s/record’s limits. Attempt to exceed the limits of a value file."), + new NTagError(0x91CA, "COMMAND_ABORTED", "Previous Command was not fully completed. Not all Frames were requested or provided by the PCD."), + new NTagError(0x91F0, "FILE_NOT_FOUND", "Specified file number does not exist."), + new NTagError(0x6700, "WRONG_LENGTH", "Wrong length; no further indication."), + new NTagError(0x6982, "SECURITY_STATUS_NOT_SATISFIED", "Security status not satisfied."), + new NTagError(0x6985, "CONDITIONS_OF_USE_NOT_SATISFIED", "Conditions of use not satisfied."), + new NTagError(0x6A80, "INCORRECT_PARAMETERS_IN_DATA_FIELD", "Incorrect parameters in the command data field."), + new NTagError(0x6A82, "FILE_OR_APPLICATION_NOT_FOUND", "File or application not found."), + new NTagError(0x6A86, "INCORRECT_PARAMETERS_P1_P2", "Incorrect parameters P1-P2."), + new NTagError(0x6A87, "LC_INCONSISTENT_WITH_PARAMETERS_P1_P2", "Lc inconsistent with parameters P1-P2."), + new NTagError(0x6C00, "WRONG_LE_FIELD", "Wrong Le field."), + new NTagError(0x6D00, "INSTRUCTION_CODE_NOT_SUPPORTED_OR_INVALID", "Instruction code not supported or invalid."), + new NTagError(0x6E00, "CLASS_NOT_SUPPORTED", "Class not supported."), + new NTagError(0x9000, "NORMAL_PROCESSING", "Normal processing (no further qualification).") + }; + internal void ThrowIfUnexpected(NtagResponse resp) + { + if (resp.sw1sw2 != ExpectedStatus) + { + var errorCode = ErrorCodes.FirstOrDefault(c => c.sw1sw2 == resp.sw1sw2); + errorCode ??= DefaultErrorCodes.FirstOrDefault(c => c.sw1sw2 == resp.sw1sw2); + if (errorCode is null) + throw new UnexpectedStatusException(Name, ExpectedStatus, resp.sw1sw2); + else + throw new UnexpectedStatusException(Name, ExpectedStatus, errorCode); + } + } + public byte[] ToBytes() + { + var list = new List + { + CLA, + INS + }; + if (!P1.HasValue) + throw new InvalidOperationException("P1 not provided"); + if (!P2.HasValue) + throw new InvalidOperationException("P2 not provided"); + + list.Add(P1.Value); + list.Add(P2.Value); + if (Data != null) + { + if (Lc.HasValue) + { + var realLc = Lc.Value; + if (CommMode is BoltCardTools.CommMode.Full) + { + var encDataSize = realLc - CommandHeaderSize; + realLc = (byte)CommandHeaderSize; + realLc += (byte)(16 - (encDataSize % 16)); // Padding + realLc += 8; // Add mac + } + if (CommMode is BoltCardTools.CommMode.MAC) + { + realLc += 8; // Add mac + } + + if (realLc != Data.Length) + throw new InvalidOperationException("Invalid Data length"); + } + list.Add((byte)(Data.Length)); + list.AddRange(Data); + } + if (Le.HasValue) + { + list.Add(Le.Value); + } + return list.ToArray(); + } + public override string ToString() + { + return ToBytes().ToHex(); + } + + internal NTagCommand Encode(Ntag424.Session currentSession) + { + if (CommMode is null) + throw new InvalidOperationException("CommMode isn't set"); + if (CommMode is BoltCardTools.CommMode.Plain) + return this; + var data = Data; + if (CommMode is BoltCardTools.CommMode.Full && data is not null) + { + var nonEncrypted = data[0..CommandHeaderSize]; + var encrypted = data[CommandHeaderSize..]; + data = Concat(nonEncrypted, currentSession.EncryptCommand(encrypted)); + } + var mac = currentSession.GetMac(INS, data); + data = Concat(data, mac); + return this with + { + Data = data + }; + } + } + internal class NtagCommands + { + internal readonly static NTagCommand AuthenticateEV2FirstPart1 = new(Name: "AuthenticateEV2FirstPart1", CLA: 0x90, INS: 0x71, P1: 0, P2: 0, Lc: null, Data: null, Le: 0, ExpectedStatus: 0x91AF, CommMode: CommMode.Plain); + internal readonly static NTagCommand AuthenticateEV2FirstPart2 = new(Name: "AuthenticateEV2FirstPart2", CLA: 0x90, INS: 0xAF, P1: 0, P2: 0, Lc: 32, Data: null, Le: 0, ExpectedStatus: 0x9100, CommMode: CommMode.Plain); + internal readonly static NTagCommand AuthenticateEV2NonFirstPart1 = new(Name: "AuthenticateEV2NonFirstPart1", CLA: 0x90, INS: 0x77, P1: 0, P2: 0, Lc: null, Data: null, Le: 0, ExpectedStatus: 0x91AF, CommMode: CommMode.Plain); + internal readonly static NTagCommand AuthenticateEV2NonFirstPart2 = new(Name: "AuthenticateEV2NonFirstPart2", CLA: 0x90, INS: 0xAF, P1: 0, P2: 0, Lc: 32, Data: null, Le: 0, ExpectedStatus: 0x9100, CommMode: CommMode.Plain); + internal readonly static NTagCommand AuthenticateLRPFirstPart1 = new(Name: "AuthenticateLRPFirstPart1", CLA: 0x90, INS: 0x71, P1: 0, P2: 0, Lc: null, Data: null, Le: 0, ExpectedStatus: 0x91AF, CommMode: null); + internal readonly static NTagCommand AuthenticateLRPFirstPart2 = new(Name: "AuthenticateLRPFirstPart2", CLA: 0x90, INS: 0xAF, P1: 0, P2: 0, Lc: 32, Data: null, Le: 0, ExpectedStatus: 0x9100, CommMode: null); + internal readonly static NTagCommand AuthenticateLRPNonFirstPart1 = new(Name: "AuthenticateLRPNonFirstPart1", CLA: 0x90, INS: 0x77, P1: 0, P2: 0, Lc: null, Data: null, Le: 0, ExpectedStatus: 0x91AF, CommMode: null); + internal readonly static NTagCommand AuthenticateLRPNonFirstPart2 = new(Name: "AuthenticateLRPNonFirstPart2", CLA: 0x90, INS: 0xAF, P1: 0, P2: 0, Lc: 32, Data: null, Le: 0, ExpectedStatus: 0x9100, CommMode: null); + internal readonly static NTagCommand ChangeFileSettings = new(Name: "ChangeFileSettings", CLA: 0x90, INS: 0x5F, P1: 0, P2: 0, Lc: null, Data: null, Le: 0, ExpectedStatus: 0x9100, CommMode: CommMode.Full, CommandHeaderSize: 1); + internal readonly static NTagCommand ChangeKey = new(Name: "ChangeKey", CLA: 0x90, INS: 0xC4, P1: 0, P2: 0, Lc: null, Data: null, Le: 0, ExpectedStatus: 0x9100, CommMode: CommMode.Full, CommandHeaderSize: 1) + { + ErrorCodes = + { + new NTagError(0x91CA, "COMMAND_ABORTED", "Chained command or multiple pass command ongoing."), + new NTagError(0x911E, "INTEGRITY_ERROR", "Integrity error in cryptogram or invalid secure messaging MAC (Secure Messaging)."), + new NTagError(0x917E, "LENGTH_ERROR", "Command size not allowed."), + new NTagError(0x919E, "PARAMETER_ERROR", "Parameter value not allowed."), + new NTagError(0x9140, "NO_SUCH_KEY", "Targeted key does not exist."), + new NTagError(0x919D, "PERMISSION_DENIED", "At PICC level, targeting any OriginalityKey which cannot be changed."), + new NTagError(0x91AE, "AUTHENTICATION_ERROR", "At application level, missing active authentication with AppMasterKey while targeting any AppKey."), + new NTagError(0x91EE, "MEMORY_ERROR", "Failure when reading or writing to non-volatile memory.") + } + }; + internal readonly static NTagCommand GetCardUID = new(Name: "GetCardUID", CLA: 0x90, INS: 0x51, P1: 0, P2: 0, Lc: null, Data: null, Le: 0, ExpectedStatus: 0x9100, CommMode: CommMode.Full); + internal readonly static NTagCommand GetFileCounters = new(Name: "GetFileCounters", CLA: 0x90, INS: 0xF6, P1: 0, P2: 0, Lc: null, Data: null, Le: 0, ExpectedStatus: 0x9100, CommMode: CommMode.Full); + internal readonly static NTagCommand GetFileSettings = new(Name: "GetFileSettings", CLA: 0x90, INS: 0xF5, P1: 0, P2: 0, Lc: 1, Data: null, Le: 0, ExpectedStatus: 0x9100, CommMode: CommMode.MAC) + { + ErrorCodes = + { + new NTagError(0x91CA, "COMMAND_ABORTED", "Chained command or multiple pass command ongoing."), + new NTagError(0x911E, "INTEGRITY_ERROR", "Invalid secure messaging MAC (only)."), + new NTagError(0x917E, "LENGTH_ERROR", "Command size not allowed."), + new NTagError(0x919E, "PARAMETER_ERROR", "Parameter value not allowed."), + new NTagError(0x919D, "PERMISSION_DENIED", "PICC level (MF) is selected."), + new NTagError(0x91F0, "FILE_NOT_FOUND", "File with targeted FileNo does not exist for the targeted application."), + new NTagError(0x91EE, "MEMORY_ERROR", "Failure when reading or writing to non-volatile memory.") + } + }; + internal readonly static NTagCommand GetKeyVersion = new(Name: "GetKeyVersion", CLA: 0x90, INS: 0x64, P1: 0, P2: 0, Lc: 1, Data: null, Le: 0, ExpectedStatus: 0x9100, CommMode: CommMode.MAC); + internal readonly static NTagCommand GetVersionPart1 = new(Name: "GetVersionPart1", CLA: 0x90, INS: 0x60, P1: 0, P2: 0, Lc: null, Data: null, Le: 0, ExpectedStatus: 0x91AF, CommMode: CommMode.MAC); + internal readonly static NTagCommand GetVersionPart2 = new(Name: "GetVersionPart2", CLA: 0x90, INS: 0xAF, P1: 0, P2: 0, Lc: null, Data: null, Le: 0, ExpectedStatus: 0x91AF, CommMode: CommMode.MAC); + internal readonly static NTagCommand GetVersionPart3 = new(Name: "GetVersionPart3", CLA: 0x90, INS: 0xAF, P1: 0, P2: 0, Lc: null, Data: null, Le: 0, ExpectedStatus: 0x9100, CommMode: CommMode.MAC); + internal readonly static NTagCommand ISOReadBinary = new(Name: "ISOReadBinary", CLA: 0x00, INS: 0xB0, P1: null, P2: null, Lc: null, Data: null, Le: null, ExpectedStatus: 0x9000, CommMode: CommMode.Plain); + internal readonly static NTagCommand ReadData = new(Name: "ReadData", CLA: 0x90, INS: 0xAD, P1: 0, P2: 0, Lc: null, Data: null, Le: 0, ExpectedStatus: 0x9100, CommMode: null, CommandHeaderSize: 7); + internal readonly static NTagCommand Read_Sig = new(Name: "Read_Sig", CLA: 0x90, INS: 0x3C, P1: 0, P2: 0, Lc: 1, Data: null, Le: 0, ExpectedStatus: 0x9100, CommMode: CommMode.Full); + internal readonly static NTagCommand ISOSelectFile = new(Name: "ISOSelectFile", CLA: 0x00, INS: 0xA4, P1: null, P2: null, Lc: null, Data: null, Le: null, ExpectedStatus: 0x9000, CommMode: CommMode.Plain); + internal readonly static NTagCommand SetConfiguration = new(Name: "SetConfiguration", CLA: 0x90, INS: 0x5C, P1: 0, P2: 0, Lc: null, Data: null, Le: 0, ExpectedStatus: 0x9100, CommMode: CommMode.Full, CommandHeaderSize: 1); + internal readonly static NTagCommand ISOUpdateBinary = new(Name: "ISOUpdateBinary", CLA: 0x00, INS: 0xD6, P1: null, P2: null, Lc: null, Data: null, Le: null, ExpectedStatus: 0x9000, CommMode: CommMode.Plain); + internal readonly static NTagCommand WriteData = new(Name: "WriteData", CLA: 0x90, INS: 0x8D, P1: 0, P2: 0, Lc: null, Data: null, Le: 0, ExpectedStatus: 0x9100, CommMode: null, CommandHeaderSize: 7); + } +} diff --git a/src/NtagResponse.cs b/src/NtagResponse.cs new file mode 100644 index 0000000..8b2e99a --- /dev/null +++ b/src/NtagResponse.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace BoltCardTools +{ + public record NtagResponse(byte[] Data, ushort sw1sw2) + { + internal NtagResponse Decode(Ntag424.Session currentSession, CommMode commMode) + { + return this with + { + Data = currentSession.DecryptResponse((byte)sw1sw2, Data, commMode) + }; + } + } +} diff --git a/src/PCSCAPDUTransport.cs b/src/PCSCAPDUTransport.cs new file mode 100644 index 0000000..232cc2e --- /dev/null +++ b/src/PCSCAPDUTransport.cs @@ -0,0 +1,53 @@ +using PCSC; +using PCSC.Extensions; +using PCSC.Iso7816; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; + +namespace BoltCardTools +{ + public class PCSCAPDUTransport : IAPDUTransport + { + public readonly ISCardReader CardReader; + public PCSCAPDUTransport(ISCardReader cardReader) + { + ArgumentNullException.ThrowIfNull(cardReader); + CardReader = cardReader; + } + + public Task SendAPDU(NTagCommand apdu) + { + //return Task.Factory.StartNew(() => + //{ + // var bytes = apdu.ToBytes(); + // Console.WriteLine(bytes.ToHex()); + // Console.WriteLine("---"); + // var resp = new byte[256]; + // int received = resp.Length; + // var sc = CardReader.Transmit(bytes, resp, ref received); + // if (sc != SCardError.Success) + // sc.Throw(); + // var sw1sw2 = (ushort)(resp[received - 2] << 8 | resp[received - 1]); + // var data = resp[..(received - 2)]; + // return new NtagResponse(data, sw1sw2); + //}, TaskCreationOptions.LongRunning); + + var bytes = apdu.ToBytes(); + Console.WriteLine("Command APDU : " + bytes.ToHex()); + var resp = new byte[512]; + int received = resp.Length; + var sc = CardReader.Transmit(bytes, resp, ref received); + if (sc != SCardError.Success) + sc.Throw(); + Console.WriteLine("Response APDU : " + resp[..received].ToHex()); + var sw1sw2 = (ushort)(resp[received - 2] << 8 | resp[received - 1]); + var data = resp[..(received - 2)]; + return Task.FromResult(new NtagResponse(data, sw1sw2)); + } + } +} diff --git a/src/PICCData.cs b/src/PICCData.cs new file mode 100644 index 0000000..8f54ba0 --- /dev/null +++ b/src/PICCData.cs @@ -0,0 +1,28 @@ + +using System; + +namespace BoltCardTools; + +public record PICCData(byte[]? Uid, int? Counter) +{ + public static PICCData Create(ReadOnlySpan data) + { + bool hasUid = (data[0] & 0b1000_0000) != 0; + bool hasCounter = (data[0] & 0b0100_0000) != 0; + if (hasUid && ((data[0] & 0b0000_0111) != 0b0000_0111)) + throw new InvalidOperationException("Invalid PICCData"); + int i = 1; + byte[]? uid = null; + int? counter = null; + if (hasUid) + { + uid = data[i..(i + 7)].ToArray(); + i += 7; + } + if (hasCounter) + { + counter = data[i] | data[i + 1] << 8 | data[i + 2] << 16; + } + return new PICCData(uid, counter); + } +} diff --git a/src/Program.cs b/src/Program.cs new file mode 100644 index 0000000..90dda35 --- /dev/null +++ b/src/Program.cs @@ -0,0 +1,54 @@ +using BoltCardTools; +using NdefLibrary.Ndef; +using PCSC; +using PCSC.Extensions; +using PCSC.Iso7816; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +// https://github.com/boltcard/boltcard-wallet/blob/master/class/Ntag424.js +// https://github.com/boltcard/boltcard-wallet/blob/master/screen/boltcard/create.js#L201 +// https://github.com/danm-de/pcsc-sharp +public class Program +{ + public static async Task Main(string[] args) + { + var message = NdefMessage.FromByteArray("D1012C5503746573742E636F6D3F703D303030303030303030303030303026633D303030303030303030303030303030".HexToBytes()); + + // http://test.com?p=00000000000000&c=000000000000000, description=, uriType=sms, mirrorFlags=null, mUri=http://test.com?p=00000000000000&c=000000000000000 + + var contextFactory = ContextFactory.Instance; + using (var ctx = contextFactory.Establish(SCardScope.System)) + { + var readerName = ctx.GetReaders().FirstOrDefault(); + if (readerName != null) + { + using var reader = new SCardReader(ctx); + reader.Connect(readerName, SCardShareMode.Exclusive, SCardProtocol.Any).ThrowIfNotSuccess(); + var transport = new PCSCAPDUTransport(reader); + var ntag = new Ntag424(transport); + var key = new AESKey(new byte[16]); + await ntag.AuthenticateEV2First(0, key); + await ntag.AuthenticateEV2NonFirst(0, key); + + //Console.WriteLine("UID: " + (await ntag.GetCardUID()).ToHex()); + //await ntag.SetupBoltcard("http://test.com"); + //await ntag.ChangeFileSettings(); + + //await ntag.ReadFile(DataFile.NDEF, 0, 10); + //await ntag.WriteNDef(message); + //await ntag.ReadFile(DataFile.NDEF, 0, 10); + //await ntag.WriteNDef(message); + //await ntag.IsoSelectFile(DataFile.CC); + //var d = await ntag.ReadFile(DataFile.NDEF, 0, 0); + //Console.WriteLine(d.ToHex()); + //await ntag.GetCardUID + //var access = await ntag.GetFileSettings(); + } + } + } + + +} \ No newline at end of file diff --git a/src/Properties.cs b/src/Properties.cs new file mode 100644 index 0000000..3a161ab --- /dev/null +++ b/src/Properties.cs @@ -0,0 +1,8 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading.Tasks; + +[assembly:InternalsVisibleTo("BoltCardTools.Tests")] diff --git a/src/UnexpectedStatusException.cs b/src/UnexpectedStatusException.cs new file mode 100644 index 0000000..fb0a283 --- /dev/null +++ b/src/UnexpectedStatusException.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace BoltCardTools +{ + public class UnexpectedResponseException : Exception + { + public UnexpectedResponseException(string? message) : base(message) + { + + } + } + public class UnexpectedStatusException : UnexpectedResponseException + { + public UnexpectedStatusException(string commandName, int expectedStatus, NTagError error) + : base($"Error for {commandName}: Expected {expectedStatus:X4}, Actual: {error}") + { + Details = error; + } + public NTagError? Details { get; } + public UnexpectedStatusException(string commandName, int expectedStatus, int actualStatus) + : base($"Unexpected status for {commandName}: Expected: {expectedStatus:X4}, Actual: {actualStatus:X4}") + { + } + } +} diff --git a/tests/BoltCardTools.Tests.csproj b/tests/BoltCardTools.Tests.csproj new file mode 100644 index 0000000..64f1e4d --- /dev/null +++ b/tests/BoltCardTools.Tests.csproj @@ -0,0 +1,33 @@ + + + + net7.0 + enable + enable + + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + diff --git a/tests/CardReaderContext.cs b/tests/CardReaderContext.cs new file mode 100644 index 0000000..9dbbddf --- /dev/null +++ b/tests/CardReaderContext.cs @@ -0,0 +1,39 @@ +using PCSC; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection.PortableExecutable; +using System.Text; +using System.Threading.Tasks; + +namespace BoltCardTools.Tests +{ + public record CardReaderContext(ISCardReader CardReader, IContextFactory ContextFactory, ISCardContext Context) : IDisposable + { + public static CardReaderContext Create() + { + var contextFactory = PCSC.ContextFactory.Instance; + var context = contextFactory.Establish(SCardScope.System); + var readerNames = context.GetReaders(); + var readerName = readerNames.FirstOrDefault(); + if (readerName is null) + { + throw new InvalidOperationException("No readers found"); + } + var reader = new SCardReader(context); + reader.Connect(readerName, SCardShareMode.Shared, SCardProtocol.Any); + return new CardReaderContext(reader, contextFactory, context); + } + public void Dispose() + { + CardReader.Dispose(); + Context.Dispose(); + } + + public Ntag424 CreateNTag424() + { + var transport = new PCSCAPDUTransport(this.CardReader); + return new Ntag424(transport); + } + } +} diff --git a/tests/GlobalUsings.cs b/tests/GlobalUsings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/tests/UnitTest1.cs b/tests/UnitTest1.cs new file mode 100644 index 0000000..bff9ba1 --- /dev/null +++ b/tests/UnitTest1.cs @@ -0,0 +1,196 @@ +using Microsoft.VisualStudio.TestPlatform.ObjectModel; +using NdefLibrary.Ndef; +using PCSC; +using PCSC.Extensions; +using PCSC.Iso7816; +using System.Reflection.Emit; +using System.Text; +using System.Text.RegularExpressions; + +namespace BoltCardTools.Tests; + +public class UnitTest1 +{ + [Fact] + public void CanCreateAPDUFromNtagCommand() + { + var actual = (NtagCommands.ISOSelectFile with + { + P1 = 0x04, + P2 = 0x00, + Data = "d2760000850101".HexToBytes() + }).ToBytes().ToHex(); + var expected = "00A4040007D2760000850101".ToLowerInvariant(); + Assert.Equal(expected, actual); + + actual = (NtagCommands.ISOSelectFile with + { + P1 = 0x04, + P2 = 0x00, + Data = "d2760000850101".HexToBytes(), + Le = 0 + }).ToBytes().ToHex(); + expected = "00A4040007D276000085010100".ToLowerInvariant(); + Assert.Equal(expected, actual); + } + + //from https://github.com/boltcard/boltcard/blob/7745c9f20d5ad0129cb4b3fc534441038e79f5e6/docs/TEST_VECTORS.md + [Theory] + [InlineData("E19CCB1FED8892CE", "04996c6a926980", 3)] + [InlineData("66B4826EA4C155B4", "04996c6a926980", 5)] + [InlineData("CC61660C020B4D96", "04996c6a926980", 7)] + public void CanCalculateSunMac(string expected, string uid, int ctr) + { + var key = new AESKey(Convert.FromHexString("b45775776cb224c75bcde7ca3704e933")); + var actual = key.GetSunMac(uid.HexToBytes(), ctr); + Assert.Equal(expected.ToLowerInvariant(), actual.ToHex()); + } + + //from https://github.com/boltcard/boltcard/blob/7745c9f20d5ad0129cb4b3fc534441038e79f5e6/docs/TEST_VECTORS.md + [Theory] + [InlineData("4E2E289D945A66BB13377A728884E867", "04996c6a926980", 3)] + [InlineData("00F48C4F8E386DED06BCDC78FA92E2FE", "04996c6a926980", 5)] + [InlineData("0DBF3C59B59B0638D60B5842A997D4D1", "04996c6a926980", 7)] + public void CanDecryptSunPICCData(string encrypted, string uid, int ctr) + { + var key = new AESKey(Convert.FromHexString("0c3b25d92b38ae443229dd59ad34b85d")); + var picc = key.DecryptSun(encrypted.HexToBytes()); + Assert.Equal(ctr, picc.Counter); + Assert.Equal(uid.ToLowerInvariant(), picc.Uid?.ToHex()); + } + + [Theory] + [InlineData("01020304050607080910111213141516", "0102030405060708091011121314151680000000000000000000000000000000")] + [InlineData("010203040506070809101112131415", "01020304050607080910111213141580")] + [InlineData("01", "01800000000000000000000000000000")] + public void CanDoPadding(string data, string padded) + { + var actual = Ntag424.Session.PaddingForEnc(data.HexToBytes()).ToHex(); + Assert.Equal(padded, actual); + } + + [Fact] + public void CanCreateCommModeMAC() + { + var session = new Ntag424.Session(0, new AESKey(new byte[16]), new AESKey("8248134A386E86EB7FAF54A52E536CB6".HexToBytes()), "7A21085E".HexToBytes()); + var command = NtagCommands.GetFileSettings with + { + CommMode = CommMode.MAC, + Data = new byte[] { 0x02 } + }; + command = command.Encode(session); + var apdu = command.ToBytes().ToHex(); + Assert.Equal("90F5000009026597A457C8CD442C00".ToLower(), apdu); + var resp = new NtagResponse("0040EEEE000100D1FE001F00004400004400002000006A00002A474282E7A47986".HexToBytes(), 0x9100); + command.ThrowIfUnexpected(resp); + session.Counter++; + var respData = resp.Decode(session, CommMode.MAC).Data.ToHex(); + Assert.Equal("0040EEEE000100D1FE001F00004400004400002000006A0000".ToLower(), respData); + } + [Fact] + public void CanCreateCommModeFull() + { + var session = new Ntag424.Session(0, new AESKey("7305E2CCA5B0377617CDBFEB96C9B358".HexToBytes()), new AESKey("8B485037C8C2FB400D79BF0AB956F28F".HexToBytes()), "856C1841".HexToBytes()); + var command = NtagCommands.WriteData with + { + CommMode = CommMode.Full, + Data = "02000000800000005ED1015B5500687474703A2F2F7777772E6D69666172652E6E65742F70726F64756374732F6E746167733F265549443D3034323136353441434634433830264374723D30303030303126436D61633D323145323336303832363645334345410000000000000000000000000000000000000000000000000000000000000000".HexToBytes() + }; + command = command.Encode(session); + var apdu = command.ToBytes().ToHex(); + // Why there are 4 more bytes in the doc?? + // Original: 908D00009F02000000800000B4716C58E71A09F6D869AB7810C2E94BD02F13DF2159433D581F50185B11535F3E7A068582B04B5E4BDE374A788DF7AD8C4C5473F7B30D9496BD8F3F8ED51D506D3194FDEA51A877C2EB28A0A8FD2B34E196800A7D2F0AD1CBED98E311E2F7667DA10DF3CF4CE6A5658B89695EDAD9F500000000D9AD1E4C41748D34BC6B15A2B45B050F34765F3E9D2CF701E0C7F781477F7B91B97CBB2A236F876C00 + Assert.Equal("908D00009F02000000800000B4716C58E71A09F6D869AB7810C2E94BD02F13DF2159433D581F50185B11535F3E7A068582B04B5E4BDE374A788DF7AD8C4C5473F7B30D9496BD8F3F8ED51D506D3194FDEA51A877C2EB28A0A8FD2B34E196800A7D2F0AD1CBED98E311E2F7667DA10DF3CF4CE6A5658B89695EDAD9F5D9AD1E4C41748D34BC6B15A2B45B050F34765F3E9D2CF701E0C7F781477F7B91B97CBB2A236F876C00".ToLower(), apdu); + + var resp = new NtagResponse("DDDB9EC959B3EFEB".HexToBytes(), 0x9100); + command.ThrowIfUnexpected(resp); + + session.Counter++; + var respData = resp.Decode(session, CommMode.MAC).Data.ToHex(); + Assert.Empty(respData); + } + + [Fact] + public void CanCreateFileSettings() + { + var actual = new FileSettings(DataFile.NDEF) + { + AccessRights = new() + { + ReadWrite = AccessCondition.Key0, + Change = AccessCondition.Key0, + Write = AccessCondition.Key0, + Read = AccessCondition.Free + }, + SDMMirroring = true, + SDMUID = true, + SDMReadCtr = true, + SDMAccessRights = new() + { + MetaRead = AccessCondition.Key1, + FileRead = AccessCondition.Key2, + CtrRet = AccessCondition.Never + }, + PICCDataOffset = 3, + SDMMACOffset = 2, + SDMMACInputOffset = 1 + }.ToBytes().ToHex(); + + Assert.Equal("4000E0C1FF12030000010000020000".ToLower(), actual); + } + + [Fact] + public async Task CanAuthenticate() + { + using var ctx = CardReaderContext.Create(); + var ntag = ctx.CreateNTag424(); + var key = new AESKey(new byte[16]); + await ntag.AuthenticateEV2First(0, key); + var uid1 = await ntag.GetCardUID(); + await ntag.AuthenticateEV2NonFirst(0, key); + var uid2 = await ntag.GetCardUID(); + Assert.Equal(uid1.ToHex(), uid2.ToHex()); + } + + [Fact] + public async Task CanChangeKey() + { + using var ctx = CardReaderContext.Create(); + var ntag = ctx.CreateNTag424(); + var key1 = new AESKey(new byte[16]); + var key2b = new byte[16]; + key2b[^1] = 1; + var key2 = new AESKey(key2b); + await ntag.AuthenticateEV2First(0, key1); + await ntag.ChangeKey(0, key1); + + await ntag.AuthenticateEV2First(0, key1); + await ntag.ChangeKey(1, key1); + await ntag.ChangeKey(1, key2, key1); + await ntag.ChangeKey(1, key1, key2); + } + + [Fact] + public async Task CanDoBoltcard() + { + using var ctx = CardReaderContext.Create(); + var ntag = ctx.CreateNTag424(); + var key = new AESKey(new byte[16]); + await ntag.AuthenticateEV2First(0, key); + await ntag.SetupBoltcard("http://test.com"); + var message = await ntag.ReadNDef(); + var uri = new NdefUriRecord(message[0]).Uri; + var p = Regex.Match(uri, "p=([^&]*)&").Groups[1].Value.ToLowerInvariant(); + var c = Regex.Match(uri, "c=(.*)").Groups[1].Value.ToLowerInvariant(); + var piccData = key.DecryptSun(p.HexToBytes()); + Assert.Equal(c, key.GetSunMac(piccData).ToHex()); + } + + [Fact] + public void CanCalculateCRC() + { + var bytes = new byte[] { 104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100 }; + var result = Helpers.CRCJam(bytes); + Assert.Equal(unchecked((uint)(-0xd4a1186)), result); + } +} \ No newline at end of file diff --git a/tests/xunit.runner.json b/tests/xunit.runner.json new file mode 100644 index 0000000..ace6d13 --- /dev/null +++ b/tests/xunit.runner.json @@ -0,0 +1,4 @@ +{ + "diagnosticMessages": true, + "methodDisplay": "method" +} \ No newline at end of file