

認証とセッション④
ICカードをかざして、タイムカードを作ってみる
前回まで、パスキーやクライアント証明書、マイナンバーカードの電子証明書など、公開鍵暗号を使った認証を見てきました。今回は少し身近な例として、勤怠打刻で使うICカードを考えます。
カードをリーダーにかざすと、何が読み取られるのでしょう。読み取った値は、どうやってC#のプログラムに届くのでしょうか。PC/SC方式の場合、カードをかざした瞬間にHTTP通信が始まるのでしょうか。
実際に流れを追うと、カードからIDを読むことと、カードそのものが本物だと確かめることは別だと分かります。さらに、読み取ったIDをどこに保存するかも考えてみます。
カードをかざすと、打刻までどう進む?

カードID方式の勤怠打刻では、カードリーダーがカードの識別子を読み取り、それをアプリへ渡します。社員検索や打刻記録は、リーダーではなくC#アプリとサーバー側が行います。

- リーダーがカードから識別子を読み取る
- C#アプリが識別子を受け取る
- 登録済みのカード情報から社員を検索する
- 出勤・退勤などの勤怠記録を保存する
ここで「カードID」と呼んでいる値は、カードや規格、リーダーによって、呼び名や形式が異なります。どのカードでも同じ値が読めるわけではありません。採用するカードとリーダーの仕様を確認します。
カードIDを読むことと、カードを認証することは別

カードIDを読み取って社員を検索する方式は、読み取った識別子を社員情報に結び付けています。読み取りだけで、カードが秘密鍵を使ったことや、登録者本人が操作していることまで確かめているとは限りません。
図の右側は、カードが秘密鍵で応答し、登録済みの公開鍵で検証する方式の一例です。ICカードの暗号認証には、カードと読み取り側が共有する秘密鍵を使う方式もあります。実際の方式はカードの規格と製品仕様によって異なります。
この違いを押さえておくと、「カードを読めた」ことと「カードの正当性を確かめた」ことを区別できます。カードIDの読み取りだけなら、社員を特定する手がかりにはなっても、本人確認そのものとは言えません。
カードIDをそのままデータベースに置かない
カードIDを社員情報と結び付けるとしても、生のIDをそのままデータベースに保存する必要はありません。照合値を保存する方法として、SHA-256とHMAC-SHA-256を比べます。

SHA-256は秘密鍵なしで計算できるハッシュ関数です。HMAC-SHA-256は、カードIDとアプリ側の秘密鍵を使って照合値を計算します。
SHA-256の場合
カードIDが同じなら、誰が計算しても同じハッシュ値になります。秘密鍵は必要ありません。
カードIDの候補が少ない場合、データベースからハッシュ値が漏れると、候補を次々にハッシュ化して照合される可能性があります。SHA-256だけでは、カードIDを秘密にするための鍵の役割はありません。
HMAC-SHA-256の場合
HMAC-SHA-256では、カードIDだけでなく、アプリ側に保管した秘密鍵も使って照合値を計算します。登録時と打刻時に同じカードIDと同じ鍵を使えば、同じ照合値になります。
この鍵はカードに入っている鍵ではありません。カードIDを読み取った後、C#アプリがHMACを計算するために使う鍵です。
パスワード認証とカードID認証では、探す順番が違う
パスワード認証では、先にユーザーIDを使って利用者の記録を探します。入力されたパスワードを所定の方法で処理し、データベースに保存されたその利用者のパスワードハッシュと照合します。つまり、「このユーザーIDに対するパスワードか」を確かめます。
カードID認証では、カードから読み取ったIDでHMAC照合値を計算し、その値に対応するユーザーIDを探します。つまり、「このカードIDは誰に登録されているか」を調べます。HMAC鍵はカードではなく、アプリ側に保管します。
秘密鍵を保管する金庫の鍵は、どこに置く?

HMACに使う秘密鍵は、一度生成して保管し、カードの登録時と打刻時に同じものを使います。しかし、ソースコードに直接書いたり、誰でも読める場所に保存したりすると、鍵が漏れる原因になります。
では、鍵を暗号化して保存すればよいのでしょうか。今度は「その暗号化に使う鍵を、どこに置くのか」という問題が生まれます。そこで、最後の鍵の管理を、OSやクラウドの仕組みに任せます。
Windowsでは、DPAPIを使って秘密鍵を暗号化し、レジストリなどへ保存できます。ユーザー単位の保護(CurrentUser)では、暗号化したWindowsユーザーの実行環境で復号します。アプリ自身が、暗号化用の別の固定鍵をソースコードに持つ必要はありません。
HKEY_CURRENT_USERは、現在の実行ユーザーに対応するレジストリ領域です。ユーザーAでログインすればAの領域、ユーザーBでログインすればBの領域が表示されます。Aが保存したデータが消えるわけではありません。また、ここに置くだけで暗号化されるわけでもありません。保存先のアクセス権と、DPAPIによる暗号化を組み合わせて保護します。
クラウドでは、Azure Key Vaultなどに秘密鍵を保管できます。マネージドIDを使えば、Azureが管理するアプリの身元に基づいて、権限のあるアプリに鍵の取り出しを許可できます。さらに、接続元のIP制限などを追加できます。IP制限は、アプリの認証や権限確認と併用するものです。
どちらも、「どのユーザー・アプリなら鍵を取り出してよいか」を基盤側で管理する考え方です。同じWindowsユーザーの権限で動く別のプログラムもアクセス・復号できる場合があり、管理者にも例外があります。アプリ専用の実行アカウントや権限の管理も、鍵の保護の一部になります。
鍵を紛失すると、登録済みのHMAC照合値を同じように計算できなくなります。鍵を交換した場合も照合値が変わるため、バックアップやカードの再登録など、復旧・移行の方法を決めておきます。
HMAC鍵を一度だけ生成し、レジストリへ保存する
ここではWindows上のC#アプリを想定し、暗号用の乱数から32バイト(256ビット)の鍵を一度だけ生成します。DPAPIのCurrentUserで暗号化し、HKEY_CURRENT_USER\Software\AttendanceSampleのCardIdHmacKeyへバイナリ値として保存します。レジストリのパスや値の名前は秘密ではなく、ソースコードに記載できます。
using System;
using System.Security.Cryptography;
using Microsoft.Win32;
public static class CardHmacKeyStore
{
private const string RegistryPath = @"Software\AttendanceSample";
private const string ValueName = "CardIdHmacKey";
// セットアップ時だけ実行。通常起動時には呼ばない。
public static void InitializeOnce()
{
using (var registry = Registry.CurrentUser.CreateSubKey(RegistryPath))
{
if (registry == null)
throw new InvalidOperationException("保存先を開けません。");
if (registry.GetValue(ValueName) != null)
throw new InvalidOperationException("鍵は登録済みです。");
var key = new byte[32]; // 256ビット
try
{
using (var rng = RandomNumberGenerator.Create())
{
rng.GetBytes(key);
}
byte[] encrypted = ProtectedData.Protect(
key, null, DataProtectionScope.CurrentUser);
registry.SetValue(ValueName, encrypted, RegistryValueKind.Binary);
}
finally
{
Array.Clear(key, 0, key.Length);
}
}
}
// 通常起動時に、保管済みの鍵を読み出す。
public static byte[] Load()
{
using (var registry = Registry.CurrentUser.OpenSubKey(RegistryPath))
{
byte[] encrypted = registry == null
? null
: registry.GetValue(ValueName) as byte[];
if (encrypted == null)
throw new InvalidOperationException("HMAC鍵が未登録です。");
byte[] key = ProtectedData.Unprotect(
encrypted, null, DataProtectionScope.CurrentUser);
if (key.Length != 32)
{
Array.Clear(key, 0, key.Length);
throw new InvalidOperationException("鍵の長さが不正です。");
}
return key;
}
}
}この例ではBase64文字列にはせず、暗号化されたバイト列を保存します。.NET FrameworkではSystem.Securityへの参照が必要です。現行.NETではWindowsを対象にし、NuGetのSystem.Security.Cryptography.ProtectedDataを追加します。
InitializeOnceは、セットアップ時に単独で一度だけ実行します。既存の鍵があれば上書きせず停止します。通常起動時はLoadで読み出し、DPAPIで復号します。鍵が見つからない場合も、新しい鍵を勝手に生成せずエラーにします。鍵を作り直すと、登録済みの照合値と一致しなくなるためです。
// 初回セットアップ時に一度だけ、同じ実行ユーザーで呼ぶ。
// CardHmacKeyStore.InitializeOnce();
// 通常起動時はこちら。未登録でも新しい鍵は自動生成しない。
byte[] hmacKey = CardHmacKeyStore.Load();
// このhmacKeyを、以下のカード登録・打刻処理で共通に使用する。
// 終了時は、処理中の打刻がないことを確認してから解放する。
// Array.Clear(hmacKey, 0, hmacKey.Length);セットアップと通常実行は、同じWindowsユーザー・同じ端末で行う前提です。別ユーザーで起動すると参照するHKEY_CURRENT_USERが変わります。IISやWindowsサービスで使う場合は、実行アカウントとユーザープロファイルを確認します。読み出したhmacKeyは起動中に保持して登録・打刻に使い、利用を終えてから消去します。
カードIDから照合値を作る
読み取ったIDの表記を登録時と打刻時で同じ形にそろえてから、HMAC-SHA-256を計算します。
using System.Security.Cryptography;
using System.Text;
static byte[] CreateCardLookupValue(string canonicalCardId, byte[] hmacKey)
{
using (var hmac = new HMACSHA256(hmacKey))
{
var input = Encoding.UTF8.GetBytes(canonicalCardId);
return hmac.ComputeHash(input);
}
}このメソッドは32バイトの照合値を返します。データベースには、たとえばbinary(32)の列に照合値を保存し、社員レコードにひも付けます。登録時も同じ鍵と同じ正規化処理で照合値を作ります。
byte[] lookupValue = CreateCardLookupValue(canonicalCardId, hmacKey);
await cardRepository.RegisterAsync(employeeId, lookupValue);打刻時は、読み取ったカードIDから同じ照合値を作って検索します。
async Task PunchAsync(string canonicalCardId)
{
byte[] lookupValue =
CreateCardLookupValue(canonicalCardId, hmacKey);
var card = await cardRepository.FindByLookupValueAsync(lookupValue);
if (card == null)
{
Console.WriteLine("登録されていないカードです。");
return;
}
await punchRepository.SaveAsync(card.EmployeeId, DateTime.Now);
}cardRepositoryとpunchRepositoryは、データベース検索・保存を行う処理の例です。実際には、打刻区分、時刻のタイムゾーン、二重打刻の扱いなどを勤怠システムの要件に合わせて決めます。
C#アプリへカードIDを渡す方法は二つある
リーダーからC#アプリへIDを渡す方法には、キーボード方式とPC/SC方式があります。どちらも、IDを受け取った後は同じ社員検索・打刻処理へ渡せます。
キーボード方式:入力欄にIDが打ち込まれる
キーボード方式のリーダーは、カードIDをキーボード入力のように送ります。機器の設定によっては、IDの後にEnterキーも送れます。WinFormsならID受け取り用のテキストボックスにフォーカスを合わせておきます。
private async void txtCardId_KeyDown(object sender, KeyEventArgs e)
{
if (e.KeyCode != Keys.Enter)
return;
e.SuppressKeyPress = true;
var rawCardId = txtCardId.Text.Trim();
txtCardId.Clear();
if (rawCardId.Length == 0)
return;
var canonicalCardId = NormalizeKeyboardCardId(rawCardId);
await attendance.PunchAsync(canonicalCardId);
txtCardId.Focus();
}動きは、バーコードリーダーが文字をテキストボックスへ入力する場合に似ています。C#アプリがリーダー専用APIを呼ばなくても、入力欄でIDを受け取れます。カードIDは文字列として扱い、打刻後に入力欄を消し、ログにも生のIDを記録しません。
PC/SC方式:C#アプリがリーダーにコマンドを送る
PC/SC方式では、C#アプリがPC/SC対応リーダーを監視し、カードが読み取り位置に置かれた通知を受けて、カードへAPDUコマンドを送ります。SCardTransmitやライブラリのTransmitは、リーダーを通じてカードへコマンドを送り、その応答を受け取る処理です。
カードをかざしただけで、リーダーが自動的にHTTPリクエストを送るわけではありません。まず利用者側のPC上で動くC#アプリがリーダーとカードに問い合わせます。そのアプリが必要に応じて、受け取った結果をWebサーバーへ送ります。
以下はPCSCパッケージを使う構成の骨組みです。モニター通知を受ける処理と、カードへコマンドを送る処理を分けます。
using PCSC;
using PCSC.Monitoring;
using System;
using System.Threading.Tasks;
static void StartCardMonitor(AttendanceService attendance)
{
using (var context =
ContextFactory.Instance.Establish(SCardScope.System))
{
var readerNames = context.GetReaders();
if (readerNames == null || readerNames.Length == 0)
throw new InvalidOperationException("リーダーが見つかりません。");
using (var monitor = MonitorFactory.Instance.Create(SCardScope.System))
{
monitor.CardInserted += (sender, args) =>
{
Task.Run(async () =>
{
try
{
var rawCardId = ReadCardId(args.ReaderName);
var canonicalCardId =
NormalizePcscCardId(rawCardId);
await attendance.PunchAsync(canonicalCardId);
}
catch (Exception ex)
{
Console.WriteLine($"読み取りエラー: {ex.Message}");
}
});
};
monitor.Start(readerNames);
Console.WriteLine("カードをかざしてください。終了するにはEnter。");
Console.ReadLine();
}
}
}次の例は、PC/SC対応リーダーの一部で使われるUID取得用コマンドを送る形です。FF CA 00 00 00相当のコマンドは、すべてのカードやリーダーで共通ではありません。製品の仕様書で対応を確認してください。
using PCSC;
using PCSC.Iso7816;
static string ReadCardId(string readerName)
{
using (var context =
ContextFactory.Instance.Establish(SCardScope.System))
using (var reader = context.ConnectReader(
readerName, SCardShareMode.Shared, SCardProtocol.Any))
{
var apdu = new CommandApdu(IsoCase.Case2Short, reader.Protocol)
{
CLA = 0xFF,
Instruction = InstructionCode.GetData,
P1 = 0x00,
P2 = 0x00,
Le = 0
};
using (reader.Transaction(SCardReaderDisposition.Leave))
{
var command = apdu.ToArray();
var responseBuffer = new byte[256];
var bytesReceived = reader.Transmit(
SCardPCI.GetPci(reader.Protocol), command, command.Length,
new SCardPCI(), responseBuffer, responseBuffer.Length);
var response = new ResponseApdu(
responseBuffer, bytesReceived, IsoCase.Case2Short, reader.Protocol);
if (response.SW1 != 0x90 || response.SW2 != 0x00 || !response.HasData)
throw new InvalidOperationException("カードIDを取得できませんでした。");
return BitConverter.ToString(response.GetData()).Replace("-", "");
}
}
}PC/SCのコードは、リーダーを接続したPC上のC#プロセスで実行します。Webサーバー上のC#コードだけでは、利用者のPCに接続されたリーダーを直接操作できません。
キーボード方式の出力が10進数で、PC/SC方式の出力が16進数なら、同じカードでも文字列表現が異なる場合があります。登録時と打刻時に同じIDとして扱えるよう、出力形式とNormalizeの処理をそろえます。
HMACにしても、カードが本物だとは証明できない

HMACを使うと、データベースに生のカードIDを保存せずに検索できます。しかし、カードそのものが正規のカードだと確認できるわけではありません。
カードを貸して、別の人がかざした場合を考えます。カードID方式では、アプリが読み取ったIDを社員情報に照合するため、記録には登録者の出勤として残ります。システムが確かめたのはカードIDであって、カードを操作した人の本人性ではありません。
さらに、カードIDを複製できる方式なら、複製したカードから同じIDが読まれる可能性があります。アプリは同じIDから同じHMAC照合値を作るため、HMACだけではカードの複製を防げません。HMAC鍵はカード内ではなくアプリ側にあるからです。

カード自体が暗号処理に参加する方式では、リーダー側が使い捨ての確認データを送り、カードが秘密鍵で応答するなどして、カードの正当性を確かめられます。ただし、方式によって共通鍵や公開鍵などの使い方は異なり、カードとリーダー双方の対応が必要です。
この例でのHMACの役割は、主にデータベースへ生のカードIDを置かずに照合することです。データベースの漏えい対策と、カードを本物と確認する仕組みは、別の課題として設計します。
カードリーダーから打刻までをつなげて考える
今回のカードID方式では、リーダーがIDを読み取り、C#アプリが受け取ります。アプリはHMAC-SHA-256で照合値を作り、登録済みの値から社員を検索して、打刻を保存します。
キーボード方式は、IDを入力欄へ送ります。PC/SC方式は、アプリがリーダーへコマンドを送り、カードの応答からIDを取り出します。入口は違いますが、IDを受け取った後の社員検索と打刻処理は共通化できます。
ただし、カードIDの読み取りは本人認証そのものではありません。どのカードを使うのか、リーダーがどの形式でIDを渡すのか、カードの複製や貸し借りをどこまで防ぎたいのかを分けて考えると、勤怠システムの構成が見えてきます。
参考資料
- Microsoft Learn:RandomNumberGenerator.GetBytes
- Microsoft Learn:HMACSHA256
- pcsc-sharp:リーダーの監視例
- pcsc-sharp:APDUの送受信例
- NIST FIPS 198-1:HMAC
- Microsoft Learn:DPAPIによるデータの保護
- Microsoft Learn:Azure Key Vaultの認証
- Microsoft Learn:Azure Key Vaultのネットワーク制限
次回
次回は「認証とセッション⑤ ― ログインしたことを、Webサイトはどう覚えているのか」です。
カードやパスワードなどでログインした後、ページを移動してもログイン状態が続くのはなぜか。認証の後に使われるセッションの仕組みを見ていきます。