Day06 - [丰收款] 安全签章签下去,API呼叫没在怕

在昨天讲完了Message的密文产生细节流程後,回过头来我再来谈Sign安全签章,我认为这顺序比较容易理解。其实这整段API的呼叫,可分为几个要素:

  • 沟通真正的主体:讯息内文
  • 机密目标1: 不可被有心人士拦截讯息;即使被拦截了,也要确保讯息内容不被解析。
    若无法达成,会造成以下问题:
    • 机敏讯息被他人取得
    • 讯息被神不知鬼不觉地篡改与重送
      解决方法:将讯息内文进行加密
  • 机密目标2: 若不幸讯息被拦截、破解、篡改或送重,要能够有机制验证与识别出来。
    • 需将讯息内文以及上述作AES加密的AES KeyIV产生依据 (也就是并非直接拿金钥或IV来运用),由API端讲定规则,将上述要素透过安全杂凑的演算法产生数位签章。

接收端如何作验证?

由传送端(商家)依规则与重要元素产生的数位签章,
当商家呼叫API时,先前已经经由AES对称加密法产生出密文Message,因此接收端(永丰API)当然有与加密相同的AES Key来对密文作解密。因此接收端目前可从中取出讯息内文的明文。

接着,永丰API会另外收到一个安全签章Sign,还记得当初是如何产生这个Sign的方法吗?
再复习一次:「需将讯息内文以及上述作AES加密的AES KeyIV产生依据 (也就是并非直接拿金钥或IV来运用),由API端讲定规则,将上述要素透过安全杂凑的演算法产生数位签章。」

刚刚已经从AES解密拿回讯息内文了,那剩下的产生依据,既然当初这些都是永丰提供的,他们当然都有这些值。只需要依据讲好的规则,再运作一次,然後「比对这个刚刚做出来的Sign和商家传来的Sign是否相同」即可。(因为杂凑演算法是不可逆推的,因此只需比对结果是否一样就好)

当然,其中还会有机制去多比对Nonce值是否在效期内,以及是否被重覆使用过,甚至判断是否为商家的IP等。这些上面说明的所有作法的目的,都是为了验证一件事:
「即使API收到了一个密文,也顺利解开,看起来也很合理的内容,到底是不是该商家传来的」

但有一个很重要的前提是,商家有好好保护自己的机敏讯息:那4个需要拼了命保护不可泄漏的Hash代码。
虽然商店编号(ShopNo)也很重要,因为有这个就可以透过API问到Nonce值,但我相信正式的运作机制下,在申请服务的初期就会绑定好商家主机固定IP。因此即使其他人乱猜中商店编号,无法从正确的IP位置发送讯息是会被拒绝的。

说了这麽多,来实作吧

Sign的制作材料会需要三个:

  • 讯息内文
  • Nonce
  • Hash ID

最後把上述三个黏起来後再做一次SHA256

「讯息内文」需要加工一下

这个讯息内文不是直接把我们打算要传的JSON格式直接传出去,而是要符合以下规格:

  • 先移除所有空值的参数,参数值前後不可有空白。
  • 将剩余所有参数值依照「参数名称」由小至大排序 (不分大小写即 A<B and a <B ) ),组成如param1=value1&param2=value2 的字串。
  • 如为多节点参数则不参与 sign 值演算 。

https://ithelp.ithome.com.tw/upload/images/20210919/20130354dF1ZoqgKBU.png

所以这张规格书的内文JSON范例的黄色部份,就是依据上面规则後需要放入的属性与值。
没标黄色的为「空值」或是「多节点参数」,没有入场券,因此他们就不要放进去了。

我们一样先以开发规格书中的范例内容进行处理,先确保写出来的Python程序可以达成和他一样的结果。
这里面的处理有一些地方需要慢慢处理与尝试,光完成这些步骤就花了一点时间,先看一下完整的Python Code:

import urllib

shop_data = {
    "ShopNo": "BA0026_001", "OrderNo": "A201804270001", "Amount": 50000, "CurrencyID": "TWD", "PayType": "A",
    "ATMParam": { "ExpireDate": "20180502" },
    "CardParam": { },
    "ConvStoreParam": { }, "PrdtName": "虚拟帐号订单", "ReturnURL": "http://10.11.22.113:8803/QPay.ApiClient/Store/Return", "BackendURL": "http://10.11.22.113:8803/QPay.ApiClient/AutoPush/PushSuccess"
}

print("* The original dictionary : {}".format(shop_data))
# Output: * The original dictionary : {'ShopNo': 'BA0026_001', 'OrderNo': 'A201804270001', 'Amount': 50000, 'CurrencyID': 'TWD', 'PayType': 'A', 'ATMParam': {'ExpireDate': '20180502'}, 'CardParam': {}, 'ConvStoreParam': {}, 'PrdtName': '虚拟帐号订单', 'ReturnURL': 'http://10.11.22.113:8803/QPay.ApiClient/Store/Return', 'BackendURL': 'http://10.11.22.113:8803/QPay.ApiClient/AutoPush/PushSuccess'}

def check_passed_rule_param(value):
    if value is None:
        return False
    elif type(value) is dict or type(value) is list:
        return False
    elif type(value) is str and not value.strip():
        return False
    else:
        return True

sorted_shop_datat = {key: shop_data.get(key) for key in sorted(shop_data.keys(), key=str.casefold)}
print("* sorted_shop_datat: {}".format(sorted_shop_datat))
# Output: * sorted_shop_datat: {'Amount': 50000, 'ATMParam': {'ExpireDate': '20180502'}, 'BackendURL': 'http://10.11.22.113:8803/QPay.ApiClient/AutoPush/PushSuccess', 'CardParam': {}, 'ConvStoreParam': {}, 'CurrencyID': 'TWD', 'OrderNo': 'A201804270001', 'PayType': 'A', 'PrdtName': '虚拟帐号订单', 'ReturnURL': 'http://10.11.22.113:8803/QPay.ApiClient/Store/Return', 'ShopNo': 'BA0026_001'}

removed_rule_values_shop_data = {key: value for key, value in sorted_shop_datat.items() if check_passed_rule_param(value)}
print("* removed_rule_values_shop_data: {}".format(removed_rule_values_shop_data))
# Output: * removed_rule_values_shop_data: {'Amount': 50000, 'BackendURL': 'http://10.11.22.113:8803/QPay.ApiClient/AutoPush/PushSuccess', 'CurrencyID': 'TWD', 'OrderNo': 'A201804270001', 'PayType': 'A', 'PrdtName': '虚拟帐号订单', 'ReturnURL': 'http://10.11.22.113:8803/QPay.ApiClient/Store/Return', 'ShopNo': 'BA0026_001'}

urlparam = urllib.parse.urlencode(removed_rule_values_shop_data)
print("* urlparam: {}".format(urlparam))
# Output: * urlparam: Amount=50000&BackendURL=http%3A%2F%2F10.11.22.113%3A8803%2FQPay.ApiClient%2FAutoPush%2FPushSuccess&CurrencyID=TWD&OrderNo=A201804270001&PayType=A&PrdtName=%E8%99%9B%E6%93%AC%E5%B8%B3%E8%99%9F%E8%A8%82%E5%96%AE&ReturnURL=http%3A%2F%2F10.11.22.113%3A8803%2FQPay.ApiClient%2FStore%2FReturn&ShopNo=BA0026_001

urlparam_no_percent_encode = urllib.parse.unquote(urlparam).replace("+", " ")
print("* urlparam_no_percent_encode: {}".format(urlparam_no_percent_encode))
# Output: * urlparam_no_percent_encode: Amount=50000&BackendURL=http://10.11.22.113:8803/QPay.ApiClient/AutoPush/PushSuccess&CurrencyID=TWD&OrderNo=A201804270001&PayType=A&PrdtName=虚拟帐号订单&ReturnURL=http://10.11.22.113:8803/QPay.ApiClient/Store/Return&ShopNo=BA0026_001

程序说明

几个地方需要处理:

  • 不分大小写,将JSON中的属性(Key)的部份作排序(A->Z)
  • 仅保留符合规则的属性
  • 将JSON中的属性键值转成类似URL的参数方式相接呈现
  • 使用URLEncode模组转出会以URI percent-encoding rules方式呈现属性值(会加上百分比符号开头),要再转换回UTF-8编码(主要是让中文正确显示)

1. 忽略大小写的重新排序
将原面JSON的物件的Key值作sorted(),以字串方式排序,但要加上str.casefold才会忽略大小写的ASCII值。先取得排序过後的Key List後,再使用Dictionary Comprehension方法把排序过後的物件(以Dictionary结构组成)产生回来,如此一来我们就拿到了属性排序过後的JSON。

2. 去掉不要的属性
我们先准备好一个规则过滤的布林function,主要是用在Dictionary Comprehension後的if条件式。意思就是我们把原本已排序好的新Dictionary重新跑一遍,只会留下规则过滤器结果为True的值。所以我们就会拿到那些没有「多节点」或「空值」的内文版本。

3. 使用URLEncode模组帮忙转出
透过好用的urllib.parse.urlencode()就可以将Dictionary转出成URL的参数表示法。但这里转出後会将非英数字的符号或编码转成以百分比开头的URI percent-encoding rules编码文字。

4. 把文字编码的呈现再最後调整一下
永丰文件中是会希望保留原始带有类似中文的内文去做後续杂凑运算,因此我们需要再加一点工。透过urllib.parse.unquote()方法将百分比编码法则再转回原本的编码。另外若原本参数里面(不是前後)有空白的话,转出来的url参数会将空白变成+,因此要再换回来一次,否则Sign的内容会因此而有所差异。

透过上面的步骤处理,我们就能够取得讯息内文的部份。

後面还有两个跟屁虫

https://ithelp.ithome.com.tw/upload/images/20210920/20130354raQrsODH8h.png

但在开发规格中,把上面最终我们取得的URLEncode这串结果称之为「内文杂凑」,我个人认为这个步骤使用杂凑这个词汇似乎是不太正确。这里没有用到任何杂凑的技术。

接下来还要再做两个相当简单的动作,将是将Nonce和Hash ID拼在上面这一串的後面,但文件中把这三个拼接在一起的产出结果称之为「字串杂凑」,我一样是觉得这个动作也没有杂凑在里面。(难道是杂乱的凑在一起!?)

总之,把这三个再拼接起来後,就可以进行最终的SHA256处理,离成功就在不远处!

import hashlib

nonce = "NjM2NjA0MzI4ODIyODguMzo3NzI0ZDg4ZmI5Nzc2YzQ1MTNhYzg2MTk3NDBlYTRhNGU0N2IxM2Q2M2JkMTIwOGU5YzZhMGFmNGY5MjA5YzVm"
hash_id = "17D8E6558DC60E702A6B57E1B9B7060D"

final_shop_data = "{}{}{}".format(urlparam_no_percent_encode, nonce, hash_id)
print(final_shop_data)
# Output: Amount=50000&BackendURL=http://10.11.22.113:8803/QPay.ApiClient/AutoPush/PushSuccess&CurrencyID=TWD&OrderNo=A201804270001&PayType=A&PrdtName=虚拟帐号订单&ReturnURL=http://10.11.22.113:8803/QPay.ApiClient/Store/Return&ShopNo=BA0026_001NjM2NjA0MzI4ODIyODguMzo3NzI0ZDg4ZmI5Nzc2YzQ1MTNhYzg2MTk3NDBlYTRhNGU0N2IxM2Q2M2JkMTIwOGU5YzZhMGFmNGY5MjA5YzVm17D8E6558DC60E702A6B57E1B9B7060D

sign = hashlib.sha256(final_shop_data.encode('UTF-8')).hexdigest().upper()
print(sign)
# Output: A3EAEE3B361B7E7E9B0F6422B954ECA5D54CEC6EAB0880CB484AA6FDA4154331
程序说明

这边要处理的事情其实蛮简单的,把许久不见的NonceHash ID再请出来,然後字串黏一黏之後,用我们先前做过要产生Hash ID时的SHA256一样,依样画葫芦,就可以产生出最後的大魔王Sign了!

终於把API前置所需准备好了

为了要完成之後的每一次API叫用,我们一步一步把所需要的属性都一个一个验证且实作出来了,最困难的当然就是MessageSign,接下来我们就可以使用我们自己产生的资料,来正式对永丰API作呼叫罗!


<<:  【Day 06】C 的资料型态(下)

>>:  Day 21 Azure machine learning: Upload data- 自己的资料自己传

[Angular] Day25. Reactive forms (一)

上一篇中介绍完 Template-driven forms 後,接着再介绍 Angular 的另一种...

Day-11 priority queue

Priority queue Priority queue和queue一样也有两种形式 : max ...

[DAY 7] Spring Boot 启动原理

在昨天了解如何建立专案後,今天来说明SPring Boot的启动原理 在不导入第三方library的...

Day 22 - 物理模拟篇 - 二维布料模拟 - 成为Canvas Ninja ~ 理解2D渲染的精髓

时间过得很快,这边我们已经来到物理模拟篇的最後一节 ~ 二维布料模拟了。 原本其实我是打算把这一篇放...

Day 14:第三方 SDK / API

前言 我们经常会在求职网上看到需要某 SDK、API 的串接经验,我们应该也要做相关功课,才能理解这...