ApplePay In-App Provisioning记录

[TOC]

In-App是什么?

    全称:Apple Pay In-App Provisioning。就是在应用内配置信息,把用户的银行卡直接绑定到用户手机的Apple Wallet中,而不需要用户手动输入信息,提供了良好的用户体验。无需跳出应用,直接把用户信息通过Passkit SDK提供给苹果,达到绑卡的目的。 starts and finishes 整个流程都是在App中。

    我们要做的,也就是ApplePay的应用内绑卡功能。和使用AppleWallet绑卡是一样的,绑卡后可以通过ApplePay购物支付等。

绑卡流程

关键角色

  1. Bank Client:对接用户
  2. Bank Server:提供用户/卡信息
  3. PKPass:提供Apple Wallet相关查询/绑卡接口
  4. Apple Server:接收PKPass数据
  5. Visa:移动支付运营商(PNO:Payment Network Operator)。
  6. FD:卡信息提供商,负责发卡

解释一下Visa与FD他们的区别:

Visa是支付运营商,他们定义了一套支付的协议,不同的银行可以对接其协议,达成支付能力。他们会给每个银行一个编号,支付过程首先流转到Visa,再根据编号识别属于什么银行,然后做数据流转。

类似的还有美国运通,

FD是卡信息提供商,Bank的卡片生成也是FD生成的,包括cvv2,日期,卡号等信息。支付过程中卡信息的确认也发生在FD。

Bank银行负责记录账户与卡之间的关系,卡余额也是记录在Bank方。

关键短句

  1. DPAN:Device Primary Account Number 设备主账号(与银行卡号唯一对应的一串号码,如:V-999916888641233333222)
  2. FPAN:Funding Primary Account Number 资金主账号(银行卡号)
  3. SEID:苹果手机的一个序列号(NFC模块的序列号)
  4. ECC:Elliptic Curve Cryptography(椭圆曲线加密),苹果绑卡就是经过ECC-V2加密传输的。
  5. PNO:Payment Network Operator,支付网络运营商,我们目前PNO就是Visa。
  6. regular provisioning flow:常规认证流程。无论是苹果支付,或华为/小米/谷歌,有的只是加解密过程不一样,最终解密完成之后的拿到明文Payload,执行绑定操作。

绑卡流转流程

image

苹果文档上介绍的,是单独的绑卡过程。下面讲述的,是我们Bank绑卡的实际流程,包括绑卡入口的判定。

1. Bank App判断是否显示绑卡按钮。

想要显示绑卡入口,需要满足两点:

  1. 设备及系统支持。(iphone6 ios9.0+)
  2. Bank的绑卡功能开关。Bank添加了开关功能,基于此项开的情况下,才去判断PKPassLibrary。
  3. 未被添加到当前设备(连接iwatch的情况下,需要两者都被绑定才会不显示)。

App向Bank后台请求ApplePay相关数据,根据拿到的DPAN,放到PKPassLibrary的canAddPaymentPass接口判断是否已绑定,如已绑定则不展示。

2. Bank用户点击Add To Apple Wallet按钮,触发绑卡流程。

首先,Bank App会向Bank后台发起一个请求,后台返回启动绑卡的config信息,包含的关键信息有:

  1. cardHolderName 持卡人姓名
  2. paymentNetwork 支付运营商
  3. primaryAccountNumberSuffix 主账户后4位
  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":"xxx626", "encryptedPrimaryAccountNumber":"TUJQxxxxxx1GSy0xxwNjQuMS0tVERFQS1BOEZFOEVGRTdFNzlFN",
"nonceSignature":"xxx089C255A06ExxxF1702BA74715D9xxx1C5CBD7xxxx90A6F06B94ED67D231765D", "networkName":"Visa",
"name":"xxxxxxxxxleseed",
"nonce":"0aa6xxx98"
}

绿色/橙色流程

对于每个绑卡认证请求,苹果都会给出对应的风险等级建议。

  • 绿色流程:大部分苹果给出的建议都是绿色流程,可以直接认证绑卡成功,不需要其它用户身份验证
  • 黄色流程:必须先验证身用户身份,由发卡行提供验证选项,具体方式可以通过(SMS/EMAIL/phone-call)
  • 橙色流程:需要更严格的身份验证,苹果推断出可能存在欺诈行为(Apple账户/历史记录),需要上报反欺诈团队严格验证。
  • 红色流程:拒绝认证

苹果Must条款

  1. 发卡行必须支持应用内绑卡,包括iPad(提供安全/无缝的用户体验)。
  2. 提交审核资料时,必须附带应用内绑卡的相关视频。
  3. 不能自定义“Add To Apple Wallet”按钮,否则苹果会拒绝。
  4. 必须支持远程启用/禁用功能。
  5. 必须支持推送通知(后台逻辑)。
  6. 必须支持卡生命周期管理(后台逻辑)。

iOS端接入PassKit

1. 提供原生“Add To Apple Wallet”按钮

因为苹果不支持自定义按钮,所以需要把原生按钮,桥接到Flutter/RN,具体代码不再展示,遵守其规则就行。

2. 是否展示“Add To Apple Wallet”按钮

RN写在了RNApplePayService,Flutter写在了ApplePayFlutterService中,功能包括4个:

  1. 是否需要展示按钮
  2. 是否包含此卡
  3. 卡片激活状态
  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验证。

注意

  1. TestFlight测试,ios最低版本必须选择>=10.3,否则调不起in-app流程。
  2. 出现绑卡失败问题,需要提供机器的SEID给苹果,苹果可以协助查找原因。

遇到的问题点

1. PKAddPaymentPassViewController返回nil,无法调起in-app流程

  1. 首先,苹果要给开通In-app权限,需要在develop.apple.com中,编辑并勾选权限关联到证书中。
  2. 需要在Xcode中配置entitlements文件,添加com.apple.developer.payment-pass-provisioning为YES
  3. TestFlight测试,ios最低版本必须选择>=10.3,否则调不起来。

2. 调起in-app后,绑卡失败

这个问题点就多了,大多失败在Visa及FD,我简单总结几点

  1. App是否开了代理。苹果能检测到抓包,直接报网络失败。我调试时是先切抓包,map显示卡tab,然后切非抓包网络,点击进入in-app流程。
  2. 白名单(卡ID + SEID)
  3. 银行后台准备JSON字段错误
  4. 银行后台加密存在错误(苹果加密一层,Visa加密一层。Bank传给苹果,苹果解密后发给Visa,Visa解密后拿到初始JSON)(1. ephemeralPublicKey 65bytes 2. ephemeralPublicKey需要转为Hex)
  5. 需要在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. 调试

相比其它需求开发,调试相对是困难的。

  1. ---。
  2. release环境,无法抓包,无法调试,很多次都通过上TestFlight,Toast报调试信息。
  3. 测试必须发到TestFlight,我使用Adhoc证书试,都不可以。
  4. 想要有卡table入口,必须加白名单。
  5. 无测试环境,只能发布生产验证bug,及bug修改后是否修复。

2. 英文沟通

无论是苹果还是FD,对话都是英语。

苹果给出的官方文档,是全部英文的; 挺多次的视频通话是全英文,很考验听力; 出问题时,咨询苹果也要英文对话,全靠大能的谷歌翻译。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 142,461评论 1 300
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 61,089评论 1 256
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 94,078评论 0 212
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 40,938评论 0 175
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 48,698评论 1 255
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 38,733评论 1 175
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 30,340评论 2 268
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 29,138评论 0 165
  • 想象着我的养父在大火中拼命挣扎,窒息,最后皮肤化为焦炭。我心中就已经是抑制不住地欢快,这就叫做以其人之道,还治其人...
    爱写小说的胖达阅读 28,949评论 6 229
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 32,493评论 0 213
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 29,269评论 2 214
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 30,591评论 1 229
  • 白月光回国,霸总把我这个替身辞退。还一脸阴沉的警告我。[不要出现在思思面前, 不然我有一百种方法让你生不如死。]我...
    爱写小说的胖达阅读 24,216评论 0 31
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 27,107评论 2 213
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 31,485评论 3 204
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,615评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,006评论 0 167
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 33,496评论 2 230
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 33,572评论 2 230