利用PCSC協定透過讀卡機讀取全民健保卡

最近COVID-19疫情肆虐,全民健保卡突然搖身一變成為口罩領取的重要憑證,剛好有空研究一下相關的操作方式,發現其實還蠻好寫的(感謝前人包裝的PCSC wrapper class),加上網路上也有許多先進已經把APDU指令集整個sniffer完成,因此不到30分鐘就把雛型寫好了。

PS/CS相關知識

  1. PC/SC的全名是Personal Computer / SmartCard,是微軟創建的一個標準的通訊協定,意圖統一所有的智慧晶片卡之存取。
  2. 支援許多通訊界面,例如:USB、RS232、PCMCIA...
  3. 所有的讀卡機廠商若想要在Windows上面玩,就得自己寫PC/SC驅動程式。
  4. PC/SC於USB設備之調用,基本上是透過CCID(Intergrated Circuts Card Interface Device)協定來進行,CCID是USB組織制定的一個標準。
  5. CCID跟最終端的智慧晶片卡(ICC;Intergrated Chips Card),則是透過ISO 7816 標準協定來進行。
  6. APDU全名為Smart card Application Protocol Data Unit,簡單的說就是從讀卡機送資料給智慧晶片卡的封包協定,最必要的輸出格式包含了四個Bytes的標頭(CLA, INS, P1, P2)與至多65535 bytes的資料。

結論:Application > Windows PC/SC > USB > CCID > ISO 7816,大致上存取制度如上。

感謝前輩封裝好的PS/CS類別

上面講了這麼多的標準,如果用Unmanaged寫法直接去呼叫Win32 API一定會很想死,所以第一時間在nuget發現了Daniel Mueller前輩已經幫我們封裝成.NET版本的PCSC wrapper類別庫,以下是相關連結:

我在這邊把PCSC Library 5.0.0.zip放在雲端硬碟,有需要的人可自行下載。

台灣全民健保卡顯性資料汲取程式

有了上面的觀念與前輩的類別後,操作PCSC協定簡直易如反掌,以下是我所撰寫的範例程式碼,想要少寫一些namespace的人記得using一下PCSC跟PCSC.Iso7816喔。

using System;
using System.Linq;

namespace Console_Simply
{
  class Program
  {
    public static void Main()
    { //讀卡機名稱
      string cReader;
      //尋找本機讀卡機設備
      using (var oReaders = PCSC.ContextFactory.Instance.Establish(PCSC.SCardScope.User))
      {
        cReader = oReaders.GetReaders().FirstOrDefault();
        if (string.IsNullOrEmpty(cReader))
        { //找不到任何PSCS讀卡機就跳走
          Console.WriteLine("# 系統找不到任何讀卡機,請重新檢查硬體。");
          return;
        }
        else
        {
          Console.Clear();
          Console.WriteLine($"# 歡迎使用台灣全民健保卡檢視程式 / by slashlook.com");
          Console.WriteLine($"# 共找到「{oReaders.GetReaders().Count()} 部設備」並使用「{cReader}」讀卡機,程式運行期間請勿任意移除設備。");
        }
      }
      //建立事件監控
      using (var oMonitor = PCSC.Monitoring.MonitorFactory.Instance.Create(PCSC.SCardScope.System))
      {
        oMonitor.CardRemoved += (oSender, oArgs) => { Console.WriteLine("# 偵測到晶片卡移除。"); };
        oMonitor.CardInserted += (oSender, oArgs) =>
        {
          Console.WriteLine("# 偵測到晶片卡插入。");
          GetCardInfo(cReader);  //讀取健保卡顯性資料
        };
        oMonitor.MonitorException += (oSender, oArgs) =>
        {
          Console.WriteLine("# 讀卡機被移除或是讀取晶片卡出現異常,請重新啟動程式。");
          System.Environment.Exit(0); //強制退出
        };
        oMonitor.Start(cReader);
        //有可能執行程式前讀卡機與卡片就都已經準備好,如此一來並不會觸發事件,因此先強制執行一次讀取看看
        try
        { GetCardInfo(cReader); }
        catch
        { } //若有讀取出錯就直接跳過(可能是未插卡)
        //設定離開程序
        System.ConsoleKeyInfo oKey;
        do
        {
          Console.WriteLine("# 若需要離開程式,請按下ESC按鈕。");
          oKey = Console.ReadKey(true);
        } while (oKey.Key != System.ConsoleKey.Escape);
      }
      //程式結束
      Console.WriteLine("# 程式結束。");
    }

    /// <summary>
    /// 讀取台灣全民健保卡顯性資料
    /// </summary>
    public static void GetCardInfo(string cReader)
    {
      using (var oContext = PCSC.ContextFactory.Instance.Establish(PCSC.SCardScope.User))
      using (var oReader = new PCSC.Iso7816.IsoReader(
        context: oContext,
        readerName: cReader,
        mode: PCSC.SCardShareMode.Shared,
        protocol: PCSC.SCardProtocol.Any
      ))
      {
        Console.WriteLine("-----");
        //初始化健保卡
        var oAdpuInit = new PCSC.Iso7816.CommandApdu(PCSC.Iso7816.IsoCase.Case4Short, oReader.ActiveProtocol)
        { 
          CLA = 0x00,
          INS = 0xA4,
          P1 = 0x04,
          P2 = 0x00,
          Data = new byte[] { 0xD1, 0x58, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x11 }
        };
        Console.WriteLine($"@ APDU InitCard:   {System.BitConverter.ToString(oAdpuInit.ToArray())}");
        //取得初始化健保卡回應
        var oAdpuInitResponse = oReader.Transmit(oAdpuInit);
        Console.WriteLine($"@ Response:        SW1={oAdpuInitResponse.SW1.ToString("X")}|SW2={oAdpuInitResponse.SW2.ToString("X")}");
        //檢查回應是否正確(144;00)
        if (!(oAdpuInitResponse.SW1.Equals(144) && oAdpuInitResponse.SW2.Equals(0)))
        {
          Console.WriteLine("-----");
          Console.WriteLine("# 晶片卡並非健保卡,請換張卡片試看看。");
          return;
        }
        //讀取健保顯性資訊
        var oAdpuProfile = new PCSC.Iso7816.CommandApdu(PCSC.Iso7816.IsoCase.Case4Short, oReader.ActiveProtocol)
        {
          CLA = 0x00,
          INS = 0xCA,
          P1 = 0x11,
          P2 = 0x00,
          Data = new byte[] { 0x00, 0x00 }
        };
        Console.WriteLine($"@ APDU GetProfile: {System.BitConverter.ToString(oAdpuProfile.ToArray())}");
        //取得讀取健保卡顯性資訊回應
        var oAdpuProfileResponse = oReader.Transmit(oAdpuProfile);
        Console.WriteLine($"@ Response:        SW1={oAdpuProfileResponse.SW1.ToString("X")}|SW2={oAdpuProfileResponse.SW2.ToString("X")}");
        //檢查回應是否正確(144;00)
        if (!(oAdpuInitResponse.SW1.Equals(144) && oAdpuInitResponse.SW2.Equals(0)))
        {
          Console.WriteLine("-----");
          Console.WriteLine("# 健保卡讀取錯誤,請換張卡片試看看。");
          return;
        }
        //如果有回應且具備資料的話,就將其輸出到畫面上
        if (oAdpuProfileResponse.HasData)
        { //播放提示音
          Console.Beep();
          //位元組資料
          byte[] aryData = oAdpuProfileResponse.GetData();
          //文字編碼解碼器
          var oUTF8 = System.Text.Encoding.UTF8;
          var oBIG5 = System.Text.Encoding.GetEncoding("big5");
          //建立使用者匿名物件
          var oUser = new
          {
            cCardNumber  = oUTF8.GetString(aryData.Take(12).ToArray()),
            cName        = oBIG5.GetString(aryData.Skip(12).Take(20).ToArray()),
            cID          = oUTF8.GetString(aryData.Skip(32).Take(10).ToArray()),
            cBirthday    = oUTF8.GetString(aryData.Skip(42).Take(7).ToArray()),
            cGender      = oUTF8.GetString(aryData.Skip(49).Take(1).ToArray()) == "M" ? "男" : "女",
            cCardPublish = oUTF8.GetString(aryData.Skip(50).Take(7).ToArray())
          };
          //輸出資料
          Console.WriteLine("-----");
          Console.WriteLine($"卡號  :{oUser.cCardNumber}");
          Console.WriteLine($"姓名  :{oUser.cName}");
          Console.WriteLine($"身分證號:{oUser.cID}");
          Console.WriteLine($"生日  :{oUser.cBirthday}");
          Console.WriteLine($"性別  :{oUser.cGender}");
          Console.WriteLine($"發卡日期:{oUser.cCardPublish}");
          Console.WriteLine("-----");
        }
      }
    }
  }
}

大家可以發現這個PCSC wrapper類別竟然還貼心地附上事件監控Monitor的類別,簡直超級佛心,讓我們這些晚輩可以快快樂樂運用上事件監控的寫法,最後利用ILMerge合併PCSC.dll與PCSC.Iso7816.dll這兩個類別庫後,附上編譯好的單一可執行檔案讓大家可以測試看看:

台灣全民健保卡測試程式(.Net Framework 4.8)

程式運行畫面:

C# CSharp PCSC PC/SC SmartCard Read/Write TaiwanHealthCard Event Monitoring