本文下列所述的卡片均为 Mifare Classic 1K 卡,如果你正在找一些有关 FeliCa 的资料,那么来错地方了。

对于不带有 Amusement IC 标记的 AiME 卡,具有固定的 20 位卡号(Access Code),常见的以 0103 6 开头。闲鱼上的兼容卡大多也是此类卡。

Access Code 不是随机生成的数据,它遵循一定的规则:

bytes description
1-5 Prefix
6-13 Serial (Encrypted)
14-20 Digest

Digest 由未加密的 Serial 推导得出,用于加密 Serial:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import hashlib

def digest(serial: int, key: str):
real_digest = hashlib.md5(str(serial).zfill(8).encode()).digest()
digest = bytes(real_digest[int(key[i], 16)] for i in range(16))

bitstring = "".join(bin(i)[2:].zfill(8)[::-1] for i in digest)[::-1].zfill(6 * 23)
computed = 0
while bitstring:
work = int(bitstring[:23], 2)
computed ^= work
bitstring = bitstring[23:]

return str(computed).zfill(7)

此处的 Key 由 Access Code 前六位决定,对于 0103 6 开头的 Access Code,Key 为 A1B3E86CF02974D5。更多的 Prefix 和 Key 的对应关系请见本文参考文献。

Serial 的加密部分,也就是 Access Code 中 6-13 位的内容,由变种纸牌密码算法加密而来,所使用的密钥即为刚才提到的 Digest。

此处使用一副包含 22 张 “牌” 的虚拟牌堆,其中包括 2 张 Joker。通过对这副牌进行一系列确定的 “洗牌步骤”,它能生成一串伪随机数字流。再将这串数字与明文数字组合,就能得到密文;解密时用同样的密钥和牌堆操作,反向计算即可恢复原文。

  1. 准备一副牌堆

    • 共有 22 张牌,编号 1~22。
    • 其中第 21、22 号牌被视为两张 Joker(特殊牌)。
    • 初始状态时,牌堆按顺序排列。
  2. 用密钥洗牌

    • 读取密钥中的每一个字符,将它转换为对应的数字(0-9 → 1-10)。

    • 对每个数字:

      • 先执行一次 “洗牌” 步骤(详见第 3 点)。
      • 再按照该数字的位置切牌,改变整个牌堆顺序。
  3. 生成加密用的随机数(下称为 keystream)
    每加密一个字符,都要重复以下洗牌过程一次:

    • 把 Joker A 向下移动一张;
    • 把 Joker B 向下移动两张;
    • 把 Joker 之外的两部分牌交换位置;
    • 查看底牌的数值(如果是 Joker B 就当作 Joker A 的数值),再按照这个数切牌;
    • 查看顶牌的数值(同样 Joker B 视为 Joker A 的数值),取这个数字对应位置的牌号作为 keystream 数字。
      如果这张牌又是 Joker,就重新洗一次直到不是 Joker。
  4. 对明文字符进行加密

    • 明文中的每一个字符(0-9)会先被转换成数字 1-10;
    • 然后将这个数字与刚才生成的 keystream 数字相加;
    • 再把结果映射回 0-9 的字符,形成密文字符。
  5. 得到最终密文

    • 所有字符加密完成后,密文就是一串同样长度的数字字符。
    • 解密时,使用相同的密钥和同样的洗牌步骤,只不过把加法变成减法,就能还原明文。

一个简单的实现如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80

def char2num(c): return ord(c) - ord('0') + 1
def num2char(num):
while num < 1: num += 10
return chr(((num - 1) % 10) + ord('0'))

class MiniSolitaire:
def __init__(self):
self.DECK_SIZE = 22
self.JOKER_A = 21
self.JOKER_B = 22
self.deck = list(range(1, self.DECK_SIZE + 1))

def move_card(self, card):
p = self.deck.index(card)
if p < self.DECK_SIZE - 1:
self.deck[p], self.deck[p + 1] = self.deck[p + 1], self.deck[p]
else:
self.deck.pop()
self.deck.insert(1, card)

def cut_deck(self, point):
tmp = self.deck[point:-1] + self.deck[:point]
self.deck = tmp + [self.deck[-1]]

def swap_outside_joker(self):
j1 = self.deck.index(self.JOKER_A)
j2 = self.deck.index(self.JOKER_B)
if j1 > j2:
j1, j2 = j2, j1
tmp = self.deck[j2+1:] + [self.deck[j1]] + self.deck[j1+1:j2] + [self.deck[j2]] + self.deck[:j1]
self.deck = tmp

def cut_by_bottom_card(self):
p = self.deck[-1]
if p == self.JOKER_B:
p = self.JOKER_A
self.cut_deck(p)

def get_top_card_num(self):
p = self.deck[0]
if p == self.JOKER_B:
p = self.JOKER_A
return self.deck[p]

def deck_hash(self):
while True:
self.move_card(self.JOKER_A)
self.move_card(self.JOKER_B)
self.move_card(self.JOKER_B)
self.swap_outside_joker()
self.cut_by_bottom_card()
p = self.get_top_card_num()
if p not in (self.JOKER_A, self.JOKER_B):
return p

def create_deck(self, key):
self.deck = list(range(1, self.DECK_SIZE + 1))
for ch in key:
self.deck_hash()
c = char2num(ch)
self.cut_deck(c)

def encode(self, key, plaintext):
self.create_deck(key)
ciphertext = ""
for ch in plaintext:
self.deck_hash()
p = self.get_top_card_num()
ciphertext += num2char(char2num(ch) + p)
return ciphertext

def decode(self, key, ciphertext):
self.create_deck(key)
plaintext = ""
for ch in ciphertext:
self.deck_hash()
p = self.get_top_card_num()
plaintext += num2char(char2num(ch) - p)
return plaintext

将所得的三个部分拼接在一起,即得到了一个合法的 Access Code。

这里 提供了一份 AiME 卡的数据样例,在 Sector 0,Block 3 的最后 20 byte 存储了 Access Code 信息。若要模拟,只需生成一个合法的 Access Code,写入对应位置即可。

参考资料