この記事は、PaSoRi RC-S380/Sを使ってFeliCa Type 3相当のカード応答をローカルで再現し、JSONまたはTXTのダンプから読み取り応答を返せるようにした作業記録です。最初の症状は、IDmだけは読めるのに、System Code、Service Code、Block Numberを指定した読み取りに進むと、リーダー側に何も返らない、あるいはノード情報が読めない、というものでした。

結論から申し上げると、単に「nfcpyが使いづらい」という話ではありませんでした。nfcpyの便利なType 3 Tagエミュレーション層で吸収される範囲を越えて、RC-S380/Sのtarget modeで返すフレームの長さ、FeliCaコマンドのレスポンス形式、Service Codeのリトルエンディアン、Block List Elementの解釈、Request ServiceのKey Version応答まで、一つひとつ合わせていく必要がありました。

また、作業の最後にはWeb UIも構築しています。ブラウザからダンプを選び、エミュレーションを開始・停止し、ログを確認し、HEXをASCIIやShift-JISのカタカナとして読み、ブロックを書き換えてedited_*.jsonとして保存できます。元ダンプは直接上書きしない構成です。

注意:本文中のIDm、PMm、System Code、Service Code、Block値、学生番号、氏名、パス、ログはすべて説明用に置換しています。実カード固有の情報、本人情報、所属先情報、認証が必要な領域の秘密情報は一切掲載していません。この記事は、本人所有または許可済みの検証環境において、非暗号化読み取り可能な領域やテスト用データを扱うための技術メモです。アクセス制御の回避や、第三者の資格情報の複製を目的としないでください。

この記事で扱う範囲

扱うのは、FeliCaのうちリーダーから見た基本的なNFC-F/Type 3 Tagの応答です。具体的には、PollingでIDmとPMmを返す段階、Request Serviceでノードの存在とKey Versionを返す段階、Read Without EncryptionでService Code ListとBlock Listに対応した16バイト単位のBlock Dataを返す段階を扱います。

一方、暗号化されたサービス、相互認証、鍵の推測、セキュア領域の復号、実運用システムへのなりすましは対象外です。読み取れない領域を無理に読ませるための記事ではなく、読み取り可能なダンプを仕様に合うフレームとして正しく返せるようにするための技術記録です。

対象:
  - PaSoRi RC-S380/S
  - nfcpy
  - JSON/TXT形式のFeliCaダンプ
  - Polling / Request Service / Request System Code
  - Search Service Code
  - Read Without Encryption
  - 16バイトBlockの編集Web UI

対象外:
  - 実カード固有値の公開
  - 認証鍵や暗号化サービスの突破
  - 第三者カードの複製
  - 入退室・決済・学籍などの実システムへの投入

先行資料で分かること

まず公式資料を確認しました。SonyのRC-S380/S製品ページでは、この機種がUSB接続のNFC対応リーダーであり、FeliCa、Type A、Type Bカードを扱えること、PC/SC 2.0に対応することが説明されています。nfcpyのGetting Startedにも、nfcpyでカードエミュレーションが可能である一方、対応は一部デバイスに限られ、RC-S380はType 3 Tagエミュレーションをサポートすると明記されています。

nfcpyのContactlessFrontend.listen()は、通常のconnect()より低い層のAPIです。ドキュメントでは、より細かい制御が必要な専門家向けとされており、Type F Targetではsensf_resを用意して待ち受け、Polling後の最初のType 3コマンドがtt3_cmdとして返ると説明されています。

SonyのFeliCa Lite-S User's Manualでは、BlockへアクセスするにはService Code ListとBlock Listを指定すること、Block List Elementには2バイト形式と3バイト形式があること、Read Without Encryptionの応答にはStatus FlagとBlock数、Block Dataが並ぶことが確認できます。ここが、IDmだけ読める状態から先へ進むための土台となりました。

ただし、これらの資料は、手元のJSON/TXTダンプをWeb UIでどう編集するか、RC-S380/Sのtarget modeでlisten()exchange()のフレーム境界をどう扱うか、ノードが読めないときにどこから切り分けるか、といった実作業の手順までは書いてくれません。本記事の主眼はまさにそこにあります。

症状:IDmは読めるのにノードとブロックが読めない

最初に見えていた現象は分かりやすいものでした。外部リーダーがPollingを行うと、エミュレートしたIDmは読めます。つまり、カードとしてフィールドに出て、最低限のSENSF_RES相当の応答は返せている状態です。ところが、System Code、Service Code、Block Numberを指定して読もうとすると、何も返りません。

この状態で疑うべきポイントは、IDmそのものではありません。IDmが読めているのであれば、少なくともPolling応答までは成立しています。次に疑うべきなのは、Request Service、Request System Code、Search Service Code、Read Without Encryptionなど、Pollingの後に飛んでくるType 3コマンドへの応答部分です。

観測した状態の抽象化:

  1. 外部リーダーがPollingする
  2. エミュレータがIDm/PMm/System Codeを返す
  3. 外部リーダーはIDmを表示できる
  4. その後、Service CodeやBlock指定の読み取りに進む
  5. ここで応答が崩れる、または無応答になる

この場合、Pollingだけを見て「カード化できている」と判断すると深みにはまります。
Polling成功とRead Without Encryption成功は、別の段階です。

落とし穴1:listen()で受けたtt3_cmdとexchange()へ返すフレームの長さ

最初の大きな落とし穴はフレーム長でした。nfcpyのlisten.pyサンプルを見ると、Type 3 Tagとして受けたtarget.tt3_cmdを見て、応答をclf.exchange()へ返しています。この応答は、先頭に長さバイト(LEN)を付けた形で組み立てられています。

今回の実装では、受信側では長さバイトが付いている場合と付いていない場合の両方を吸収し、送信側では必ずFeliCaフレームとして長さバイトを付けるようにしました。ここがずれていると、コマンド処理自体は正しくても、リーダー側からは何も返っていないように見えてしまいます。

KNOWN_COMMANDS = {
    0x00,  # Polling
    0x02,  # Request Service
    0x04,  # Request Response
    0x06,  # Read Without Encryption
    0x08,  # Write Without Encryption
    0x0A,  # Search Service Code
    0x0C,  # Request System Code
}


def strip_len_byte(frame: bytes) -> bytes:
    """nfcpyから来るデータがLEN付きなら外し、LENなしならそのまま扱います。"""
    if len(frame) >= 2 and frame[0] == len(frame) and frame[1] in KNOWN_COMMANDS:
        return frame[1:]
    return frame


def add_len_byte(payload: bytes) -> bytes:
    """clf.exchange()へ返す応答は、FeliCaフレームとしてLENを先頭に付けます。"""
    frame_length = len(payload) + 1
    if frame_length > 255:
        raise ValueError(f"FeliCa frame too large: {frame_length} bytes")
    return bytes([frame_length]) + payload

この2つの関数だけを見ると地味に映りますが、今回の「IDmは読めるのに、その後が全部だめ」という症状においては、極めて重要な役割を果たしています。Polling後の最初のコマンドはtt3_cmdとして得られ、それ以降のやり取りはexchange()で行われます。どちらの境界で長さバイトが存在するのかを曖昧にしてしまうと、応答がカードコマンドとして解釈されなくなります。

落とし穴2:Request ServiceはService Codeを返すのではなくKey Versionを返す

次に重要だったのがRequest Serviceの扱いです。名前だけを見ると、つい「要求されたService Codeをそのまま返せばよい」と思いがちですが、実際にはそうではありません。Request Serviceは、指定されたAreaまたはServiceが存在するかどうかを確認し、存在する場合はKey Versionを返すコマンドです。存在しない場合はFFFFhを返します。

ここでService Codeそのものを返してしまうと、リーダー側のノード確認が失敗します。外部からは「ノードが読めない」「Service Codeを指定しても先へ進まない」という見え方になってしまいます。

def request_service(command: bytes, idm: bytes, find_key_version) -> bytes | None:
    if len(command) < 10 or command[1:9] != idm:
        return None

    node_count = command[9]
    offset = 10
    response = bytearray([0x03])  # Request Service Response
    response.extend(idm)
    response.append(node_count)

    for _ in range(node_count):
        if offset + 2 > len(command):
            response.extend(b"\xFF\xFF")
            continue

        # Node Codeはリトルエンディアンです。
        node_code = int.from_bytes(command[offset:offset + 2], "little")
        offset += 2

        key_version = find_key_version(node_code)
        if key_version is None:
            response.extend(b"\xFF\xFF")
        else:
            response.extend(int(key_version).to_bytes(2, "little"))

    return bytes(response)

記事用のサンプルではfind_key_version()という抽象関数にしています。実際の実装では、JSON/TXTから読み込んだSystem/Service構造を参照し、該当ノードが存在するならKey Versionを返します。Key Versionがダンプに含まれていない場合は、検証用の既定値を入れるか、明示的にFFFFhを返して「存在しない」と応答します。

落とし穴3:Service Codeはリトルエンディアンで飛んでくる

FeliCaのService Codeは、人間がログで確認する際には100Bのような4桁HEXで表記しがちです。しかし、Read Without Encryptionのコマンドパケット内では、Service Code Listはリトルエンディアンで格納されています。つまり、ログ上の表示とパケット上のバイト順が逆になります。

表示上のService Code:
  100B

パケット上のService Code List:
  0B 10

Pythonで読む:
  service_code = struct.unpack_from("<H", command, offset)[0]

これをビッグエンディアンとして読んでしまうと、存在しないServiceを探しに行くことになり、Blockが見つかりません。外部リーダーの指定は正しいのに、エミュレータの内部では別のService Codeとして解釈していた、という状態に陥ります。

落とし穴4:Block List Elementの下位4ビットはService Code List Order

Read Without EncryptionでさらにハマりやすいのがBlock Listです。Block List Elementの先頭バイトは、単純なBlock番号ではありません。2バイト形式の場合、先頭バイトの最上位ビットは長さ、下位4ビットはService Code Listの何番目を参照するかを表します。Block番号は次のバイトです。

例えば80 05なら、2バイトBlock List Elementで、Service Code Listの0番目、Block 5を読む、という意味になります。複数サービスを一つのRead Without Encryptionで読む場合は、ここを正しく見ないと、別サービスのブロックとして解釈してしまいます。

def parse_block_list(data: bytes, offset: int, count: int) -> tuple[list[tuple[int, int]], int]:
    blocks: list[tuple[int, int]] = []

    for _ in range(count):
        if offset >= len(data):
            raise ValueError("truncated block list")

        first = data[offset]
        service_index = first & 0x0F

        if first & 0x80:
            # 2-byte Block List Element
            if offset + 2 > len(data):
                raise ValueError("truncated 2-byte block list element")
            block_number = data[offset + 1]
            offset += 2
        else:
            # 3-byte Block List Element
            if offset + 3 > len(data):
                raise ValueError("truncated 3-byte block list element")
            block_number = int.from_bytes(data[offset + 1:offset + 3], "little")
            offset += 3

        blocks.append((service_index, block_number))

    return blocks, offset

今回の修正では、Block Listを(service_index, block_number)の配列として扱いました。先にService Code Listを読み、Block List側のservice_indexで参照するという形にすると、仕様とコードの対応が見えやすくなります。

Read Without Encryptionの応答を組み立てる

ここまでそろうと、Read Without Encryptionの本体が書けます。手順は、IDm確認、Service Code List読み取り、Block Count読み取り、Block List解析、各Blockの存在確認、16バイト単位のBlock Data連結、Status Flag付き応答の生成です。

def read_without_encryption(command: bytes, idm: bytes, card) -> bytes | None:
    if len(command) < 12 or command[1:9] != idm:
        return None

    service_count = command[9]
    offset = 10
    service_codes: list[int] = []

    for _ in range(service_count):
        if offset + 2 > len(command):
            return bytes([0x07]) + idm + b"\xFF\xA1"
        service_codes.append(int.from_bytes(command[offset:offset + 2], "little"))
        offset += 2

    if offset >= len(command):
        return bytes([0x07]) + idm + b"\xFF\xA2"

    block_count = command[offset]
    offset += 1
    block_list, _ = parse_block_list(command, offset, block_count)

    block_data = bytearray()
    for read_index, (service_index, block_number) in enumerate(block_list):
        if service_index >= len(service_codes):
            return bytes([0x07]) + idm + bytes([1 << (read_index % 8), 0xA3])

        service_code = service_codes[service_index]
        block = card.get_block(service_code, block_number)
        if block is None:
            # 説明用の簡略エラー。実装ではログにService/Blockの詳細を出します。
            return bytes([0x07]) + idm + bytes([1 << (read_index % 8), 0xA2])

        block_data.extend(block)

    response = bytearray([0x07])  # Read Without Encryption Response
    response.extend(idm)
    response.extend(b"\x00\x00")  # Status Flag1/2 OK
    response.append(block_count)
    response.extend(block_data)
    return bytes(response)

実装においては、エラーを無言で握りつぶさないことも重要です。何も返さなければ、外部リーダー側では原因の切り分けができません。「Serviceがロードされていない」「Block番号が存在しない」「Service IndexがService Code Listの範囲外」といったケースは、すべてログに出力するようにしています。

ダンプ形式:JSONとTXTを両方拾えるようにする

手元にはJSON形式のダンプと、テキスト形式のダンプが混在していました。そのため、Web UIやバッチから起動するときに、ファイル形式を意識しすぎなくて済むようにしました。JSONは構造化データとして読み、TXTはHEXらしき行からService/Blockを復元できる範囲で拾います。

記事では、個人情報を含まないダミーJSONだけを例にします。実際のカードから得たIDmや氏名、学籍番号などは書きません。

{
  "header": {
    "idm": "0123456789ABCDEF",
    "pmm": "FFFFFFFFFFFFFFFF",
    "timestamp": "2026-05-26T12:00:00",
    "note": "sample data; personal fields replaced"
  },
  "results": [
    {
      "system_code": "FE00",
      "services": [
        {
          "service_code": "100B",
          "blocks": [
            {
              "index": 0,
              "content": {
                "hex": "53414D504C452D303030310000000000",
                "ascii": "SAMPLE-0001"
              }
            },
            {
              "index": 1,
              "content": {
                "hex": "B6C0B6C520B6C4B30000000000000000",
                "shift_jis": "カタカナ テスト"
              }
            }
          ]
        }
      ]
    }
  ]
}

この例では、IDmを0123456789ABCDEFに置換し、System Codeも説明用のFE00にしています。Service Codeも記事用の100Bです。実カードのノード構造そのものを公開する必要はありません。

エミュレーション起動の全体像

エミュレーションは、カードデータを読み、System Codeを決め、LocalTargetを作り、sensf_resを設定し、clf.listen()で外部リーダーからの起動を待ちます。最初のType 3コマンドが来たら処理し、応答をclf.exchange()へ返します。

import nfc


def run_emulation(card, system_code: int, listen_timeout: float = 5.0) -> int:
    handler = FelicaHandler(card, system_code, debug=True)

    with nfc.ContactlessFrontend("usb") as clf:
        target = nfc.clf.LocalTarget("212F")
        target.sensf_res = handler.sensf_res()

        print("waiting for external reader...")
        activated = clf.listen(target, listen_timeout)
        if not activated:
            print("timeout: no external reader activation")
            return 1

        if activated.tt3_cmd:
            response = handler.process_frame(activated.tt3_cmd)
            if response:
                clf.exchange(response, timeout=1)

        return 0

実際の実装では、--onceで1回だけ待つモード、--listen-timeout--brty 212F/424F--system-code--self-testなどを用意しました。外部リーダーを待つ処理はハードウェアの状態に依存するため、まず自己テストでコマンド処理だけ通るかを確認できるようにしています。

バッチファイルで起動しやすくする

Windowsで毎回Python環境を意識するのは面倒です。そこで、通常のPythonでnfcをimportできるかを確認し、できなければ既知のPython環境にフォールバックするバッチを用意しました。これは記事用にパスを置換した短縮版です。

@echo off
setlocal
cd /d "%~dp0"

set "PY=python"

%PY% -c "import nfc" >nul 2>nul
if errorlevel 1 (
  if exist "%USERPROFILE%\miniconda3\envs\nfc-lab\python.exe" (
    set "PY=%USERPROFILE%\miniconda3\envs\nfc-lab\python.exe"
  )
)

%PY% emulate.py dump_sample.json --system-code FE00 --debug
pause

Web UI用にも同じ考え方のwebui.batを用意しました。ダブルクリックでローカルサーバーを立て、ブラウザから操作する入口にしています。

Web UI:ログを見る、ブロックを見る、HEXを文字として読む

エミュレータ本体が動いた後に欲しくなるのは、起動しやすさと観察しやすさです。コマンドラインのログだけでは、どのServiceにどのBlockが入っているか、HEXが何の文字に見えるか、どのダンプを使っているかが分かりづらくなります。

そこで、標準ライブラリだけの小さなWeb UIを作りました。ダンプ候補の一覧、選択中カードのIDm/PMm、System数、Service数、Block数、各BlockのHEX、ASCIIプレビュー、Shift-JISプレビュー、ログビュー、Start/Stop/Self-testボタンを持ちます。

Web UIでできること:

  - JSON/TXTダンプの選択
  - System Code / bitrate / timeout / debugの指定
  - エミュレータのStart/Stop
  - self-testの実行
  - stdout/stderrログのリアルタイム表示
  - Service別Block一覧の表示
  - HEXのASCIIプレビュー
  - HEXのShift-JISプレビュー
  - テキストをShift-JISまたはASCIIで16バイトBlockへ変換
  - Block編集結果をedited_*.jsonとして保存

ASCIIとShift-JISプレビュー

学生証や業務用カードの検証では、Shift-JIS由来の半角カタカナや日本語が混ざることがあります。ただし、文字化けしたまま直接記事やログに載せると、個人名や所属情報をうっかり出してしまう危険があります。Web UIでは、作業者がローカルで確認するためのプレビューとしてASCIIとShift-JISを並べ、記事や共有ログでは必ず置換する運用にしました。

function bytesFromHex(value) {
  const clean = value.replace(/[^0-9a-f]/gi, "").toUpperCase();
  if (clean.length % 2) {
    throw new Error("HEX needs full bytes.");
  }
  if (clean.length > 32) {
    throw new Error("A block holds 16 bytes.");
  }

  const bytes = [];
  for (let offset = 0; offset < clean.length; offset += 2) {
    bytes.push(Number.parseInt(clean.slice(offset, offset + 2), 16));
  }
  while (bytes.length < 16) {
    bytes.push(0);
  }
  return Uint8Array.from(bytes);
}


function asciiFromBytes(bytes) {
  return Array.from(bytes, (value) =>
    value >= 0x20 && value < 0x7f ? String.fromCharCode(value) : "."
  ).join("");
}


function shiftJisFromBytes(bytes) {
  const text = new TextDecoder("shift-jis").decode(bytes).replace(/\0+$/g, "");
  return Array.from(text, (char) => {
    const value = char.charCodeAt(0);
    if (char === "\0") return "\\x00";
    if (value < 0x20 || value === 0x7f) {
      return `\\x${value.toString(16).toUpperCase().padStart(2, "0")}`;
    }
    return char;
  }).join("");
}

ブラウザ側のTextDecoder('shift-jis')で見た目を確認し、テキストからHEXへ変換するときはサーバー側でPythonのshift_jisエンコードを使いました。ブラウザの入力とPythonの保存結果がずれないよう、最終的なBlock化はサーバー側で行います。

Block編集は元ダンプを直接上書きしない

Web UIから送るデータを直せるようにすると、元ダンプを壊すリスクが出ます。そこで、編集結果は必ずedited_元ファイル名.jsonへ保存します。さらに、同じ編集済みファイルが既に存在する場合は、保存前に.bak_日時へリネーム退避します。

def edited_dump_path(source: Path) -> Path:
    if source.name.lower().startswith("edited_"):
        return source
    return ROOT / f"edited_{source.stem}.json"


def write_json_with_backup(path: Path, payload: dict) -> None:
    if path.exists():
        stamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
        path.rename(path.with_name(f"{path.name}.bak_{stamp}"))

    with path.open("w", encoding="utf-8", newline="\n") as fh:
        json.dump(payload, fh, indent=2, ensure_ascii=False)
        fh.write("\n")

この方式なら、Web UIで試行錯誤しても元データに戻れます。カードから吸い出した原本、編集済み作業コピー、さらに編集済みファイルのバックアップが分かれるため、検証の巻き戻しがしやすくなります。

編集APIの考え方

編集APIでは、System Code、Service Code、Block Number、HEXを受け取り、16バイトに正規化して保存します。HEXが奇数桁、16バイト超過、System/Serviceの形式ミスの場合はエラーにします。

def edit_block(body: dict) -> dict:
    source = resolve_dump_path(body.get("dump", ""))
    system_code = parse_u16_field(body, "system_code", "System code")
    service_code = parse_u16_field(body, "service_code", "Service code")
    block_number = int(body.get("block_number", ""))

    if not 0 <= block_number <= 0xFFFF:
        raise ApiError(400, "Block number must be between 0 and 65535.")

    data = block_data_from_hex(body.get("hex", ""))
    card = load_dump(source)
    card.add_service_block(system_code, service_code, block_number, data)

    destination = edited_dump_path(source)
    write_json_with_backup(destination, card_to_json(card, source))

    return {
        "dump": destination.name,
        "block": {
            "number": block_number,
            "hex": data.hex().upper(),
            "ascii": ascii_preview(data),
            "shift_jis": text_preview(data, "shift_jis"),
        },
    }

実装では、保存後に再度カードサマリを作り直し、画面側のBlock一覧も更新します。これにより、保存したはずなのに表示が古い、というWeb UIでありがちな混乱を減らしました。

self-testを必ず持つ

NFC機器を使う実装では、毎回ハードウェアと外部リーダーを用意しないと検証できない構成にすると、修正速度が落ちます。そこで、エミュレータ本体には--self-testを付けました。self-testでは、ロードしたカードデータから読み取り可能なService/Blockを選び、Read Without Encryptionのコマンドを内部生成して、応答が期待どおり組み立てられるかを確認します。

PS D:\Lab\nfc> python emulate.py dump_sample.json --self-test
[self-test] using system 0xFE00
[self-test] using service 0x100B block 0
  <- Read Without Encryption services=[0x100B] blocks=[(0, 0)]
  -> Read OK 16 data bytes
[self-test] OK

このログも記事用に置換しています。実際のIDmやService Codeを出す必要はありません。self-testが通れば、少なくともダンプ読み込み、Service Codeのエンディアン、Block List解析、応答フレーム生成までは確認できます。

実機確認:PaSoRiが開けることと、外部リーダー応答は分ける

実機確認では、PaSoRiが開けることと、外部リーダーが実際にカードとして読み切ることを分けて見ます。前者はPC側のドライバ、USB、nfcpy、デバイスパスの確認です。後者は、外部リーダーのタイミング、bitrate、System Code、読み取りコマンドの違いまで含みます。

サニタイズ済みの実行ログ例:

  device: SONY RC-S380/S NFC Port-100 v1.11 at usb:001:023
  mode:   212F
  IDm:    0123456789ABCDEF
  PMm:    FFFFFFFFFFFFFFFF
  SYS:    FE00

  waiting for external reader...
  <- Polling system=0xFFFF request=1
  <- Request System Code -> 0xFE00
  <- Request Service nodes=1
     node 0x100B: keyver=0x0000
  <- Read Without Encryption services=[0x100B] blocks=[(0, 0)]
  -> Read OK 16 data bytes

このように、Polling、Request System Code、Request Service、Read Without Encryptionが順番に見えると、IDmだけで止まっていた状態から抜けられたと判断しやすくなります。逆に、Pollingしか出ない場合は、外部リーダーがそのSystem Codeを対象にしていない、Request Serviceで落ちている、長さバイトが合っていない、などを順番に疑います。

よくある切り分け

1. IDmだけ見える

Polling応答は成立しています。sensf_resのIDm/PMm/System Codeまでは届いています。次はtarget.tt3_cmdに何が来ているかをログに出します。Request System Codeなのか、Read Without Encryptionなのか、外部リーダーが何も送っていないのかを分けます。

2. Request Serviceで止まる

Node Codeのリトルエンディアンと、返す値がKey Versionであることを見ます。存在しないノードにはFFFFhを返します。Service Codeをそのまま返していないか確認します。

3. Read Without Encryptionが返らない

Service Code Listの読み取り順、Block Count、Block List Elementの2バイト/3バイト判定、Service Code List Order、Block番号を確認します。特にfirst & 0x0Fを見ずにBlock番号として扱っていると、複数サービスや一部リーダーで破綻します。

4. Web UIで編集したのに反映されない

edited_*.jsonを選び直しているか、古いWeb UIサーバーが残っていないかを確認します。Windowsで同じポートの古いプロセスが残っていると、ファイルを直してもブラウザには古いAPIが返ることがあります。

PS> netstat -ano | findstr :8765
  TCP    127.0.0.1:8765    0.0.0.0:0    LISTENING    12345

PS> Stop-Process -Id 12345
PS> .\webui.bat

5. カタカナが変に見える

Shift-JISとして解釈すべきバイト列をUTF-8として見ている、または16バイトBlock末尾のゼロ埋めを文字として見ている可能性があります。Web UIではASCIIとShift-JISを並べ、末尾の00はプレビュー時に落としています。

セキュリティと公開時の置換ルール

NFC/FeliCaの記事では、技術的な再現性と公開時の安全性のバランスが大切です。今回のように学生証や社員証のような形をしたカードを扱う場合、IDmだけでも個体識別子になり得ます。Service CodeやBlockの中身も、所属、番号、氏名、発行年、内部管理情報を含む場合があります。

そのため、公開記事では以下を徹底しました。

公開時の置換ルール:

  - IDmは 0123456789ABCDEF に置換
  - PMmは FFFFFFFFFFFFFFFF に置換
  - System Codeは説明用の FE00 に置換
  - Service Codeは説明用の 100B に置換
  - 学籍番号、氏名、所属、発行番号は SAMPLE-0001 などに置換
  - 実ファイル名にIDmが入る場合は dump_sample.json に置換
  - USBパスやユーザー名を含むローカルパスは D:\Lab\nfc に置換
  - 実カードで読めない領域を読めるように見せる表現は避ける

また、記事の焦点も「低レイヤーのフレーム処理を正しくする」「自分の検証データを編集しやすくする」に置き、実サービスのアクセス制御を突破するような話に寄せないようにしています。

先行資料のその先に足したもの

先行資料だけでも、RC-S380/SがNFC/FeliCaを扱えること、nfcpyでType 3 Tagエミュレーションができること、Read Without Encryptionの基本構造は分かります。しかし、現場で詰まるのは、その間です。

今回足したのは、まず、listen()で受けるコマンドとexchange()へ返す応答のフレーム境界を明示的に扱う層です。次に、Request ServiceをKey Version応答として正しく実装する層です。さらに、Block List ElementをService Code List Order込みで解析する層を足しました。

その上に、ダンプをJSON/TXTから読み、Web UIでBlockを見ながら編集し、Shift-JIS/ASCIIプレビューで内容を確認し、保存時は元ダンプではなくedited_*.jsonへ逃がし、ログを見ながらStart/Stopできる作業台を作りました。ここまで来ると、単発のサンプルコードではなく、失敗したときに原因へ戻れるツールになります。

最終構成

D:\Lab\nfc
  emulate.py
    - JSON/TXT dump loader
    - FeliCa command handler
    - LEN byte normalizer
    - Request Service / Request System Code
    - Search Service Code
    - Read Without Encryption
    - Write Without Encryption for in-memory test
    - self-test

  emulate.bat
    - Python environment bootstrap
    - dump auto selection

  webui.py
    - localhost Web API
    - emulator subprocess control
    - live log buffer
    - block edit endpoint
    - text-to-HEX endpoint

  webui_assets/
    index.html
    app.js
    styles.css

  webui.bat
    - Web UI launcher

  dump_sample.json
    - sanitized sample dump

  edited_dump_sample.json
    - Web UI edited working copy

記事用にはD:\Lab\nfcとしています。実際の作業パス、ユーザー名、IDm入りファイル名は公開しない方が安全です。

まとめ

IDmが読めるのにSystem Code、Service Code、Block指定で何も返らない場合、問題はIDmではなく、Pollingの次にあるType 3コマンド応答に潜んでいることが多いです。今回のRC-S380/S + nfcpy環境では、フレーム長、Request ServiceのKey Version、Service Codeのリトルエンディアン、Block List ElementのService Index、Read Without EncryptionのStatus FlagとBlock Dataの並びを直すことで、読み取り応答が成立しました。

さらにWeb UIを足したことで、ただ動くスクリプトから、ログを見て、送るデータを直し、HEXを文字として確認し、編集済みダンプを安全に保存できる道具になりました。先行資料の知識を読むだけでなく、実際に失敗する箇所へ戻れる形にした点が、今回の一番大きな成果です。

参考資料