10.11.2020

WebUSB APIでSuicaの履歴を読み取るメモ

Android & Kotlin で Suica の履歴を読み取るメモの続き。

動作環境
  • 読み取り側
    • カードリーダー
      • RC-S380
    • Mac
      • Chrome
    • Windows
      • Chrome
      • Edge
  • 読まれる方
    • Suica
      • カード媒体
      • iPhone
      • Apple Watch
    • PASMO
      • カード媒体

WebUSB 経由で Suica の履歴をどうしても取りたくてwebpasoriもあるんだけど、iPhone や Apple Watch では上手く取得できず。
自分で実装するにも PC/SC やらのラッパー部分が無いので、低レイヤーな PaSoRi を操る部分をコーディングする必要があるらしくさっぱりわからない。
WebUSB で FeliCa の一意な ID である IDm を読むの記事をふと見ていたときに、

// Level 9:nfc.clf.rcs380:GetFirmwareVersion
// Level 9:nfc.clf.transport:>>> 0000ffffff0200fed6200a00
// Level 9:nfc.clf.transport:<<< 0000ff00ff00
// Level 9:nfc.clf.transport:<<< 0000ffffff0400fcd7211101f600

のようにパケットレベルのログが出力できているので、ここからどうにかできないか模索。
nfcpy を使えばログを吐き出せるっぽい。

histories.py

import nfc
import logging
import binascii

logging.basicConfig()
logging.getLogger('nfc').setLevel(logging.DEBUG-1)
clf = nfc.ContactlessFrontend('usb')


def connected(tag):
    print(tag)

    if isinstance(tag, nfc.tag.tt3.Type3Tag):
        try:
            sc = nfc.tag.tt3.ServiceCode(0x090f >> 6, 0x090f & 0x3f)
            data = tag.read_without_encryption([sc], [
                nfc.tag.tt3.BlockCode(0),
                nfc.tag.tt3.BlockCode(1),
            ])
            print('result', binascii.hexlify(data))
        except Exception as e:
            print("error: %s" % e)
    else:
        print("error: tag isn't Type3Tag")


tag = clf.connect(rdwr={'targets': ['212F', '424F'], 'on-connect': connected})

Python は書いたことがないので、下記を参考に履歴取得のコーディング。
https://github.com/m2wasabi/nfcpy-suica-sample/blob/master/suica_read.py
iPhone や Apple Watch は Type4 タグという形式で取得されてしまって、履歴取得で必要な IDm が取れなくなってしまうので、targets212F 424F (FeliCa のデータ転送速度の仕様?)を渡す。

python3 histories.py と実行する。

logging.basicConfig()
logging.getLogger('nfc').setLevel(logging.DEBUG-1)

上記をセットしてあげることで、パケットのログが吐き出せた。 そのログを元に、JS/TS でやりとりする部分を書き起こす。

rc-s380.ts

const get2ByteUint8Array = ({ value, little_endian }: { value: number; little_endian?: boolean }) => {
  const ab = new ArrayBuffer(2)
  const v = new DataView(ab)
  v.setUint16(0, value, little_endian)
  return new Uint8Array(ab)
}

export async function connect() {
  try {
    const d = await navigator.usb.requestDevice({
      filters: [
        {
          vendorId: 0x054c, // Sony
          productId: 0x06c3, // RC-S380
        },
      ],
    })
    await d.open()
    await d.selectConfiguration(1)
    await d.claimInterface(0)
    return d
  } catch (e) {
    throw new Error(e)
  }
}

const checksum = ({ data }: { data: number[] }) => (256 - data.reduce((x, y) => x + y)) % 256

const sendPacket = async ({ device, data }: { device: USBDevice; data: number[] }) => {
  const _ = Uint8Array.from(data)
  console.log('>>>', Buffer.from(_).toString('hex'))
  return await device.transferOut(2, _)
}

const receive = async ({ device }: { device: USBDevice }) => {
  const result = await device.transferIn(1, 290)
  const data = new Uint8Array(result.data!.buffer)
  console.log('<<<', Buffer.from(data).toString('hex'))
  return [...data]
}

const send = async ({ device, data }: { device: USBDevice; data: number[] }) => {
  const packet_header = [0x00, 0x00, 0xff, 0xff, 0xff]
  const command_header = 0xd6
  const command = [command_header, ...data]

  const command_length = [...get2ByteUint8Array({ value: command.length, little_endian: true })]
  const command_length_checksum = checksum({ data: command_length })
  const command_checksum = [...get2ByteUint8Array({ value: checksum({ data: command }), little_endian: true })]

  await sendPacket({
    device,
    data: [...packet_header, ...command_length, command_length_checksum, ...command, ...command_checksum],
  })

  await receive({ device })
  return await receive({ device })
}

export async function sendACK({ device }: { device: USBDevice }) {
  const result = await sendPacket({ device, data: [0x00, 0x00, 0xff, 0x00, 0xff, 0x00] })
  if (result.status !== 'ok') {
    console.error(result)
    throw new Error('ACK Error')
  }
}

export async function setCommandType({ device, data }: { device: USBDevice; data: number[] }) {
  console.log('setCommandType', Buffer.from(data).toString('hex'))
  return await send({ device, data: [0x2a, ...data] })
}

export async function getFirmwareVersion({ device }: { device: USBDevice }) {
  console.log('getFirmwareVersion')
  return await send({ device, data: [0x20] })
}

export async function getPDDataVersion({ device }: { device: USBDevice }) {
  console.log('getPDDataVersion')
  return await send({ device, data: [0x22] })
}

export async function switchRF({ device, data }: { device: USBDevice; data: number[] }) {
  console.log('switchRF', Buffer.from(data).toString('hex'))
  return await send({ device, data: [0x06, ...data] })
}

export async function inSetRF({ device, data }: { device: USBDevice; data: number[] }) {
  console.log('inSetRF', Buffer.from(data).toString('hex'))
  return await send({ device, data: [0x00, ...data] })
}

export async function inSetProtocol({ device, data }: { device: USBDevice; data?: number[] }) {
  // prettier-ignore
  const default_in_set_protocol = [0x00, 0x18, 0x01, 0x01, 0x02, 0x01, 0x03, 0x00, 0x04, 0x00, 0x05, 0x00, 0x06, 0x00, 0x07, 0x08, 0x08, 0x00, 0x09, 0x00, 0x0a, 0x00, 0x0b, 0x00, 0x0c, 0x00, 0x0e, 0x04, 0x0f, 0x00, 0x10, 0x00, 0x11, 0x00, 0x12, 0x00, 0x13, 0x06]
  if (data == null) {
    data = default_in_set_protocol
  }

  console.log('inSetProtocol', Buffer.from(data).toString('hex'))
  return await send({ device, data: [0x02, ...data] })
}

export async function inCommRF({
  device,
  data,
  timeout_ms,
}: {
  device: USBDevice
  data: number[]
  timeout_ms: number
}) {
  console.log('inCommRF', '>>>', Buffer.from(data).toString('hex'))

  const response = await send({
    device,
    data: [0x04, ...get2ByteUint8Array({ value: (timeout_ms + 1) * 10, little_endian: true }), ...data],
  })

  const result = response.slice(15, response.length - 2)
  console.log('inCommRF', '<<<', Buffer.from(result).toString('hex'))
  return result
}

WebUSB ことはじめの方の記事を参考にしつつ、nfcpy のrcs380.pyを見ながら必要な処理をコーディング。
途中まで書いていたところで、上記の記事の方が既にWebUSB-RC-S380というドライバのライブラリ出していました。
実装する前は、ちゃんと調べないとダメですね。

suica.ts

import {
  getFirmwareVersion,
  getPDDataVersion,
  inCommRF,
  inSetProtocol,
  inSetRF,
  sendACK,
  setCommandType,
  switchRF,
} from './rc-s380'

const data_transfer_rate = {
  kbps212: [0x01, 0x01, 0x0f, 0x01],
  kbps424: [0x01, 0x02, 0x0f, 0x02],
} as const

const request_code = {
  none: 0x00,
  system_code: 0x01,
  communication_ability: 0x02,
} as const

const service_code = {
  history: [0x09, 0x0f],
} as const

const block_list_size = {
  byte3: 0b00000000,
  byte2: 0b10000000,
} as const

const block_list_access_mode = {
  unuse_parse_service: 0b00000000,
  use_parse_service: 0b00010000,
} as const

export async function fetchIDm({ device }: { device: USBDevice }) {
  // INFO:nfc.clf:searching for reader on path usb
  // DEBUG:nfc.clf.transport:using libusb-1.0.23
  // DEBUG:nfc.clf.transport:path matches '^(usb|)$'
  // DEBUG:nfc.clf.device:loading rcs380 driver for usb:054c:06c3
  // Level 9:nfc.clf.transport:>>> 0000ff00ff00
  await sendACK({ device })

  // Level 9:nfc.clf.rcs380:SetCommandType 01
  // Level 9:nfc.clf.transport:>>> 0000ffffff0300fdd62a01ff00
  // Level 9:nfc.clf.transport:<<< 0000ff00ff00
  // Level 9:nfc.clf.transport:<<< 0000ffffff0300fdd72b00fe00
  await setCommandType({ device, data: [0x01] })

  // Level 9:nfc.clf.rcs380:GetFirmwareVersion
  // Level 9:nfc.clf.transport:>>> 0000ffffff0200fed6200a00
  // Level 9:nfc.clf.transport:<<< 0000ff00ff00
  // Level 9:nfc.clf.transport:<<< 0000ffffff0400fcd7211101f600
  // DEBUG:nfc.clf.rcs380:firmware version 1.11
  //* await getFirmwareVersion({ device })

  // Level 9:nfc.clf.rcs380:GetPDDataVersion
  // Level 9:nfc.clf.transport:>>> 0000ffffff0200fed6220800
  // Level 9:nfc.clf.transport:<<< 0000ff00ff00
  // Level 9:nfc.clf.transport:<<< 0000ffffff0400fcd72300010500
  // DEBUG:nfc.clf.rcs380:package data format 1.00
  //* await getPDDataVersion({ device })

  // Level 9:nfc.clf.rcs380:SwitchRF 00
  // Level 9:nfc.clf.transport:>>> 0000ffffff0300fdd606002400
  // Level 9:nfc.clf.transport:<<< 0000ff00ff00
  // Level 9:nfc.clf.transport:<<< 0000ffffff0300fdd707002200
  //* await switchRF({ device, data: [0x00] })

  // Level 9:nfc.clf.rcs380:GetFirmwareVersion
  // Level 9:nfc.clf.transport:>>> 0000ffffff0200fed6200a00
  // Level 9:nfc.clf.transport:<<< 0000ff00ff00
  // Level 9:nfc.clf.transport:<<< 0000ffffff0400fcd7211101f600
  // DEBUG:nfc.clf.rcs380:firmware version 1.11
  //* await getFirmwareVersion({ device })

  // INFO:nfc.clf:using SONY RC-S380/P NFC Port-100 v1.11 at usb:020:003
  // DEBUG:nfc.clf:connect('rdwr',)
  // DEBUG:nfc.clf:connect options after startup: rdwr
  // Level 9:nfc.clf.rcs380:SwitchRF 00
  // Level 9:nfc.clf.transport:>>> 0000ffffff0300fdd606002400
  // Level 9:nfc.clf.transport:<<< 0000ff00ff00
  // Level 9:nfc.clf.transport:<<< 0000ffffff0300fdd707002200
  //* await switchRF({ device, data: [0] })

  // DEBUG:nfc.clf:sense 212F
  // DEBUG:nfc.clf.rcs380:polling for NFC-F technology
  // Level 9:nfc.clf.rcs380:InSetRF 01010f01
  // Level 9:nfc.clf.transport:>>> 0000ffffff0600fad60001010f011800
  // Level 9:nfc.clf.transport:<<< 0000ff00ff00
  // Level 9:nfc.clf.transport:<<< 0000ffffff0300fdd701002800
  await inSetRF({ device, data: [...data_transfer_rate.kbps212] })

  // Level 9:nfc.clf.rcs380:InSetProtocol 00180101020103000400050006000708080009000a000b000c000e040f001000110012001306
  // Level 9:nfc.clf.transport:>>> 0000ffffff2800d8d60200180101020103000400050006000708080009000a000b000c000e040f0010001100120013064b00
  // Level 9:nfc.clf.transport:<<< 0000ff00ff00
  // Level 9:nfc.clf.transport:<<< 0000ffffff0300fdd703002600
  // * await inSetProtocol({ device })

  // Level 9:nfc.clf.rcs380:InSetProtocol 0018
  // Level 9:nfc.clf.transport:>>> 0000ffffff0400fcd60200181000
  // Level 9:nfc.clf.transport:<<< 0000ff00ff00
  // Level 9:nfc.clf.transport:<<< 0000ffffff0300fdd703002600
  //* await inSetProtocol({ device, data: [0x00, 0x18] })

  // DEBUG:nfc.clf.rcs380:send SENSF_REQ 00ffff0100
  // Level 9:nfc.clf.rcs380:InCommRF 6e000600ffff0100
  // Level 9:nfc.clf.transport:>>> 0000ffffff0a00f6d6046e000600ffff0100b300
  // Level 9:nfc.clf.transport:<<< 0000ff00ff00
  // Level 9:nfc.clf.transport:<<< 0000ffffff1b00e5d7050000000008140101010310d10cee27100b4b428485d0ff00037d00
  const pol_res = await inCommRF({
    device,
    data: [
      0x06, // パケットサイズ(6byte固定)
      0x00, // コマンドコード
      0x00, // システムコード
      0x03, // システムコード
      request_code.system_code, // リクエストコード
      0x0f, // タイムスロット
    ],
    timeout_ms: 100,
  })

  // DEBUG:nfc.clf.rcs380:rcvd SENSF_RES 0101010310d10cee27100b4b428485d0ff0003
  // DEBUG:nfc.clf:found 212F sensf_res=0101010310D10CEE27100B4B428485D0FF0003
  // DEBUG:nfc.clf:discovered target 212F sensf_res=0101010310D10CEE27100B4B428485D0FF0003
  // DEBUG:nfc.tag:trying to activate 212F sensf_res=0101010310D10CEE27100B4B428485D0FF0003
  // DEBUG:nfc.tag:trying type 3 tag activation for 212F
  // DEBUG:nfc.clf:connected to Type3Tag 'FeliCa Standard (RC-S???)' ID=01010310D10CEE27 PMM=100B4B428485D0FF SYS=0003
  // Type3Tag 'FeliCa Standard (RC-S???)' ID=01010310D10CEE27 PMM=100B4B428485D0FF SYS=0003
  // DEBUG:nfc.tag.tt3:read w/o encryption service/block list: 0f09 / 8000 8001
  // DEBUG:nfc.tag.tt3:>> 12 06 01010310d10cee27 010f090280008001 (0.0386688s)
  // DEBUG:nfc.clf:>>> 120601010310d10cee27010f090280008001 timeout=0.0386688

  const result = pol_res.slice(2, 10)
  return result.length === 0 ? undefined : result
}

export async function fetchHistories({ device, idm }: { device: USBDevice; idm: number[] }) {
  // Level 9:nfc.clf.rcs380:InSetRF 01010f01
  // Level 9:nfc.clf.transport:>>> 0000ffffff0600fad60001010f011800
  // Level 9:nfc.clf.transport:<<< 0000ff00ff00
  // Level 9:nfc.clf.transport:<<< 0000ffffff0300fdd701002800
  await inSetRF({ device, data: [...data_transfer_rate.kbps212] })

  // Level 9:nfc.clf.rcs380:InSetProtocol 00180101020103000400050006000708080009000a000b000c000e040f001000110012001306
  // Level 9:nfc.clf.transport:>>> 0000ffffff2800d8d60200180101020103000400050006000708080009000a000b000c000e040f0010001100120013064b00
  // Level 9:nfc.clf.transport:<<< 0000ff00ff00
  // Level 9:nfc.clf.transport:<<< 0000ffffff0300fdd703002600
  //* await inSetProtocol({ device })

  // Level 9:nfc.clf.rcs380:InCommRF 8601120601010310d10cee27010f090280008001
  // Level 9:nfc.clf.transport:>>> 0000ffffff1600ead6048601120601010310d10cee27010f0902800080016400
  // Level 9:nfc.clf.transport:<<< 0000ff00ff00
  // Level 9:nfc.clf.transport:<<< 0000ffffff3400ccd70500000000082d0701010310d10cee27000002c74600001ae383a41416470000061d00c84600001ae3434029bbc40000061c00c200

  // DEBUG:nfc.clf:<<< 2d0701010310d10cee27000002c74600001ae383a41416470000061d00c84600001ae3434029bbc40000061c00 0.011s
  // DEBUG:nfc.tag.tt3:<< 2d 07 01010310d10cee27 0000 02c74600001ae383a41416470000061d00c84600001ae3434029bbc40000061c00 (0.010754s)
  // result b'c74600001ae383a41416470000061d00c84600001ae3434029bbc40000061c00'

  const fetch = async ({ blocks }: { blocks: number[][] }) => {
    return await inCommRF({
      device,
      data: [
        14 + blocks.length * 2, // パケットサイズ
        0x06, // コマンドコード(固定値)
        ...idm, // IDm
        0x01, // サービス数(履歴取得しかしないので1つ)
        ...[...service_code.history].reverse(), // サービスコードリスト(リトルエンディアン)
        blocks.length, // ブロック数
        ...blocks.flat(), // ブロック群
      ],
      timeout_ms: 38,
    })
  }

  const range = (start, end) => [...Array(end + 1).keys()].slice(start)
  const createBlockListElement = ({ block_number }: { block_number: number }) => {
    return [block_list_size.byte2 | block_list_access_mode.unuse_parse_service | 0b00000000, block_number]
  }

  const first_blocks = range(0, 9).map(_ => createBlockListElement({ block_number: _ }))
  const second_blocks = range(10, 19).map(_ => createBlockListElement({ block_number: _ }))

  const first_result = await fetch({ blocks: first_blocks })
  const second_result = await fetch({ blocks: second_blocks })

  const chunk = <T>({ items, size }: { items: T[]; size: number }) =>
    items.reduce((newarr, _, i) => (i % size ? newarr : [...newarr, items.slice(i, i + size)]), [] as T[][])

  return chunk({ items: [...first_result.slice(13), ...second_result.slice(13)], size: 16 })
}

RC-S380 から FeliCa カードの製造 ID を読み込むプログラムを作った(Python3)の方の記事を参考にすると、 InCommRF が実際にカードと通信するコマンドみたいで SENSF_REQ の部分が Polling のパケットデータを表している。
ただ、Suica などの交通系システムコードは 0003 なのに nfcpy では FFFF でセットしていて直すと Polling が上手くいかない。
SwitchRFInSetProtocol を外すと通るので、このプレ処理が何か関係ある?
InSetRF は 転送速度を設定していて、このコマンドと InCommRF だけで通信自体はできるっぽい。

IDm 取得部分のタイムアウトはデフォルト 10ms だったんだけど、取れないことがあるので 100ms に伸ばしてる。

InCommRF から返却されたレスポンスの 16 バイト目から最後 2 バイト取った部分がデータ部分みたいなので、そちらをFeliCa カード ユーザーズマニュアル 抜粋版を見ながら値を取り出していく。 履歴取得部分も、前回の記事を参考にそのままパケットデータを組み立てて取得できた。 読み出し可能ブロックの最大数を考慮して、2 回に分けて履歴を取得。

import { connect } from './rc-s380'
import { fetchHistories, fetchIDm } from './suica'

const device = await connect().catch((_: Error) => _)
if (device instanceof Error) {
  console.error(device)
  alert(device.message)
  return
}

const read = async () => {
  const idm = await fetchIDm({ device })
  if (idm == null) {
    await read()
    return
  }

  console.log('IDm', Buffer.from(idm).toString('hex'))

  const result = await fetchHistories({ device, idm })
  console.log(
    'histories',
    result.map(_ => Buffer.from(_).toString('hex')),
  )

  device.close()
}

await read()

あとは、上記をボタンの onClick なイベント発火時に実行すると読み取りができるはず。
そのほかコマンドの意味やエラーパケットの検知などまだまだわからないことやらないといけないことが多いですね。