微信/支付宝的条码支付是如何验证账户的?安全吗?

请留意:本文所述的方法仅是经过资料收集和讨论所提出的一种原理假设,并非微信/支付宝官方披露的具体方法。

疑问

在信息时代,路边摊都能接受支付宝付款。于是,我一直有个疑问。

1. 微信/支付宝/Paypal 的条形码支付/二维码支付是如何实现的?它们安全吗?

2. 为什么用户不需要联网,也能完成支付?

条形码/二维码支付的特点

二维码支付主要有“用户扫码”和“商家扫码”(反扫)两种。“用户扫码”是商家提供二维码,用户手机客户端扫码,确认购物信息后进行支付。“商家扫码”(“刷卡支付”)则是用户出示二维码,商户扫描该二维码进行扣款。

“用户扫码”的二维码实际是个购物网站的链接,扫描后的流程与我们通常的网上购物差异不大。

“商家扫码”(“刷卡支付”)则是由用户的手机客户端生成一串“支付码”(如下图),商家读取支付码后,交由支付网关进行清算。据观察,它有如下特点:

1. 用户的手机客户端不需要联网,即可生成支付码并完成交易(第一次使用时,需要联网验证支付密码,来开启扫码支付功能);

2. 这串18位的支付码是动态变化的,大约30秒动态变化一次。

本文将探讨“商家扫码”(“刷卡支付”)背后的原理。

1600126017

“刷卡支付”的流程

“刷卡支付”分为“免密支付”和“验密支付”两种,前者用户无须联网无须输入支付密码,后者需要用户联网输入密码以完成交易。本文接下来讨论的,是“刷卡支付”中的“免密支付”。

“免密刷卡支付”的流程大致如下:

1. 商家生成订单,告知用户消费金额;

2. 用户打开客户端,进入条码界面;

3. 商家使用扫码设备读取支付码,调用 API 将支付信息提交支付网关进行处理;

4. 支付网关验证商家和用户的信息,进行扣款后向商家返回支付结果;

5. 用户收到扣款消息(银行短信等),交易完成;

微信支付的流程如下图所示(来源 https://pay.weixin.qq.com/wiki/doc/api/micropay.php?chapter=5_4 )

微信刷卡支付流程

微信免密刷卡支付时序图

另外,使用微信免密支付需要满足以下规则:

◆ 支付金额>1000元的交易需要验证用户支付密码
◆ 用户账号每天最多有5笔交易可以免密,超过后需要验证密码
◆ 微信支付后台判断用户支付行为有异常情况,符合免密规则的交易也会要求验证密码

来源 https://pay.weixin.qq.com/wiki/doc/api/micropay.php?chapter=5_2

一次性密码 (OTP)

在进一步讨论“刷卡支付”前,我们首先要了解一次性密码(One Time Password, OTP)。常用的一次性密码包括 HOTP (RFC 4226) 和 TOTP (RFC 6238)。

在使用 HOTP (HMAC-Based One-Time Password) 之前,双方要事先约定 shared_secret. 往后每次进行验证,HTOP 将根据 shared_secret(K) 和 计数器(C),生成一个6-8位的动态口令。

动态口令是一次性的,每次生成动态口令时,计数器C会加1,生成的新口令与之前的口令完全无关。使用 OTP 无需暴露 shared_secret 即可验证身份。假如第三方截获了某个动态口令,也无法反推出 shared_secret,更无法推导出下一个动态口令。

下面以用户登录网站为例,简述 HOTP 验证的大致方法:

1. 在最开始, 网站和用户约定一个 shared_secret(K)。

2. 往后,用户每次登录网站时,通过 HOTP 生成一个6位的动态口令(user_token),并提供给网站:

user_token = HOTP(K,C) = Truncate(HMAC-SHA-1(K,C))

3. 因为网站与用户拥有同样的 shared_secret(K) 和计数器(C),所以网站也可以算出一个6位的动态口令:

web_token = HOTP(K,C) = Truncate(HMAC-SHA-1(K,C))

4. 随后,网站可以通过对比动态口令校验用户身份:

if ( user_token == web_token ) accept();
else reject();

另一种算法 TOTP (Time-Based One-Time Password),是在 HOTP 基础上的实现。它用时间代替 HOTP 中的计数器, C = Unix_time / step,通常 step = 30s.

TOTP

客户端如何生成支付码?

了解OTP后,我们可以猜想微信支付是如何生成支付码的。

支付码最少需要包含 [账户信息 + 动态密码],账户信息可以是一个静态的能映射到支付帐号的数字,动态密码可以通过 OTP 生成。然而从实际中,我们观察到微信/支付宝的支付码在每次刷新时,几乎每一位都在变化,那账户信息藏哪呢? 北邮人论坛的 hdyvip 提供了一个思路:

全部hash肯定不行,要能识别出用户来才可以。
我说的一部分标示用户,一部分是密码并不是指特定几位数的。
这个16位应该是编码之后的。

比如:
提前商定一个数,至少要大于用户数,质数最好(全局唯一)
用一次性密码乘以这个数加上用户的数字id
当然这种方法并不能保证13是不变的,我只是举个例子。

按照这个思路,客户端可按如下方法生成支付码:

约定的质数 > 用户总数
6位动态口令 = TOTP(shared_secret)
支付码 = 账户ID + 6位动态口令 * 约定的质数

微信/支付宝如何验证账户?

确定好客户端如何生成密码,支付网关就可以从支付码中提取信息了。

账户ID = 支付码 mod 约定的质数
6位动态口令 = 支付码 / 约定的质数

因为 shared_secret 是用户和支付网关都知道的(没有第三方知道),所以支付网关也可以用同样算法得出6位动态口令进行对比,从而验证用户身份。

演示

我们可以虚构一个客户端和一个服务端进行尝试。客户端与服务端共享一个 shared_secret 和 shared_prime (约定的质数),客户端生成支付码,服务端接收并验证用户身份。

1. 客户端, client.py .

#!/usr/bin/python
#
# Demo for Barcode Payment client.
#
# Usage: python client.py
# Input: null
# Output: 11-digit barcode for payment.
#
# Note that share_secret and user_id is pre-set here
# for the convenience of demostration.
# In read world, they should not be set in program,
# instead each user_id should map with its own unique 
# shared_secret.
#
# feichashao@gmail.com
#
# 2016.May.14
#

import pyotp

# pre-set variables
shared_secret = u'NUC5JRHENP53CJK4'
user_id = 66666
shared_prime = 99991

# Generate token
totp = pyotp.TOTP(shared_secret)
token = int(totp.now())

# Generate 11-digit barcode
barcode = user_id + shared_prime * token
print "%011d" % barcode

2. 服务端,server.py .

#!/usr/bin/python
#
# Demo for Barcode Payment server.
#
# Usage: python server.py barcode
# Input: (int) 11-digit barcode for payment.
# Output: (string) 'Accept' or 'Reject'
#
# Note that share_secret and user_id is pre-set here
# for the convenience of demostration.
# In read world, they should not be set in program,
# instead each user_id should map with its own unique 
# shared_secret.
#
# feichashao@gmail.com
#
# 2016.May.14
#

import sys
import pyotp

# pre-set variables
shared_secret = u'NUC5JRHENP53CJK4'
user_id = 66666
shared_prime = 99991

# Extract barcode
try:
    barcode = int(sys.argv[1])
except:
    barcode = 0

income_uid = barcode % shared_prime
token = barcode / shared_prime

# Verify account
totp = pyotp.TOTP(shared_secret)

result = ( income_uid == user_id ) and totp.verify( str(token).zfill(6) )

# Print result
if result:
    print 'Accept'
else:
    print 'Reject'

3. 尝试一次有效的验证。

### 客户端生成支付码
~/TOTP$ python client.py 
06412089532

### 商家获取支付码 06412089532,提交到服务端进行验证。
~/TOTP$ python server.py 06412089532
Accept

4. 尝试使用一个虚假的支付码

~/TOTP$ python client.py 
26972238952

~/TOTP$ python server.py 88888888888
Reject

5. 验证支付码的时效性(默认每个支付码30秒有效)

### 获取支付码后马上进行验证
~/TOTP$ barcode=`python client.py`; python server.py $barcode
Accept

### 获取支付码后等待30s进行验证
~/TOTP$ barcode=`python client.py`; sleep 30s; python server.py $barcode
Reject

参考资料

[With Payment Code, PayPal Taps QR Codes And Existing Hardware For Large Retailer Mobile Payments]
http://techcrunch.com/2013/10/08/with-payment-code-paypal-taps-qr-codes-and-existing-hardware-for-retail-mobile-payments/

[ PayPal offers QR codes for retail-store purchases ]
http://www.cnet.com/news/paypal-offers-qr-codes-for-retail-store-purchases/

[微信支付开发者文档] https://pay.weixin.qq.com/wiki/doc/api/micropay.php?chapter=5_4

[pyotp] https://pypi.python.org/pypi/pyotp