使用树莓派和Python制作银行卡阅读器来快速识别客户

原创内容,转载请注明来源https://songyue.wang

之前在香港的CinemaCity看电影,发现他们有个很有意思的设计。客户在网上使用信用卡购买电影票后,到影院之后无需输入取票码,只需要在一个普通的磁卡阅读器(不是那种银行POS机)刷一下购票时使用的信用卡,就可以完成自助取票。

以付款信用卡识别客户,确实是一个非常高效的办法,这次经历之后我想到另外一个应用场景。现在很多演唱会深受“黄牛票”的困扰,虽然国内国外很多主办方都用实名认证来解决,但我们的目的只是想解决“购票人转卖门票”的问题,非要收集客户的legal ID显然没必要。另外,国外的legal ID不像国内一样统一(例如加拿大,各省的身份证,驾照,医保卡,持枪证都是legal ID),在检票场景只能使用机读+视读同时进行的方式,浪费人力。由于购票信用卡是每个人都有的,且无论发卡行和组织都有统一的机读方式,因此更适合。

读卡屏显信息(卡号,有效期,持卡人姓名),屏幕已打码
等待读卡状态

前置条件

  • 树莓派Raspberry Pi Model 3B+单片机(官方定价35美元),并已烧录Raspberry Pi OS进Mircro SD卡
  • 磁卡阅读器(淘宝售价70-80人民币)
  • (可选)微雪电子OLED显示屏扩展板(淘宝售价70-80人民币)

项目代码

https://github.com/songyuew/raspi-cc-reader

卡片解码

将磁卡阅读器连接电脑,打开文本编辑器刷一下卡,查看读取的磁信息。重复尝试不同发卡行和卡组织的信用卡,找到剖析的规律。

香港汇丰银联提款卡(BIN621443):

%B621443*************^WANG SONGYUE            /^YYMM*******?;621443*************=***********?+01621443*************=****************************************=************==0=100020?

美国运通信用卡(BIN3727):

%B3727***********^WANG/SONGYUE              ^YYMM************0000000000000000?;3727***********=****************00000?

中银香港万事达信用卡(BIN523984):

%B523984**********^WANG SONGYUE              ^YYMM********          ***      ?;523984**********=******0100000***?+92523984**********==**********************************************************************************?

通过观察发现卡号(PAN)最先出现,开头以%B标识,以^结尾。紧接着是持卡人姓名,同样以^结尾。之后的YYMM位是卡片有效期。除此之外发卡行还会在磁条内写入其它信息。

如果对解码磁道内其它信息感兴趣,可以阅读《GBT 19584-2010银行卡磁条信息格式和使用规范》国标文件。

剖析磁信息的代码cc_parser.py如下:

import getpass

# 使用卢氏算法检查卡号是否为有效
def validator(pan):
    rd1 = list(int(i)*2 for i in pan[-2::-2])
    rd2 = 0 
    for i in rd1: 
        if i < 10: rd2 += i
        else: rd2 += (int(str(i)[0]) + int(str(i)[1]))
    rd3 = list(int(i) for i in pan[-1::-2])
    return (sum(rd3) + rd2) % 10 == 0

# 获取卡号
def getPan(raw,start,end):
    pan = ''
    for char in raw[start:]:
        if char == end: break
        pan += char
    # 如果卡片为非银联卡且卡号长度大于16位,截取前16位(只有银联卡可能有超过16位的卡号)
    if pan[0] != '6' and len(pan) > 16:
        pan = pan[:16]
    return pan

# 获取卡片有效期
def getExp(raw,start):
    if raw[0] == '%':
        exp = raw[raw.find(start,raw.find(start) + 1) + len(start) : raw.find(start,raw.find(start) + 1) + len(start) + 4]
    else: 
        exp =  raw[raw.find(start) + len(start) : raw.find(start) + len(start) + 4]
    if exp == '0000': return 'N/A'
    return exp

# 获取持卡人姓名
def getCH(raw):
    if raw[0] == '%':
        ch = raw[raw.find('^') + 1 : raw.find('^',raw.find('^') + 1)].strip()
        if ch == '': return 'N/A'
        return ch
    else: return 'N/A'

def reader():
    try:
        # 在终端界面隐藏磁信息
        raw = getpass.getpass(prompt='Swipe the card ==>', stream=None)
        pan, exp, ch  = 'N/A','N/A','N/A'
        if raw[0] == '%':
            pan = getPan(raw,2,'^')
            exp = getExp(raw,'^')
            ch = getCH(raw)
        elif raw[0] ==  ';':
            pan = getPan(raw,1,'=')
            exp = getExp(raw,'=')
            ch = getCH(raw)
        elif raw[0] == '+':
            pan = getPan(raw,3,'=')
            exp = getExp(raw,'==')
            ch = getCH(raw)
        elif raw == "exit":
            return {'status':'exit'}
        else: 
            return {'status':'Invalid card'}

        if validator(pan) == False:
            return {'status':'Invalid card'}
        return {'status':'OK','pan':pan,'exp':exp,'ch':ch}
    except:
        return {'status':'failure'}

读卡的结果为一个包含PAN,有效期和持卡人姓名的字典,之后可以用来显示在设备屏幕上或者和订单数据库进行比对。为了防止其它磁卡被误读为信用卡,我们在每次刷卡后调用validator(pan)这个函数验证PAN的编码符合卢氏算法(Luhn Algorithm)

如果业务场景需要识别卡片发卡行或者卡品牌等级(例如Visa Infinite,Visa Signature等)用来会员匹配,可以调用https://bincheck.io/zh/api/subscribe的API,在读卡后将BIN查询结果加到返回的字典里:

import requests

def getBIN(card):
  url = "https://bin-ip-checker.p.rapidapi.com/"

  querystring = {"bin":card['pan'][:6]}
  payload = {"bin":card['pan'][:6]}
  
  # 替换成你的bincheck.io API密钥
  headers = {
    "x-rapidapi-key": "YOUR_BINCHECK_API_KEY",
    "x-rapidapi-host": "bin-ip-checker.p.rapidapi.com",
    "Content-Type": "application/json"
  }

  response = requests.post(url, json=payload, headers=headers, params=querystring).json()

  if(response['success'] == True):
    for i in response['BIN'].keys():
      card[i] = response['BIN'][i]
  
  return card

# 测试卡片(中银香港Visa Infinite Cheers号段)
card1 = {"pan":"4863308888888888","exp":"1223","ch":"John Doe"}
card1 = getBIN(card1)
print(card1)

更新后的字典示例:

{'pan': '4863308888888888', 'exp': '2712', 'ch': 'John Doe', 'valid': True, 'number': 486330, 'length': 6, 'scheme': 'VISA', 'brand': 'VISA', 'type': 'CREDIT', 'level': 'INFINITE', 'is_commercial': 'false', 'is_prepaid': 'false', 'currency': 'HKD', 'issuer': {'name': 'BOC CREDIT CARD (INTERNATIONAL), LTD.', 'website': 'http://www.boci.com.hk', 'phone': ''}, 'country': {'name': 'HONG KONG', 'native': '香港', 'flag': '🇭🇰', 'numeric': '344', 'capital': 'Hong Kong', 'currency': 'HKD', 'currency_name': 'Hong Kong dollar', 'currency_symbol': : '$', 'region': 'Asia', 'subregion': 'Eastern Asia', 'idd': '852', 'alpha2': 'HK', 'alpha3': 'HKG', 'language': 'Chinese', 'language_code': 'ZH'}}

PCI-DSS并没有对BIN列出特别的安全要求,并明文允许在隐藏其它数字的情况下显示PAN前6位和后4位。因此使用在线第三方API查询卡BIN可以是合规的。

业务程序

我这边测试用,就写了一个简单的循环,读取卡片之后继续使用getpass等待刷下一张卡。在实际场景中,可以考虑把卡号hash后之后,与客户数据库进行比对,输出票卡一致/不一致的结果。

屏显模块(可选)

在需要快速采集的场景下,在树莓派上直接安装屏显模块提示读卡结果(例如客户姓名,检票是否通过)会更加方便。首先把微雪OLED显示屏扩展板插入树莓派GPIO接口,并启用树莓派SPI界面。

sudo raspi-config

按照微雪官方教程安装Python依赖包,并下载显示屏驱动和字体包:

sudo apt-get install p7zip-full
wget http://www.waveshare.net/w/upload/c/c5/2.23inch-OLED-HAT-Code.7z
7z x 2.23inch-OLED-HAT-Code.7z 
sudo chmod 777 -R  2.23inch-OLED-HAT-Code
cd 2.23inch-OLED-HAT-Code/

把官方样例stats.py(显示树莓派系统状态的程序)末尾的while True代码块改成函数updateScreen(line1,line2,line3,line4),即简单地显示给定的四行文字。每次读卡时调用这个函数更新屏幕即可,或者使用updateScreen("","","","")清空

def updateScreen(line1,line2,line3,line4):
    # Draw a black filled box to clear the image.
    draw.rectangle((0,0,width,height), outline=0, fill=0)

    draw.text((x,top),line1,font=font,fill=255)
    draw.text((x,top+8),line2,font=font,fill=255)
    draw.text((x,top+16),line3,font=font,fill=255)
    draw.text((x,top+25),line4,font=font,fill=255)

    # Display image.
    disp.image(image)
    disp.display()

如果有树莓派3原装外壳,可以把红色上盖靠近屏显一侧的卡扣用小锯子锯掉。这样在装上屏幕的时候仍然可以把树莓派放进外壳里。

思考

在生产环境应用这种方法的主要考虑为PCI-DSS合规。下表摘自PCI-DSS Quick Reference Guide,有几处跟使用自制机具和软件(非认证POS)读取银行卡相关。

首先需要注意,完整的磁信息在任何时候都不得储存。普通的磁卡阅读器模拟的是键盘输入,所以要特别确保我们在剖析出持卡人信息后,原始输入不会存在系统内,例如文本编辑器或者待命的命令行界面。对于储存卡号(PAN),PCI-DSS的要求原文是“render PAN unreadable”,例如哈希,加密,或者标识化(tokenization),显示PAN需要有“legitimate business need”。因此,在终端屏显(即使是自助机具内部的检修屏显)像测试那样显示没有masking的PAN是存在合规隐患的。如果业务需求是辅助柜员快速完成会员匹配,那么存储持卡人姓名+卡BIN/PAN with masking就已足够。如果业务需求是与识别现有客户,在剖析PAN之后立刻hash则是最便利的合规实践。

对于持卡人姓名和有效期,PCI-DSS并没给出“render unreadable”的要求,但是这两个信息可能适用于其它隐私和个人信息保护的法规,同样需要留意。

Previous
Next