認証とセッション

 認証とセッション④

 ICカードをかざして、タイムカードを作ってみる

前回まで、パスキーやクライアント証明書、マイナンバーカードの電子証明書など、公開鍵暗号を使った認証を見てきました。今回は少し身近な例として、勤怠打刻で使うICカードを考えます。

カードをリーダーにかざすと、何が読み取られるのでしょう。読み取った値は、どうやってC#のプログラムに届くのでしょうか。PC/SC方式の場合、カードをかざした瞬間にHTTP通信が始まるのでしょうか。

実際に流れを追うと、カードからIDを読むことと、カードそのものが本物だと確かめることは別だと分かります。さらに、読み取ったIDをどこに保存するかも考えてみます。

カードをかざすと、打刻までどう進む?

ICカードを打刻端末にかざし、出勤時刻が表示される場面
カードをかざしたあと、社員検索と打刻記録はアプリ側で行います。

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

カードIDを読み取り、社員情報を検索して勤怠記録へつなぐ流れ
読み取っているのはカードIDです。この段階では、操作した人が登録者本人かまでは確かめていません。
  1. リーダーがカードから識別子を読み取る
  2. C#アプリが識別子を受け取る
  3. 登録済みのカード情報から社員を検索する
  4. 出勤・退勤などの勤怠記録を保存する

ここで「カードID」と呼んでいる値は、カードや規格、リーダーによって、呼び名や形式が異なります。どのカードでも同じ値が読めるわけではありません。採用するカードとリーダーの仕様を確認します。

カードIDを読むことと、カードを認証することは別

カードIDを読み取る方式と、暗号による応答を検証する方式の比較
図の暗号認証は公開鍵方式の例です。ICカードの方式には、共通鍵を使うものもあります。

カードIDを読み取って社員を検索する方式は、読み取った識別子を社員情報に結び付けています。読み取りだけで、カードが秘密鍵を使ったことや、登録者本人が操作していることまで確かめているとは限りません。

図の右側は、カードが秘密鍵で応答し、登録済みの公開鍵で検証する方式の一例です。ICカードの暗号認証には、カードと読み取り側が共有する秘密鍵を使う方式もあります。実際の方式はカードの規格と製品仕様によって異なります。

この違いを押さえておくと、「カードを読めた」ことと「カードの正当性を確かめた」ことを区別できます。カードIDの読み取りだけなら、社員を特定する手がかりにはなっても、本人確認そのものとは言えません。

カードIDをそのままデータベースに置かない

カードIDを社員情報と結び付けるとしても、生のIDをそのままデータベースに保存する必要はありません。照合値を保存する方法として、SHA-256とHMAC-SHA-256を比べます。

SHA-256はカードIDだけを使い、HMAC-SHA-256はアプリの秘密鍵も使って照合値を作る図
SHA-256とHMAC-SHA-256の違い。HMACの鍵はカードではなくアプリ側に保管します。

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秘密鍵の保管をWindowsのDPAPIとAzure Key Vaultで比較。ユーザーAとユーザーBのレジストリ領域、アプリのマネージドIDによるアクセス制御を示す
レジストリは保管場所、DPAPIは暗号化を担当します。クラウドではアプリの身元と権限を確認し、IP制限なども組み合わせます。

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へバイナリ値として保存します。レジストリのパスや値の名前は秘密ではなく、ソースコードに記載できます。

C#
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で復号します。鍵が見つからない場合も、新しい鍵を勝手に生成せずエラーにします。鍵を作り直すと、登録済みの照合値と一致しなくなるためです。

C#
// 初回セットアップ時に一度だけ、同じ実行ユーザーで呼ぶ。
// 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を計算します。

C#
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)の列に照合値を保存し、社員レコードにひも付けます。登録時も同じ鍵と同じ正規化処理で照合値を作ります。

C#
byte[] lookupValue = CreateCardLookupValue(canonicalCardId, hmacKey);
await cardRepository.RegisterAsync(employeeId, lookupValue);

打刻時は、読み取ったカードIDから同じ照合値を作って検索します。

C#
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受け取り用のテキストボックスにフォーカスを合わせておきます。

C#
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パッケージを使う構成の骨組みです。モニター通知を受ける処理と、カードへコマンドを送る処理を分けます。

C#
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相当のコマンドは、すべてのカードやリーダーで共通ではありません。製品の仕様書で対応を確認してください。

C#
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にしても、カードが本物だとは証明できない

カードを借りた人が打刻すると、登録者本人の勤怠として記録される問題
カードIDを使うだけの打刻では、カードを使った人と記録上の社員が異なることがあります。

HMACを使うと、データベースに生のカードIDを保存せずに検索できます。しかし、カードそのものが正規のカードだと確認できるわけではありません。

カードを貸して、別の人がかざした場合を考えます。カードID方式では、アプリが読み取ったIDを社員情報に照合するため、記録には登録者の出勤として残ります。システムが確かめたのはカードIDであって、カードを操作した人の本人性ではありません。

さらに、カードIDを複製できる方式なら、複製したカードから同じIDが読まれる可能性があります。アプリは同じIDから同じHMAC照合値を作るため、HMACだけではカードの複製を防げません。HMAC鍵はカード内ではなくアプリ側にあるからです。

端末が使い捨ての確認データを送り、ICカードが秘密鍵で署名して応答する公開鍵方式の例
カードに暗号処理をさせる方式では、カードの正当性を暗号で確かめられます。

カード自体が暗号処理に参加する方式では、リーダー側が使い捨ての確認データを送り、カードが秘密鍵で応答するなどして、カードの正当性を確かめられます。ただし、方式によって共通鍵や公開鍵などの使い方は異なり、カードとリーダー双方の対応が必要です。

この例でのHMACの役割は、主にデータベースへ生のカードIDを置かずに照合することです。データベースの漏えい対策と、カードを本物と確認する仕組みは、別の課題として設計します。

カードリーダーから打刻までをつなげて考える

今回のカードID方式では、リーダーがIDを読み取り、C#アプリが受け取ります。アプリはHMAC-SHA-256で照合値を作り、登録済みの値から社員を検索して、打刻を保存します。

キーボード方式は、IDを入力欄へ送ります。PC/SC方式は、アプリがリーダーへコマンドを送り、カードの応答からIDを取り出します。入口は違いますが、IDを受け取った後の社員検索と打刻処理は共通化できます。

ただし、カードIDの読み取りは本人認証そのものではありません。どのカードを使うのか、リーダーがどの形式でIDを渡すのか、カードの複製や貸し借りをどこまで防ぎたいのかを分けて考えると、勤怠システムの構成が見えてきます。

参考資料

次回

次回は「認証とセッション⑤ ― ログインしたことを、Webサイトはどう覚えているのか」です。

カードやパスワードなどでログインした後、ページを移動してもログイン状態が続くのはなぜか。認証の後に使われるセッションの仕組みを見ていきます。

返信を残す

メールアドレスが公開されることはありません。 ※ が付いている欄は必須項目です

CAPTCHA