using System.CommandLine; using PCSC; using PCSC.Monitoring; using PCSC.Iso7816; const int defaultAmount = 150; var rootCommand = new RootCommand("Fancy Test Machine for AfRApay"); var listReadersOption = new Option("--list-readers", "List card readers and exit"); rootCommand.Add(listReadersOption); var webAddrOption = new Option("--web-addr", "Base URL for AfRApay.Web"); webAddrOption.SetDefaultValue(new Uri("http://127.0.0.1:5296")); rootCommand.Add(webAddrOption); rootCommand.SetHandler((listReaders, webAddr) => { using var context = ContextFactory.Instance.Establish(SCardScope.System); // Ignore Yubikeys. var readerNames = context.GetReaders() .Where((readerName) => !readerName.Contains("Yubico")) .ToArray(); // We need at least one card reader or this won't work! if (readerNames.Length == 0) { Console.Error.WriteLine("Error: no card reader detected"); Environment.Exit(1); } // If --list-readers is passed, list readers and exit. if (listReaders) { Console.Error.WriteLine("----------- Connected Readers ----------"); foreach (var name in readerNames) { Console.WriteLine(name); } Console.Error.WriteLine("----------------------------------------"); Environment.Exit(0); } // Setup! using HttpClient httpClient = new(); httpClient.BaseAddress = webAddr; var state = TerminalState.Debit; decimal amount = defaultAmount; Console.Error.WriteLine("----------------------------------------"); Console.Error.WriteLine("--- AfRApay FTM - Fancy Test Machine ---"); Console.Error.WriteLine("----------------------------------------"); Console.Error.WriteLine(); Console.Error.WriteLine("AfRApay.Web: {0}", httpClient.BaseAddress); Console.Error.WriteLine(); Console.Error.WriteLine("Hotkeys (case insensitive):"); Console.Error.WriteLine(" [-] Debit (default)"); Console.Error.WriteLine(" [+] Credit"); Console.Error.WriteLine(" [=] Set amount (default: €{0:C})", amount/100M); Console.Error.WriteLine(); Console.Error.WriteLine(" [B] Balance query"); Console.Error.WriteLine(" [L] Link card (initiate from web UI)"); Console.Error.WriteLine(" [Esc] Cancel, reset state and amount"); Console.Error.WriteLine(); Console.Error.WriteLine("----------------------------------------"); // Listen for events on all connected readers. using var monitor = MonitorFactory.Instance.Create(SCardScope.System); monitor.Initialized += (_, args) => Console.WriteLine("[ Reader Initialized: {0} ]", args.ReaderName); monitor.MonitorException += (_, args) => { Console.Error.WriteLine("! ERROR: {0}", args); Environment.Exit(1); }; monitor.StatusChanged += (_, args) => Console.WriteLine("~ {0} -> {1}", args.LastState, args.NewState); monitor.CardInserted += (_, args) => { Console.WriteLine("> TAP: {0}", Convert.ToHexString(args.Atr)); var reader = new IsoReader(context, args.ReaderName, SCardShareMode.Shared, SCardProtocol.Any); HandleTap(reader, httpClient, state, amount); }; monitor.CardRemoved += (_, _) => { Console.WriteLine("< OFF"); Console.WriteLine(); // Write a blank line between card taps for readability. }; Console.WriteLine("[ Starting... ]"); monitor.Start(readerNames); // Handle hotkeys. while (true) { var key = Console.ReadKey(); var dontPrint = false; switch (key.Key) { case ConsoleKey.Subtract: case ConsoleKey.OemMinus: state = TerminalState.Debit; break; case ConsoleKey.Add: case ConsoleKey.OemPlus: state = TerminalState.Credit; break; case 0 when key.KeyChar == '=': Console.Error.Write("\b => ENTER AMOUNT: "); amount = Math.Abs(int.Parse(Console.ReadLine() ?? "150".Trim())); break; case ConsoleKey.L: state = TerminalState.Link; break; case ConsoleKey.B: state = TerminalState.Balance; break; case ConsoleKey.Escape: state = TerminalState.Debit; amount = defaultAmount; break; default: Console.Error.Write("\b"); dontPrint = true; break; } if (dontPrint) { // Invalid input, just ignore it. } else if (state is TerminalState.Debit or TerminalState.Credit) { Console.Error.WriteLine("\b => {0}: €{1}", state, amount); } else { Console.Error.WriteLine("\b => {0}", state); } } // ReSharper disable once FunctionNeverReturns }, listReadersOption, webAddrOption); return await rootCommand.InvokeAsync(args); // Queries a card for data when one is tapped. static async void HandleTap(IIsoReader reader, HttpClient httpClient, TerminalState state, decimal amount) { // Send a PCSC pseudo-APDU to query the ISO 14443 UID. var uidRsp = reader.Transmit(new CommandApdu(IsoCase.Case2Short, SCardProtocol.Any) { CLA = 0xFF, Instruction = InstructionCode.GetData, P1 = 0x00, P2 = 0x00, }); if (!IsSucc(uidRsp)) { Console.Error.WriteLine("--> Card Error: SW1={0} SW2={1}", (SW1Code)uidRsp.SW1, uidRsp.SW2); return; } var uid = uidRsp.GetData(); Console.WriteLine(" UID: {0}", Convert.ToHexString(uid)); // Query the backend, which endpoint depending on terminal state. switch (state) { case TerminalState.Debit: case TerminalState.Credit: var finalAmount = Math.Abs(amount) * (state == TerminalState.Debit ? -1 : 1); var idempotencyKey = new byte[16]; Random.Shared.NextBytes(idempotencyKey); var ik = Convert.ToHexString(idempotencyKey); await CallPut(httpClient, $"/api/card/{Convert.ToHexString(uid)}/transaction/{ik}?amount={finalAmount}"); break; case TerminalState.Link: await CallGet(httpClient, $"/api/card/{Convert.ToHexString(uid)}/link"); break; case TerminalState.Balance: await CallGet(httpClient, $"/api/card/{Convert.ToHexString(uid)}/balance"); break; default: Console.Error.WriteLine("UNKNOWN TERMINAL STATE: {0}", state); break; } } // Was the command successful? static bool IsSucc(Response rsp) { return rsp is { SW1: (byte)SW1Code.Normal, SW2: 0x00 }; } static async Task CallGet(HttpClient client, string path) { Console.WriteLine(" -> GET {0}", path); var rsp = await client.GetStringAsync(path); Console.WriteLine(" <- {0}", rsp); return rsp; } static async Task CallPut(HttpClient client, string path) { Console.WriteLine(" -> PUT {0}", path); var rsp = await client.PutAsync(path, new StringContent("")).Result.Content.ReadAsStringAsync(); Console.WriteLine(" <- {0}", rsp); return rsp; } // Terminal State. internal enum TerminalState { Debit, Credit, Link, Balance, };