やさしく学ぶ 静岡大学学生証

本投稿は[静大情報LT大会(IT) Advent Calendar 2018](https://adventar.org/calendars/3386) 24日目の記事です。

# 概要
本記事では、静岡大学(すくなくとも浜松キャンパス)で使用されている学生証に関して、NFCやFeliCaの規格、学生証のデータ、NFC/FeliCaリーダーPaSoRi RC-380/SとFeliCaライブラリnfcpyを用いた、あるいはFeliCa対応Androidスマートフォンを用いた学生証に関するプログラミングについて解説します。
静岡大学の学生証はFeliCaとよばれる非接触ICカード技術方式に対応したRFID ICカードです。全学生教員が所持しています。また内部の非暗号化領域には学籍番号や静大ID、生協や学食などの決済に用いられるキャンパスペイの履歴などが格納されています。
しかしながら、学生証の仕組みや仕様などは公開されていません。風の噂では、学生証の情報を把握している教員がいるとかいないとか。いずれにしても有志が解析公開している生協キャンパスペイ履歴などの一部領域をのぞけば、学生証のデータはいまだどのような情報がどのように符号化されて保存されているか不明瞭です。
静岡大学の学生証に関する情報源がほとんどないので、ここに情報を残しておこうと思います。

~~ソースコードとFeliCaのコマンドの説明は途中で力尽きたので最悪僕に大学で聞いてください。気が向いたら追記します。~~

# セキュリティー
この記事を通して学生証を悪用できる可能性は微粒子以上のレベルで存在します。
2016-2018年度における静岡大学学生証には本名や住所などの重要度の高い個人情報は非暗号化領域には(ぱっとみ)存在していません。だれかがあなたの学生証の情報をすっぱ抜いたからと言って直ちに危険が及ぶものではありません。
しかしながら、たとえば、IDmでの個人認証はIDmの偽装が技術的に可能な点からも大変危険ですし、それこそカードエミュレーションなどを用いれば容易ですので脆弱なシステムでは問題になりえます。

# NFC
NFCとは _Near field communication_ の略で、近距離にある2つの電子デバイス間で通信を行うための通信プロトコル群のことです。
NFCにはタイプA, B, Fがあり、それぞれNFC-A, NFC-B, NFC-Fと呼ばれます。
NFC ICチップとアンテナとこれらを一つの物とする入れ物全体とを称して、NFCタグと呼びます。静岡大学学生証、ICOCAなどもNFCタグの一つです。
NFCには以下の3つのモードが存在し、いずれかのモードで動作します。

* リーダー/ライターモード
NFC対応端末でNFCタグの情報を読み書きする
学生証をリーダーにかざすときのモード

* カードエミュレーションモード
NFC対応端末をNFCタグのように振る舞わせる
おサイフケータイ、モバイルICOCAなどでスマホをリーダーにかざすときのスマホのモード

* P2Pモード
2台のNFC対応端末間で相互に直接データを交換、通信する

本記事ではNFCタグである学生証のデータを読み(書き)するので使用するモードは主にリーダー/ライターモードです。

## NFC-F
NFCのなかでもNFC-FはFeliCaで利用されているタイプで、NFC-Fを土台にFeliCaが構築されています。
Sonyによれば、NFC-FにカードOS(リーダー側のソフトウェア)を加えたものということです。[^2]

# FeliCa
FeliCaとは、非接触(contactless)ICカード技術方式のことです。 非接触とはまさに学生証をかざすだけということです。(リーダーにガンガンぶつけてますが)。RFIDとは _Radio-frequency identification_ のことで、電磁場や電波を用いた近距離無線通信を行う識別情報のことです。要するに学生証はFeliCa対応のRFIDとして学内で活用されてということです。
FeliCaには学生証につかわれているFeliCa Standardとそのセキュリティ機能とファイルシステム(内部構造)を簡素化したFeliCa Lite-Sの2つの種類が有ります。

FeliCaでは_コマンド_を送信することで通信します。FeliCaのプロトコル/コマンドの仕様は、[FeliCaカード ユーザーズマニュアル 抜粋版](https://www.sony.co.jp/Products/felica/business/tech-support/data/card_usersmanual_2.01.pdf) にすべて載っています。
例えば、認証の必要のないサービスのブロックデータを読むためには、相手に `Read Without Encryption` コマンドを送信します。

FeliCaの内部構造であるファイルシステムについてはシステム・エリア・サービス・ブロックデータから構成されます。
ユーザーマニュアル[^3]のファイルシステムについての記述を引用します。

> FeliCa のファイルシステムは、システム・エリア・サービス・ブロックデータから構成されます。システム・エリア・サービス・ブロックデータは特定のデータサイズ単位で管理し、その単位をブロックとよびます。
> サービスは、ブロックデータに対するアクセス方式やアクセス権限を定義し、アクセス権限を確認するための認証用の鍵を保持します。サービスのアクセス方式には、この認証用の鍵を使用せずにブロックデータの読み書きが可能なものと、認証用の鍵を使用してカードとリーダ/ライタ間で相互認証が成功しなければ、ブロックデータの読み書きができないものがあります。
> エリアとは、ブロックデータを階層的に管理するためのものです。
> エリアは階層構造をとることが可能であり、あるエリアに対して 1 つ下の階層のエリアを子エリアとよび、1つ上の階層のエリアを親エリアとよびます。エリアは、子エリア作成権限と 1 つ下の階層のサービスに対するサービス登録権と鍵変更権を持ちます。
> 1 つの物理カードに複数の論理的なカードを保持することができ、この論理的なカードをシステムとよびます。
> カード製造時に最初に生成するシステムを「システム 0」といい、「システム 0」から新たに生成するシステムを、生成する順に「システム 1」、「システム 2」とよびます。

サービスには以下の属性が存在します。

* タイプ
* アクセス制御

サービスのタイプとしては以下の３つが存在します:

* `Random`
ランダムアクセスが可能なサービスです。アクセス制御のもと自由に読み書き可能です。
ユーザーズマニュアルを引用します。
> ランダムサービスは、ユーザーが自由にブロックを指定してアクセス可能なサービスです。

* `Cyclic`
リングバッファ的なFIFOです。ユーザーズマニュアルを引用します。
> サイクリックサービスは、ログの記録をユースケースとして、ブロックへのアクセスに特殊な機能を付加したサービスです。ブロックへの書き込みは、常に一番古いブロックに対して行われます。このような書き込みが行われるため、ブロックのグループを循環して使用することが可能です。

* `Purse`
データを自由に変更するのではなく、数値の加減算などを目的としたサービスです。キャンパスぺイでは使用回数の記録などに使用されています。
ユーザーズマニュアルを引用します。
> パースサービスとは、ブロックデータの一部を正の数値とみなして、その値を減算する機能を持つサービスです。パースサービスは、料金徴収をユースケースとして、ブロックへのアクセスに特殊な機能を付加したサービスです。

それぞれのサービスについてアクセス制御としては、読み込み(`read`)、書き込み(`write`)それぞれに対して要認証(`with key`)か認証不要(`w/o key`)があります。


# 学生証を覗く
ここではFeliCaの仕様を学生証の中身をみながら確認していきます。
以下は、学生証の内部構造です。実際のブロックデータは一部を除いて筆者の個人情報かもしれないよくわからない情報のうっかり公開を防ぐため省きました。

```
System 048B (unknown)
Area 0000--FFFE
  Area 0100--01FF
    Random Service 4: write with key & read w/o key (0x0108 0x010B)
    Area 0700--07FF
    Random Service 28: write with key & read with key (0x0708 0x070A)
    Area 0800--09FF
    Random Service 33: write with key & read w/o key (0x0848 0x084B)
    Random Service 34: write with key (0x0888)
    Cyclic Service 36: write with key & read w/o key (0x090C 0x090F)
    Purse Service 37: direct with key & decrement with key & read w/o key (0x0950 0x0954 0x0957)
    Cyclic Service 38: write with key & read w/o key (0x098C 0x098F)
    Random Service 39: write with key & read w/o key (0x09C8 0x09CB)
    Area 1000--10FF
    Random Service 64: write w/o key (0x1009)
    Area 2000--2FFF
    Random Service 128: write with key & read w/o key (0x2008 0x200B)
     0000: 31 39 39 39 39 39 39 39 39 30 32 30 30 30 30 30 |1999999990200000|
     0001: 32 30 32 34 30 33 33 30 7a 7a 39 39 39 39 39 39 |20240330zz999999|
    Area 3000--3FFF
    Random Service 192: write with key & read w/o key (0x3008 0x300B)
    Area 5000--5FFF
    Random Service 320: write w/o key & read w/o key (0x5009 0x500B)
    Area E000--EFFF
    Random Service 907: write with key & write w/o key & read w/o key (0xE2C8 0xE2C9 0xE2CB)
    Area F000--FFFE
    Random Service 1012: write with key & read with key (0xFD08 0xFD0A)
    Random Service 1016: write w/o key (0xFE09)
System FE00 (Common Area)
Area 0000--FFFE
  Area 1A80--1AFF
    Area 1A81--1AFF
      Random Service 106: write with key & read w/o key (0x1A88 0x1A8B)
      Area 50C0--50FF
      Area 50C1--50FF
        Random Service 323: write with key & read w/o key (0x50C8 0x50CB)
        Cyclic Service 323: write with key & read w/o key (0x50CC 0x50CF)
        Purse Service 323: direct with key & decrement with key & read w/o key (0x50D0 0x50D4 0x50D7)
```

例えば、上記学生証のサービスである `Random Service 128: write with key & read w/o key (0x2008 0x200B)` からは、このサービスがランダムアクセス可能で、書き込みには認証が必要ですが、読み込みには認証が必要ないことが分かります。

さて、このシステムコード _0x048B_ のサービスコード _0x200B_ には学籍番号と静大IDがアスキーコードで格納されています。
上記の例では学籍番号_99999999_、静大ID _zz999999_ となっています。ですから実際に学生証から学籍番号と静大IDを取得するには、このシステムコードとサービスコードとを指定してブロックデータを読み取り、該当部分を抽出すればいいわけです。

システムコード _0xFE00_ には大学生協に関するデータが格納されています。この仕様については有志の方の詳しい情報が以下で公開されいます。
[大学生協FeliCaの仕様 · GitHub](https://gist.github.com/oboenikui/ee9fb0cb07a6690c410b872f64345120)


# PaSoRiとnfcpyを用いたプログラミング
さてここではNFC/FeliCaのPythonオープンソースライブラリである_nfcpy_とNFC/FeliCaリーダーであるSony PaSoRi RC-380/Sを用いて学生証の学籍番号と静大IDを表示してみます。

```python
#!/usr/bin/env python2

import sys
import nfc

# PaSoRi RC-S380
PASORI_S380_PATH = 'usb:054c:06c3'


def sc_from_raw(sc):
    return nfc.tag.tt3.ServiceCode(sc >> 6, sc & 0x3f)


def on_startup(targets):
    return targets


def on_connect(tag):
    print "[*] connected:", tag
    sc1 = sc_from_raw(0x200B)
    bc1 = nfc.tag.tt3.BlockCode(0, service=0)
    bc2 = nfc.tag.tt3.BlockCode(1, service=0)
    block_data = tag.read_without_encryption([sc1], [bc1, bc2])
    print "Student ID: " + block_data[1:9].decode("utf-8")
    print "Shizudai ID: " + block_data[24:32].decode("utf-8")
    return True


def on_release(tag):
    print "[*] released:", tag


def main(args):
    with nfc.ContactlessFrontend(PASORI_S380_PATH) as clf:
        while clf.connect(rdwr={
            'on-startup': on_startup,
            'on-connect': on_connect,
            'on-release': on_release,
        }):
            pass


if __name__ == "__main__":
    main(sys.argv)
```

以下に実行結果を示します。

```
Student ID: 99999999
Shizudai ID: zz999999
```

nfcpyがFeliCaのコマンドの通信を全てやってくるので、非常にお手軽ですね。

# Androidスマートフォンを用いたプログラミング
FeliCa対応Android端末でJavaを用いてFeliCaで通信します。

```java
public class MainActivity extends AppCompatActivity implements NfcAdapter.ReaderCallback {

    private NfcAdapter nfcAdapter;
    private Tag tag;
    private static byte NFC_F_CMD_RWOE = 0x06;
    private static String SHIZ_UNIV_ID_SYS_CODE = "048B";
    Vibrator vibrator;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);

        vibrator = (Vibrator) getSystemService(VIBRATOR_SERVICE);
    }

    @Override
    protected void onResume() {
        super.onResume();

        nfcAdapter = NfcAdapter.getDefaultAdapter(this);
        if (nfcAdapter == null) {
            Toast.makeText(MainActivity.this, "FeliCaが有効ではありません", Toast.LENGTH_SHORT).show();
            super.finish();
        }

        nfcAdapter.enableReaderMode(this, this, FLAG_READER_NFC_F | FLAG_READER_SKIP_NDEF_CHECK, null);
    }

    @Override
    protected void onPause() {
        super.onPause();

        if (nfcAdapter != null)
            nfcAdapter.disableReaderMode(this);
    }

    private void vibrate(long[] pattern) {
        if (vibrator != null && vibrator.hasVibrator()) {
            vibrator.vibrate(pattern, -1);
        }
    }

    @Override
    public void onTagDiscovered(Tag tag) {
        MainActivity.this.tag = tag;

        NfcF nfcf = NfcF.get(MainActivity.this.tag);

        byte[] rwoe_res = null, rwoe = readWithoutEncryption(MainActivity.this.tag.getId(), new byte[]{(byte) 0x20, (byte) 0x0B}, 2);
        try {
            if (rwoe == null) {
                Toast.makeText(MainActivity.this, "Fatal: ByteArrayOutputStream.write() failed", Toast.LENGTH_SHORT).show();
                return;
            }

            nfcf.connect();
            rwoe_res = nfcf.transceive(rwoe);

            if (rwoe_res == null) {
                Toast.makeText(MainActivity.this, "FeliCa通信異常", Toast.LENGTH_SHORT).show();
                vibrate(new long[]{0, 800});
                return;
            }
        } catch (IOException e) {
            Toast.makeText(MainActivity.this, "FeliCa通信失敗" + e.toString(), Toast.LENGTH_SHORT).show();
            vibrate(new long[]{0, 800});
            return;
        }

        shizudaiID = parseRwoeString(rwoe_res, 37, 8);
        studentNumber = parseRwoeString(rwoe_res, 14, 8);
        IDm = bytesToString(MainActivity.this.tag.getId());
        PMm = bytesToString(nfcf.getManufacturer());
        systemCode = bytesToString(nfcf.getSystemCode());

        boolean isStudentCard = systemCode.equals(SHIZ_UNIV_ID_SYS_CODE)
                && (shizudaiID != null || studentNumber != null);

        if (isStudentCard) {
            Toast.makeText(MainActivity.this,
                    String.format("静岡大学学生証です\nIDm: %s\nSystem Code: %s\nPMm: %s\n学籍番号: %s\n静大ID: %s",
                            IDm, SHIZ_UNIV_ID_SYS_CODE, PMm, studentNumber, shizudaiID),
                    Toast.LENGTH_LONG).show();
            vibrate(new long[]{0, 80});
        } else {
            Toast.makeText(MainActivity.this, "静岡大学学生証ではありません", Toast.LENGTH_SHORT).show();
            vibrate(new long[]{0, 800});
        }
    }

    private static String parseRwoeString(byte[] res, int offset, int length) {
        if (res[10] != 0)
            return null;
        return new String(res, offset, length);
    }

    private byte[] readWithoutEncryption(byte[] idm, byte[] serviceCode, int blockNum)  {
        ByteArrayOutputStream bout = new ByteArrayOutputStream(128);

        bout.write(0);                  // data length
        bout.write(NFC_F_CMD_RWOE);     // command code
        try {
            bout.write(idm);            // IDm
        } catch (IOException e) {
            return null;
        }
        bout.write(1);                  // service length
        bout.write(serviceCode[1]);     // service code
        bout.write(serviceCode[0]);
        bout.write(blockNum);           // block size

        // block element
        for (int block = 0; block < blockNum; ++block) {
            bout.write(0x80);
            bout.write(block);          // block number
        }

        byte[] rwoe_request = bout.toByteArray();
        rwoe_request[0] = (byte) rwoe_request.length;

        return rwoe_request;
    }
}
```

Android APIはFeliCaの通信をラップしてくれないので、自分でFeliCaのコマンドを作成して直接学生証と通信しますのでnfcpyに比べて複雑になります。
つまりnfcpyで `tag.read_without_encryption()` でブロックデータを読めたところを、`read without encryption` を行うためのコマンドを作成し学生証に送信することでこれを行います。
このコマンドのフォーマットは、FeliCaのユーザーズマニュアルに書いてあります。[^3]

# まとめ
学生証、NFC、FeliCa、nfcpy、Androidを用いたプログラミングについて見てきました。
これで、誰でも学生証ハックができるはずなので、みなさんも是非上手に活用してみてください。

[^2]: [Sony Japan | FeliCa | NFCについて | NFCとFeliCaの関係](https://www.sony.co.jp/Products/felica/NFC/relation.html)
[^3]: [FeliCaカード ユーザーズマニュアル 抜粋版](https://www.sony.co.jp/Products/felica/business/tech-support/data/card_usersmanual_2.01.pdf)