[TOC]
In-App是什么?
全称:Apple Pay In-App Provisioning。就是在应用内配置信息,把用户的银行卡直接绑定到用户手机的Apple Wallet中,而不需要用户手动输入信息,提供了良好的用户体验。无需跳出应用,直接把用户信息通过Passkit SDK提供给苹果,达到绑卡的目的。 starts and finishes 整个流程都是在App中。
我们要做的,也就是ApplePay的应用内绑卡功能。和使用AppleWallet绑卡是一样的,绑卡后可以通过ApplePay购物支付等。
绑卡流程
关键角色
- Bank Client:对接用户
- Bank Server:提供用户/卡信息
- PKPass:提供Apple Wallet相关查询/绑卡接口
- Apple Server:接收PKPass数据
- Visa:移动支付运营商(PNO:Payment Network Operator)。
- FD:卡信息提供商,负责发卡
解释一下Visa与FD他们的区别:
Visa是支付运营商,他们定义了一套支付的协议,不同的银行可以对接其协议,达成支付能力。他们会给每个银行一个编号,支付过程首先流转到Visa,再根据编号识别属于什么银行,然后做数据流转。
类似的还有美国运通,
FD是卡信息提供商,Bank的卡片生成也是FD生成的,包括cvv2,日期,卡号等信息。支付过程中卡信息的确认也发生在FD。
Bank银行负责记录账户与卡之间的关系,卡余额也是记录在Bank方。
关键短句
- DPAN:Device Primary Account Number 设备主账号(与银行卡号唯一对应的一串号码,如:V-999916888641233333222)
- FPAN:Funding Primary Account Number 资金主账号(银行卡号)
- SEID:苹果手机的一个序列号(NFC模块的序列号)
- ECC:Elliptic Curve Cryptography(椭圆曲线加密),苹果绑卡就是经过ECC-V2加密传输的。
- PNO:Payment Network Operator,支付网络运营商,我们目前PNO就是Visa。
- regular provisioning flow:常规认证流程。无论是苹果支付,或华为/小米/谷歌,有的只是加解密过程不一样,最终解密完成之后的拿到明文Payload,执行绑定操作。
绑卡流转流程
苹果文档上介绍的,是单独的绑卡过程。下面讲述的,是我们Bank绑卡的实际流程,包括绑卡入口的判定。
1. Bank App判断是否显示绑卡按钮。
想要显示绑卡入口,需要满足两点:
- 设备及系统支持。(iphone6 ios9.0+)
- Bank的绑卡功能开关。Bank添加了开关功能,基于此项开的情况下,才去判断PKPassLibrary。
- 未被添加到当前设备(连接iwatch的情况下,需要两者都被绑定才会不显示)。
App向Bank后台请求ApplePay相关数据,根据拿到的DPAN,放到PKPassLibrary的canAddPaymentPass接口判断是否已绑定,如已绑定则不展示。
2. Bank用户点击Add To Apple Wallet按钮,触发绑卡流程。
首先,Bank App会向Bank后台发起一个请求,后台返回启动绑卡的config信息,包含的关键信息有:
- cardHolderName 持卡人姓名
- paymentNetwork 支付运营商
- primaryAccountNumberSuffix 主账户后4位
- primaryAccountIdentifier(DPAN)PassKit可以根据其判断是否展示AppleWatch绑卡。
3. App生成Config,并通过PKAddPaymentPassViewController调起In-App界面。
PKAddPaymentPassViewController这个VC的生成有条件限制,不符合条件会返回nil,后面会细说。
添加到Wallet入口 | In-App绑卡界面 | 绑卡失败示例 |
---|---|---|
image
|
image
|
image
|
4. 点击下一步,会触发PassKit代理,待App上传交易加密字段
加密发生在Bank后台,这也是苹果推荐的一种方式,保证了数据的安全。
苹果回调返回certificates/nonce/nonceSignature,这三个数据发送给Bank后台,后台验证证书链的合法性,如正确,把绑卡相关用户信息两重加密,回传给App。
App把从Bank后台收到的encryptedPassData/ephemeralPublicKey/activationData,经base64反解,组装成PKAddPaymentPassRequest,通过PassKit代理中的handler传送给苹果。
5. 苹果解密ECCV2数据,并把解密后数据K传给Visa
加解密是整个In-App绑卡中最重要和关键的部分,也是最容易出错的部分。比较难排查,需要有苹果人员配合分析日志。
Note:苹果对于加解密,有一份Test Vector,可以邮件苹果或者直接给苹果对接人要。有了这个Test Vector,后台就能准确对比加密过程中每一步的结果值,确保加密无误。
Note:在我们多次的异常解决过程中,苹果给的邮件回复往往能很直接的命中要害,所以要多邮件沟通。
6. Visa解密K得到原始JSON,进入常规认证流程。
encryptedPassData中Payload一般是这个样子的,外面再套两层加密(for Visa,for Apple),也保证了数据传输的安全性。
{
"primaryAccountNumberPrefix":"496626", "encryptedPrimaryAccountNumber":"TUJQQUQtMS1GSy00MjYwNjQuMS0tVERFQS1BOEZFOEVGRTdFNzlFN",
"nonceSignature":"408089C255A06E59FEF1702BA74715D96BC1C5CBD7CD6C90A6F06B94ED67D231765D", "networkName":"Visa",
"name":"John Appleseed",
"nonce":"08643998"
}
绿色/橙色流程
对于每个绑卡认证请求,苹果都会给出对应的风险等级建议。
- 绿色流程:大部分苹果给出的建议都是绿色流程,可以直接认证绑卡成功,不需要其它用户身份验证。
- 黄色流程:必须先验证身用户身份,由发卡行提供验证选项,具体方式可以通过(SMS/EMAIL/phone-call)
- 橙色流程:需要更严格的身份验证,苹果推断出可能存在欺诈行为(Apple账户/历史记录),需要上报反欺诈团队严格验证。
- 红色流程:拒绝认证。
苹果Must条款
- 发卡行必须支持应用内绑卡,包括iPad(提供安全/无缝的用户体验)。
- 提交审核资料时,必须附带应用内绑卡的相关视频。
- 不能自定义“Add To Apple Wallet”按钮,否则苹果会拒绝。
- 必须支持远程启用/禁用功能。
- 必须支持推送通知(后台逻辑)。
- 必须支持卡生命周期管理(后台逻辑)。
iOS端接入PassKit
1. 提供原生“Add To Apple Wallet”按钮
因为苹果不支持自定义按钮,所以需要把原生按钮,桥接到Flutter/RN,具体代码不再展示,遵守其规则就行。
2. 是否展示“Add To Apple Wallet”按钮
RN写在了RNApplePayService,Flutter写在了ApplePayFlutterService中,功能包括4个:
- 是否需要展示按钮
- 是否包含此卡
- 卡片激活状态
- 开始绑卡流程
//RNApplePayService
//MARK: 获取AppleWallet状态: 0.不支持 1.已绑定完成(iPhone/当前连接iWatch) 2.可绑定
@objc public func appleWalletState(_ primaryAccountIdentifier: String,
_ resolve: RCTPromiseResolveBlock,
_: RCTPromiseRejectBlock) {
/// 检查是否应该显示添加到Wallet按钮
/// @param primaryAccountIdentifier 账户标识
guard PKAddPaymentPassViewController.canAddPaymentPass() else {
print("客户端不能进行ApplePay的设备卡加载")
resolve(0)
return
}
// 从服务器缓存取applePaySwitch状态
if (!SingleInstanceSettings.applePaySwitch) {
resolve(0)
return
}
// 从SDK取结果
let library = PKPassLibrary()
let can = library.canAddPaymentPass(withPrimaryAccountIdentifier: primaryAccountIdentifier)
resolve(can ? 2 : 1)
}
2. 绑卡流程
import Foundation
import PassKit
import RxSwift
import XCGLogger
public typealias BankAddToWalletCallback = (_ finished: Bool, _ error: NSError?) -> Void
public class BankApplePayUtil: NSObject, PKAddPaymentPassViewControllerDelegate {
public var callback: BankAddToWalletCallback?
@objc public var cardNo = "" // 需要用户传入
@objc public func addToWalletStart() {
BankApplePayAPI.fetchPaymentConfiguration(cardNo: cardNo) { [weak self] result, error in
guard let params = result else {
XCGLogger.default.info("接口返回有误,请检查:\(error ?? "")")
return
}
guard let config = self?.parseConfig(params) else {
XCGLogger.default.info("生成PKAddPaymentPassRequestConfiguration失败")
return
}
// 主线程调用UI
DispatchQueue.main.async {
guard let addPaymentVC = PKAddPaymentPassViewController(requestConfiguration: config, delegate: self) else {
XCGLogger.default.info("AddPaymentVC生成失败,请检查!")
return
}
if #available(iOS 13.0, *) {
addPaymentVC.overrideUserInterfaceStyle = .light
}
self?.topVC?.present(addPaymentVC, animated: true, completion: nil)
}
}
}
private var topVC: UIViewController? {
var controller = UIApplication.shared.keyWindow?.rootViewController
if let rootVC = controller {
var presentedController = rootVC.presentedViewController
if let presentVC = presentedController, !presentVC.isBeingDismissed {
controller = presentedController
presentedController = controller?.presentedViewController
}
return controller
}
return controller
}
private func parseConfig(_ params: [String: Any]) -> PKAddPaymentPassRequestConfiguration? {
guard let config = PKAddPaymentPassRequestConfiguration(encryptionScheme: .ECC_V2) else {
XCGLogger.default.info("PKAddPaymentPassRequestConfiguration生成失败!")
return nil
}
if #available(iOS 12.0, *) {
config.style = .payment
}
config.cardholderName = params["cardHolderName"] as? String
config.paymentNetwork = PKPaymentNetwork(params["paymentNetwork"] as? String ?? "Visa")
config.primaryAccountSuffix = params["primaryAccountNumberSuffix"] as? String
config.primaryAccountIdentifier = params["primaryAccountIdentifier"] as? String
config.localizedDescription = params["localizedDescription"] as? String
return config
}
// MARK: PKAddPaymentPassViewControllerDelegate
/// 向发卡方提供证书链、nOnce, nOnceSignature等信息
/// 重要:回调20s未执行, 则会视为失败
/// - Parameters:
/// - controller: VC
/// - certificates: 证书链
/// - nonce: nonce
/// - nonceSignature: nonceSignature
/// - handler:
/// - activationData: ⼀次性加密OTP,⽤于确保加载请求的安全合法,由发卡方生成和验证(可省略)
/// - encryptedPassData: 数据加密后的JSON⽂件
/// - ephemeralPublicKey: ECC算法使用,发卡方生成的随机公钥
/// - wrappedKey
public func addPaymentPassViewController(_: PKAddPaymentPassViewController,
generateRequestWithCertificateChain certificates: [Data],
nonce: Data,
nonceSignature: Data,
completionHandler handler: @escaping (PKAddPaymentPassRequest) -> Void) {
// Data -> String
func stringfy(_ data: Data) -> String {
return data.base64EncodedString()
}
BankApplePayAPI.fetchPaymentData(cardNo: cardNo,
certificates: certificates.map { stringfy($0) },
nonce: stringfy(nonce),
nonceSignature: stringfy(nonceSignature)) { result, error in
guard let params = result else {
XCGLogger.default.info("接口返回有误,请检查:\(error ?? "")")
return
}
guard let data = params["encryptedPassData"] as? String,
let key = params["ephemeralPublicKey"] as? String,
let otp = params["activationData"] as? String else {
XCGLogger.default.info("接口返回参数有误"); return
}
let encryptedPassData = Data(base64Encoded: data)
let ephemeralPublicKey = Data(base64Encoded: key)
let activationData = Data(base64Encoded: otp)
let request = PKAddPaymentPassRequest()
request.activationData = activationData
request.encryptedPassData = encryptedPassData
request.ephemeralPublicKey = ephemeralPublicKey
handler(request)
}
}
/// 加载完成结果
/// - Parameters:
/// - controller: VC
/// - pass: 申请得到的pass
/// - error: 失败参数
public func addPaymentPassViewController(_ controller: PKAddPaymentPassViewController, didFinishAdding pass: PKPaymentPass?, error: Error?) {
XCGLogger.default.info("\(error?.localizedDescription)")
controller.dismiss(animated: true, completion: nil)
if let c = self.callback {
if pass != nil {
c(true, nil)
} else if error != nil {
c(false, NSError.init(domain: error!.domain, code: error!.code, userInfo: nil))
}
}
}
}
public class BankApplePayAPI: NSObject {
/// 查询支付Configuration
/// @param cardNum 卡号
public static func fetchPaymentConfiguration(cardNo: String,
callback: @escaping (_ result: [String: Any]?, _ error: String?) -> Void) {
var bag: DisposeBag? = DisposeBag()
APIFetch.fetch(host: host,
path: path,
parameters: ["cardNumber": cardNo],
options: nil,
method: Post,
disposeBag: bag!)
.subscribe(onNext: { json in
guard let dict = json as? [String: Any] else {
callback(nil, "返回字段非字典类型,请检查"); return
}
callback(dict["value"] as? [String: Any], nil)
}, onError: { error in
callback(nil, error.localizedDescription)
}) {
bag = nil; print("清理")
}.disposed(by: bag!)
}
/// 查询支付数据
/// @param cardNumber 卡号
/// @param certificates 证书文件的base64字符串 0 叶子证书 1 中级证书 2 root证书 (这个没有可不传入)
/// @param nonce 随机数
/// @param nonceSignature 加密后随机数
/// /mb/nmm33g/debit-card/apple/encrypt
public static func fetchPaymentData(cardNo: String,
certificates: [String],
nonce: String,
nonceSignature: String,
callback: @escaping (_ result: [String: Any]?, _ error: String?) -> Void) {
var bag: DisposeBag? = DisposeBag()
BankFetch.fetch(host: host,
path: path,
parameters: [
"cardNumber": cardNo,
"certificates": certificates,
"nonce": nonce,
"nonceSignature": nonceSignature,
],
options: nil,
method: Post,
disposeBag: bag!)
.subscribe(onNext: { json in
guard let dict = json as? [String: Any] else {
callback(nil, "返回字段非字典类型,请检查"); return
}
callback(dict["value"] as? [String: Any], nil)
}, onError: { error in
callback(nil, error.localizedDescription)
}) {
bag = nil; print("清理")
}.disposed(by: bag!)
}
}
如何测试?
测试必须是production环境。
苹果有文档指出可以以下3种方式:
1. Sandbox
2. TestFlight
但苹果一直强调,sandbox不稳定,推荐TestFlight。Visa方不支持sandbox,只有线上环境。所以我们选择直接在TestFlight测试。
3. AppStore
苹果推荐,在TestFlight通过后,上线时在AppStore验证。
注意
- TestFlight测试,ios最低版本必须选择>=10.3,否则调不起in-app流程。
- 出现绑卡失败问题,需要提供机器的SEID给苹果,苹果可以协助查找原因。
遇到的问题点
1. PKAddPaymentPassViewController返回nil,无法调起in-app流程
- 首先,苹果要给开通In-app权限,需要在develop.apple.com中,编辑并勾选权限关联到证书中。
- 需要在Xcode中配置entitlements文件,添加com.apple.developer.payment-pass-provisioning为YES
- TestFlight测试,ios最低版本必须选择>=10.3,否则调不起来。
2. 调起in-app后,绑卡失败
这个问题点就多了,大多失败在Visa及FD,我简单总结几点
- App是否开了代理。苹果能检测到抓包,直接报网络失败。我调试时是先切抓包,map显示卡tab,然后切非抓包网络,点击进入in-app流程。
- 白名单(卡ID + SEID)
- 银行后台准备JSON字段错误
- 银行后台加密存在错误(苹果加密一层,Visa加密一层。Bank传给苹果,苹果解密后发给Visa,Visa解密后拿到初始JSON)(1. ephemeralPublicKey 65bytes 2. ephemeralPublicKey需要转为Hex)
- 需要在TestFlight测试,并且testFlight要求iOS >= 10.3(很奇怪,上线却只要求>=9.0)
3. 绑卡后,PKPassLibrary().passes()找不到对应卡片
检查VCMM(Visa提供的录入用户数据的平台)上associatedApplicationIdentifiers字段,他是由两段组成“teamID.bundleId”,需要填入App对应的数据。如果填错,PKPassLibrary内方法将返回不准确,及拿不到passes。
4. 绑卡后,PKPassLibrary().canAddPaymentPass仍返回true
同上
5. 无法智能提示iPhone或iWatch去绑卡
当设备同时有iPhone及iWatch时,如果我的iPhone已经添加绑定,此时再次点击“Add To Apple Wallet”按钮,希望直接走iWatch的绑定(而不是出现选择界面)。
检查PKAddPaymentPassRequestConfiguration的primaryAccountIdentifier配置,是否有传入DPAN值,它就是苹果用来筛选设备的。我们就是因为后台返回了空导致filter无效。
6. Flutter中,“Add To Apple Wallet”的桥接UI覆盖住了Alert
Flutter的绘制原理,就是在bitmap上绘制合成完成,才进行渲染。对于原生来说,无论你Flutter在哪个页面,页面包含多少元素,在原生调试就是薄薄的一层界面。
而我们的首页Alert也是Flutter实现的,所以原生按钮无法被夹心,浮在了Alert的上方,导致挡住Alert。
解决办法是:
在初始化FlutterPlatformView时,按钮应在init中实例化,在获取View时直接return此实例。Flutter会自动判断被原生组件盖住的部分,然后再原生层上层再绘制被覆盖的区域,看上去仿佛原生被夹心。
required init(frame: CGRect, viewID: Int64, args: Any?, binaryMessenger: FlutterBinaryMessenger) {
button = PKAddPassButton(addPassButtonStyle: .blackOutline)
}
func view() -> UIView {
return button
}
难点
1. 调试
相比其它需求开发,调试相对是困难的。
- ---。
- release环境,无法抓包,无法调试,很多次都通过上TestFlight,Toast报调试信息。
- 测试必须发到TestFlight,我使用Adhoc证书试,都不可以。
- 想要有卡table入口,必须加白名单。
- 无测试环境,只能发布生产验证bug,及bug修改后是否修复。
2. 英文沟通
无论是苹果还是FD,对话都是英语。
苹果给出的官方文档,是全部英文的; 挺多次的视频通话是全英文,很考验听力; 出问题时,咨询苹果也要英文对话,全靠大能的谷歌翻译。