JMRTDのパーソナライズをする

 JMRTDリポジトリ)は、ICAOによって定められたMRTD(電子パスポート)のオープンな実装です。Javacardで使用できるアプレットは2009年を最後に更新されていませんが、今でも十分使用することができます。

 しかしながら、パスポート情報の書き込み(パーソナライズと言われます)を行うプログラムの大部分が非推奨へと移行され、GUIも使用できなくなった現状では使用するハードルは高くなってしまいました。この記事では簡単にアプレットのインストールからパーソナライズまでを紹介します。

カードを手に入れる

 Javacardに対応したカードは様々なルートで入手できますが、チップが本物であるかは確認が必要です。簡易的な用途ではコピー品でも問題ないでしょうが、セキュリティが重要な用途で使用する場合は信頼できるベンダから購入するほうが良いでしょう(多くの場合在庫がないかMOQが多く、入手性が悪いです)。

 私はこちらを注文しました。サンプルだけ注文して追加注文をしなかったために微妙な反応をされたので小ロットの注文はよろしく無いかもしれません。J3R180等であれば複数のサイトで販売されているのでそちらも検討してみて下さい。この商品はコピー品ではなさそうです。

アプレットをインストールする

 最近のカードではJMRTDのEAC実装が使用できないためにインストールに失敗するので、EACを除去した版を使用します。

 Makefileを書き換えます。JDK1.8以前が必要です。Javacard SDKはここからダウンロードできます。

Makefile

JAVA_HOME=/usr/lib/jvm/java-8-openjdk

# java card kit must be version 2.2.1 for gpshell
#JCPATH=/usr/local/java/java_card_kit-2_2_2
JCPATH=/path/to/oracle_javacard_sdks/jc222_kit/
# JCPATH=$(HOME)/java/java_card_kit-2_1_2
# JCPATH=$(HOME)/java/java_card_kit-2_2_1

makeし、カードをリーダーに差し込んでからGlobal Platform Proを使ってインストールします。

make
java -jar ../gp.jar --install applet.cap

パーソナライズ

ドキュメントは存在せず、最新のJMRTD Host APIには直接の書き込み機能が無いため情報がほとんどありません。Maven Centralからライブラリを読み込んで書き込みのためのプログラムを書く必要があります。(バイト列処理部を中心にGemini/Claudeを使用しました)

MRZ、顔写真、署名用の鍵(RSA/ECDSA)、署名の画像(任意)を用意して書き込みます。

書き換えを禁止するためにロックする必要がありますが、そのコードは含まれていません。 

package com.example.package;

import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.security.*;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.security.interfaces.RSAPrivateKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import javax.smartcardio.Card;
import javax.smartcardio.CardChannel;
import javax.smartcardio.CardTerminal;
import javax.smartcardio.CommandAPDU;
import javax.smartcardio.ResponseAPDU;
import javax.smartcardio.TerminalFactory;

import net.sf.scuba.data.Gender;
import org.jmrtd.BACKey;
import org.jmrtd.PassportService;
import org.jmrtd.lds.DisplayedImageInfo;
import org.jmrtd.lds.ImageInfo;
import org.jmrtd.lds.SODFile;
import org.jmrtd.lds.icao.*;
import org.jmrtd.lds.iso19794.FaceImageInfo;
import org.jmrtd.lds.iso19794.FaceInfo;

public class Personalizer {

    // JMRTDアプレットのデフォルトAID
    private static final byte[] APPLET_AID = {
            (byte) 0xA0, 0x00, 0x00, 0x02, 0x47, 0x10, 0x01
    };

    public static void main(String[] args) {
        try {
            // 1. スマートカードリーダーに接続
            TerminalFactory factory = TerminalFactory.getDefault();
            CardTerminal terminal = factory.terminals().list().get(0);
            Card card = terminal.connect("*");
            CardChannel channel = card.getBasicChannel();

            // 2. MRTDアプレットの選択 (SELECT APPLET)
            CommandAPDU selectApplet = new CommandAPDU(0x00, 0xA4, 0x04, 0x0C, APPLET_AID);
            ResponseAPDU response = channel.transmit(selectApplet);
            if (response.getSW() != 0x9000) {
                throw new RuntimeException("アプレットの選択に失敗しました: " + Integer.toHexString(response.getSW()));
            }

            System.out.println("--- Active Authentication (AA) 鍵の準備 ---");
            KeyPairGenerator aaKeyGen = KeyPairGenerator.getInstance("RSA");
            aaKeyGen.initialize(1024); // JMRTD側で固定
            KeyPair aaKeyPair = aaKeyGen.generateKeyPair();
            PublicKey aaPublicKey = aaKeyPair.getPublic();
            PrivateKey aaPrivateKey = aaKeyPair.getPrivate(); // 👈 後でカード内部に登録します

            System.out.println("--- DG1 (MRZ) のデータ生成 ---");
            // 3. JMRTDライブラリを使ってMRZのバイナリ(ASN.1)を生成
            MRZInfo mrzInfo = new MRZInfo(
                    "P", "JPN", "YAMADA", "TARO",
                    "TK1234567", "JPN", "010101", Gender.MALE, "350101", ""
            );
            DG1File dg1File = new DG1File(mrzInfo);
            byte[] dg1Bytes = dg1File.getEncoded();

            // 4. カードへDG1を書き込む
            System.out.println("DG1の書き込みを開始します...");
            createFile(channel, PassportService.EF_DG1, dg1Bytes.length);
            selectFile(channel, PassportService.EF_DG1);
            updateBinary(channel, dg1Bytes);
            System.out.println("DG1の書き込みが成功しました。");

            System.out.println("--- DG2 (顔写真) の書き込み ---");
            
            // 5. 写真ファイル(JPEG 2000等)をバイト配列として読み込み
            File imgFile = new File("face.jp2");
            byte[] rawImgBytes = new byte[(int) imgFile.length()];
            try (FileInputStream fis = new FileInputStream(imgFile)) {
                fis.read(rawImgBytes);
            }

            // ⭕ 大量の未指定パラメータと一緒に、末尾で生の画像バイナリを流し込む
            FaceImageInfo faceImageInfo = new FaceImageInfo(
                    Gender.MALE,       // 性別
                    FaceImageInfo.EyeColor.BLACK,         // 目の色
                    0,                                          // featureMask
                    0,                                          // hairColor (未指定)
                    0,                                          // expression (未指定)
                    new int[3],                                 // poseAngle
                    new int[3],                                 // poseAngleUncertainty
                    0,                                          // faceImageType (0: BASIC)
                    0,                                          // colorSpace
                    0,                                          // sourceType
                    0,                                          // deviceType
                    0,                                          // quality
                    new FaceImageInfo.FeaturePoint[0],          // 特徴点リスト (空配列)
                    800,                                        // 画像の横幅
                    800,                                        // 画像の縦幅
                    new ByteArrayInputStream(rawImgBytes),      // 👈 [16番目] ここにストリームを渡す!
                    rawImgBytes.length,                         // 👈 [17番目] 画像データの長さ
                    FaceImageInfo.IMAGE_DATA_TYPE_JPEG2000      // 👈 [18番目] imageDataType (0: JPEG, 1: JPEG2000)
            );

            // 規格上、複数の顔を入れられる配列構造になっているためネストさせてDG2ファイルを構築
            FaceInfo faceInfo = new FaceInfo(java.util.Collections.singletonList(faceImageInfo));
            DG2File dg2File = DG2File.createISO19794DG2File(java.util.Collections.singletonList(faceInfo));

            // これが「正しいヘッダー群」でラッピングされた、最終的なDG2のバイナリデータ(先頭が0x75になる)
            byte[] imgBytes = dg2File.getEncoded();

            // カードへ書き込み
            createFile(channel, PassportService.EF_DG2, imgBytes.length);
            selectFile(channel, PassportService.EF_DG2);
            updateBinary(channel, imgBytes);
            System.out.println("DG2の書き込みが成功しました。");

            System.out.println("--- DG7 (署名) の書き込み ---");

            File signatureImgFile = new File("signature.jp2");
            byte[] signatureImgBytes = new byte[(int) signatureImgFile.length()];
            try (FileInputStream fis = new FileInputStream(signatureImgFile)) {
                fis.read(signatureImgBytes);
            }

            DisplayedImageInfo dImageInfo = new DisplayedImageInfo(ImageInfo.TYPE_SIGNATURE_OR_MARK, signatureImgBytes);
            DG7File dg7File = new DG7File(Collections.singletonList(dImageInfo));

            byte[] signBytes = dg7File.getEncoded();

            createFile(channel, PassportService.EF_DG7, signBytes.length);
            selectFile(channel, PassportService.EF_DG7);
            updateBinary(channel, signBytes);

            System.out.println("DG7の書き込みが成功しました。");

            System.out.println("--- DG15 (AA公開鍵) の書き込み ---");
            // JMRTDライブラリを使って公開鍵をICAO規格にパッキング
            DG15File dg15File = new DG15File(aaPublicKey);
            byte[] dg15Bytes = dg15File.getEncoded();

            createFile(channel, PassportService.EF_DG15, dg15Bytes.length);
            selectFile(channel, PassportService.EF_DG15);
            updateBinary(channel, dg15Bytes);
            System.out.println("DG15の書き込みが成功しました。");

            System.out.println("--- EF.COM の書き込み ---");

            // ICAO LDSバージョン(0107) と Unicodeバージョン(040000), 存在するDG(1と2)を指定
            COMFile comFile = new COMFile("1.7", "4.0.0", new int[]{1, 2, 7, 15});
            byte[] comBytes = comFile.getEncoded();

            createFile(channel, PassportService.EF_COM, comBytes.length);
            selectFile(channel, PassportService.EF_COM);
            updateBinary(channel, comBytes);

            System.out.println("EF.COMの書き込みが成功しました。");

            // --- EF.SOD (セキュリティオブジェクト) の生成と書き込み ---
            System.out.println("--- EF.SOD の書き込み ---");

            // 1. 各DGのハッシュ値を計算
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            Map<Integer, byte[]> dgHashes = new HashMap<>();
            dgHashes.put(1, digest.digest(dg1Bytes)); // 事前に生成したdg1Bytes
            dgHashes.put(2, digest.digest(imgBytes)); // 事前に読み込んだimgBytes
            dgHashes.put(7, digest.digest(signBytes));
            dgHashes.put(15, digest.digest(dg15Bytes));

            // 2. 署名鍵と証明書を用意する
            PrivateKey docPrivateKey = getDSPrivKey();
            X509Certificate docCert = loadCertificate();

            // 3. SODファイルの生成 (SHA-256 with RSA/ECDSA などの署名アルゴリズムを指定)
            String digestAlgorithm = "SHA256";
            String signatureAlgorithm = "SHA256withRSA";

            SODFile sodFile = new SODFile(
                    digestAlgorithm,
                    signatureAlgorithm,
                    dgHashes,
                    docPrivateKey,
                    docCert
            );
            byte[] sodBytes = sodFile.getEncoded();

            // 4. 書き込み
            createFile(channel, PassportService.EF_SOD, sodBytes.length);
            selectFile(channel, PassportService.EF_SOD);
            updateBinary(channel, sodBytes);

            System.out.println("EF.SODの書き込みが成功しました。");

            System.out.println("--- BAC鍵の登録 ---");
            putMRZData(channel, "TK1234567", "010101", "350101"); // MRZと一致させる必要はないが、暗号通信のために使用される情報

            System.out.println("--- AA秘密鍵の登録 ---");
            putAAPrivateKey(channel, aaPrivateKey);

            // 終了処理
            card.disconnect(false);
            System.out.println("すべてのパーソナライズ処理が完了しました。");

        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * ISO 7816-4 SELECT FILE (特定のEFを選択)
     */
    private static void selectFile(CardChannel channel, short fid) throws Exception {
        byte[] fidBytes = { (byte) (fid >> 8), (byte) (fid & 0xFF) };
        CommandAPDU selectFileApdu = new CommandAPDU(0x00, 0xA4, 0x02, 0x0C, fidBytes);
        ResponseAPDU response = channel.transmit(selectFileApdu);

        if (response.getSW() != 0x9000) {
            throw new Exception("SELECT FILE 失敗. FID: " + Integer.toHexString(fid) + ", SW: " + Integer.toHexString(response.getSW()));
        }
    }

    /**
     * JMRTDアプレット固有の CREATE FILE コマンド (FIDをP1/P2に指定する版)
     */
    private static void createFile(CardChannel channel, short fid, int dataLength) throws Exception {
        byte[] payload = new byte[6];
        payload[0] = 0x63;                                    // タグ
        payload[1] = 0x04;                                    // 続くデータ長
        payload[2] = (byte) ((dataLength >> 8) & 0xFF);       // サイズ上位
        payload[3] = (byte) (dataLength & 0xFF);              // サイズ下位
        payload[4] = (byte) ((fid >> 8) & 0xFF);              // FID上位
        payload[5] = (byte) (fid & 0xFF);                     // FID下位

        CommandAPDU createFileApdu = new CommandAPDU(0x00, 0xE0, 0x00, 0x00, payload);
        ResponseAPDU response = channel.transmit(createFileApdu);

        if (response.getSW() != 0x9000) {
            if (response.getSW() == 0x6A89) {
                System.out.println("FID: " + Integer.toHexString(fid) + " は既に存在しています。");
                return;
            }
            throw new Exception("CREATE FILE 失敗. FID: " + Integer.toHexString(fid)
                    + ", SW: " + Integer.toHexString(response.getSW()));
        }
    }

    /**
     * ISO 7816-4 UPDATE BINARY (データを分割して書き込む)
     */
    private static void updateBinary(CardChannel channel, byte[] data) throws Exception {
        int offset = 0;
        // J3R452等のカードで一度に送信できる安全なバッファサイズ(JMRTDでは DEFAULT_MAX_BLOCKSIZE = 223 等)
        int maxBlockSize = 223;

        while (offset < data.length) {
            int length = Math.min(maxBlockSize, data.length - offset);
            byte[] chunk = new byte[length];
            System.arraycopy(data, offset, chunk, 0, length);

            // P1 = オフセットの上位バイト, P2 = オフセットの下位バイト
            // ※ オフセットが32767(0x7FFF)を超える大きなファイル(高画質のDG2など)の場合は、
            // 規格に沿って奇数INSによる拡張コマンドや別ロジックが必要になりますが、標準的なサイズならこれで動作します。
            int p1 = (offset >> 8) & 0xFF;
            int p2 = offset & 0xFF;

            CommandAPDU updateBinaryApdu = new CommandAPDU(0x00, 0xD6, p1, p2, chunk);
            ResponseAPDU response = channel.transmit(updateBinaryApdu);

            if (response.getSW() != 0x9000) {
                throw new Exception("UPDATE BINARY 失敗. Offset: " + offset + ", SW: " + Integer.toHexString(response.getSW()));
            }
            offset += length;
        }
    }

    /**
     * PUT DATA (INS=0xDA, P2=0x62) でMRZ由来のBAC鍵をカードに登録する
     * applet内部でkeySeedを導出しKenc/KmacをKeyStoreにセットする
     */
    private static void putMRZData(CardChannel channel,
                                   String docNumber, String dob, String doe) throws Exception {

        byte[] docNrBytes = docNumber.getBytes("ASCII"); // 9バイト
        byte[] dobBytes   = dob.getBytes("ASCII");       // 6バイト
        byte[] doeBytes   = doe.getBytes("ASCII");       // 6バイト

        // 内部TLVの合計長: (tag+len)*3 + 各値の長さ
        int innerLen = 2 + docNrBytes.length
                + 2 + dobBytes.length
                + 2 + doeBytes.length; // = 27

        byte[] data = new byte[2 + innerLen];
        int off = 0;

        // 外部TLV
        data[off++] = 0x62;           // 外部タグ(任意、ここではMRZ_TAGと同じ値)
        data[off++] = (byte) innerLen;

        // 文書番号
        data[off++] = 0x04;
        data[off++] = (byte) docNrBytes.length;
        System.arraycopy(docNrBytes, 0, data, off, docNrBytes.length);
        off += docNrBytes.length;

        // 生年月日
        data[off++] = 0x04;
        data[off++] = (byte) dobBytes.length;
        System.arraycopy(dobBytes, 0, data, off, dobBytes.length);
        off += dobBytes.length;

        // 有効期限
        data[off++] = 0x04;
        data[off++] = (byte) doeBytes.length;
        System.arraycopy(doeBytes, 0, data, off, doeBytes.length);

        CommandAPDU putData = new CommandAPDU(0x00, 0xDA, 0x00, 0x62, data);
        ResponseAPDU response = channel.transmit(putData);

        if (response.getSW() != 0x9000) {
            throw new Exception("PUT DATA (MRZ) 失敗: "
                    + Integer.toHexString(response.getSW()));
        }
        System.out.println("BAC鍵の登録が完了しました。");
    }

    private static PrivateKey getDSPrivKey() throws NoSuchAlgorithmException, InvalidKeySpecException, IOException {
        String privatePem = new String(Files.readAllBytes(new File("/path/to/CertDS_key.pem").toPath()))
                .replaceAll("\\r\\n", "")
                .replaceAll("\\n", "")
                .replace("-----BEGIN PRIVATE KEY-----", "")
                .replace("-----END PRIVATE KEY-----", "");
        PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(Base64.getDecoder().decode(privatePem));
        PrivateKey privateKey = KeyFactory.getInstance("RSA").generatePrivate(keySpec);
        return privateKey;
    }

    private static X509Certificate loadCertificate() throws IOException, CertificateException {
        try (FileInputStream fis = new FileInputStream("/path/to/CertDS_crt.pem")) {
            CertificateFactory factory = CertificateFactory.getInstance("X.509");
            return (X509Certificate) factory.generateCertificate(fis);
        }
    }

    /**
     * JMRTDアプレット内部の安全な領域にActive Authentication用の秘密鍵(RSA)を登録する
     */
    private static void putAAPrivateKey(CardChannel channel, PrivateKey aaPrivateKey) throws Exception {
        if (!(aaPrivateKey instanceof java.security.interfaces.RSAPrivateKey)) {
            throw new IllegalArgumentException("RSA秘密鍵のみサポートしています。");
        }
        java.security.interfaces.RSAPrivateKey rsaPriv =
                (java.security.interfaces.RSAPrivateKey) aaPrivateKey;

        byte[] modulus = toUnsignedByteArray(rsaPriv.getModulus());         // 128バイト
        byte[] exponent = toUnsignedByteArray(rsaPriv.getPrivateExponent()); // 128バイト

        // モジュラスを送信 (P2=0x60)
        putKeyComponent(channel, (byte) 0x60, modulus);
        System.out.println("モジュラスの登録が完了しました。");

        // 指数を送信 (P2=0x61)
        putKeyComponent(channel, (byte) 0x61, exponent);
        System.out.println("指数の登録が完了しました。");
    }

    /**
     * BER-TLV形式: [tag][0x00][0x04][len][data]
     * 128バイトを超える場合は長さフィールドを 0x81 0xXX の2バイト形式にする
     */
    private static void putKeyComponent(CardChannel channel, byte p2, byte[] data) throws Exception {
        boolean longLen = data.length > 127;
        int lenBytes = longLen ? 2 : 1;
        byte[] payload = new byte[2 + 1 + lenBytes + data.length];
        int off = 0;

        payload[off++] = p2;   // 外部タグ (0x60 or 0x61)
        payload[off++] = 0x00; // 外部長 = 0(空値)
        payload[off++] = 0x04; // 内部タグ
        if (longLen) {
            payload[off++] = (byte) 0x81;
            payload[off++] = (byte) data.length;
        } else {
            payload[off++] = (byte) data.length;
        }
        System.arraycopy(data, 0, payload, off, data.length);

        CommandAPDU apdu = new CommandAPDU(0x00, 0xDA, 0x00, p2, payload);
        ResponseAPDU response = channel.transmit(apdu);

        if (response.getSW() != 0x9000) {
            throw new Exception("PUT DATA 失敗 (P2=" + Integer.toHexString(p2 & 0xFF)
                    + "): " + Integer.toHexString(response.getSW()));
        }
    }

    /**
     * BigInteger → 符号なしバイト配列(先頭0x00を除去)
     */
    private static byte[] toUnsignedByteArray(java.math.BigInteger n) {
        byte[] b = n.toByteArray();
        if (b[0] == 0x00) {
            byte[] tmp = new byte[b.length - 1];
            System.arraycopy(b, 1, tmp, 0, tmp.length);
            return tmp;
        }
        return b;
    }
}

コメント

このブログの人気の投稿