wop-python-sdk 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wop_python_sdk-0.1.0.dist-info/METADATA +139 -0
- wop_python_sdk-0.1.0.dist-info/RECORD +21 -0
- wop_python_sdk-0.1.0.dist-info/WHEEL +5 -0
- wop_python_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- wop_python_sdk-0.1.0.dist-info/top_level.txt +1 -0
- wop_sdk/__init__.py +39 -0
- wop_sdk/canonical.py +47 -0
- wop_sdk/client.py +258 -0
- wop_sdk/digest.py +64 -0
- wop_sdk/encoding.py +74 -0
- wop_sdk/envelope.py +165 -0
- wop_sdk/errors.py +48 -0
- wop_sdk/keys.py +105 -0
- wop_sdk/signature.py +57 -0
- wop_sdk/sm2crypto.py +108 -0
- wop_sdk/sm4gcm.py +86 -0
- wop_sdk/suites.py +79 -0
- wop_sdk/transports/__init__.py +39 -0
- wop_sdk/transports/httpx_transport.py +37 -0
- wop_sdk/transports/requests_transport.py +37 -0
- wop_sdk/transports/urllib_transport.py +29 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wop-python-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: WOP 商户侧官方 Python SDK:协议核心(签名/摘要/数字信封/验签解密)+ 可插拔 HTTP 适配层
|
|
5
|
+
Author: wop-platform
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/wop-platform/wop-python-sdk
|
|
8
|
+
Keywords: wop,gateway,sm2,sm4-gcm,rsa,digital-envelope
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Security :: Cryptography
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: cryptography>=41
|
|
24
|
+
Requires-Dist: gmssl>=3.2.2
|
|
25
|
+
Provides-Extra: httpx
|
|
26
|
+
Requires-Dist: httpx>=0.24; extra == "httpx"
|
|
27
|
+
Provides-Extra: requests
|
|
28
|
+
Requires-Dist: requests>=2.28; extra == "requests"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# WOP Python SDK
|
|
32
|
+
|
|
33
|
+
WOP 网关商户侧官方 Python 客户端库:封装协议核心(套件解析 / 结构化签名 / 内容摘要 /
|
|
34
|
+
L2 数字信封 / 验签解密)与可插拔 HTTP 适配层,使商户无需理解 canonicalRequest、
|
|
35
|
+
算法套件推导与线上字节格式即可安全对接网关。
|
|
36
|
+
|
|
37
|
+
- 协议真源:[crypto-strategy-spec.md](https://github.com/wop-platform/wop-specs/blob/main/crypto/crypto-strategy-spec.md)(v0.3-reviewed)+ [wop-sdk-spec.md](https://github.com/wop-platform/wop-specs/blob/main/sdk/wop-sdk-spec.md)(v1.0-ratified)
|
|
38
|
+
- 向量真源:[crypto-vectors.json](https://github.com/wop-platform/wop-specs/blob/main/crypto/crypto-vectors.json)(本仓 fixture 为字节级副本,禁手改)
|
|
39
|
+
- Python ≥ 3.9
|
|
40
|
+
- 三套件全支持:`WOP-RSA3072-SHA256` / `WOP-RSA4096-SHA256` / `WOP-SM2-SM3`
|
|
41
|
+
- 密码依赖(唯一指定路径):`cryptography`(RSA/AES)+ `gmssl ≥ 3.2.2`(SM2/SM3/SM4)
|
|
42
|
+
- 主包零额外依赖;HTTP 适配器以 peer 依赖交付(`wop-python-sdk[httpx]` / `wop-python-sdk[requests]`)
|
|
43
|
+
|
|
44
|
+
## 快速开始
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install wop-python-sdk # 或从源码:pip install -e .
|
|
48
|
+
pip install 'wop-python-sdk[httpx]' # 可选:httpx peer 适配器(另含 requests extras)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from wop_sdk import WopClient, WopConfig
|
|
53
|
+
from wop_sdk.transports import UrllibTransport, send_draft
|
|
54
|
+
|
|
55
|
+
client = WopClient(WopConfig(
|
|
56
|
+
app_key="app_10012481831",
|
|
57
|
+
suite="WOP-RSA3072-SHA256", # 或 WOP-RSA4096-SHA256 / WOP-SM2-SM3
|
|
58
|
+
merchant_private_key=MERCHANT_PRIV_PEM, # 商户私钥(PEM 或 Base64 单行)
|
|
59
|
+
platform_public_key=PLATFORM_PUB_PEM, # 平台公钥(PEM 或 Base64 单行)
|
|
60
|
+
gateway_base_url="https://wop.example.com",
|
|
61
|
+
))
|
|
62
|
+
|
|
63
|
+
# L0 明文请求
|
|
64
|
+
draft = client.build_request("POST", "/gateway/order.create", {"orderId": 42})
|
|
65
|
+
|
|
66
|
+
# 发送(任意 HTTP 栈;此处 stdlib urllib 适配器)
|
|
67
|
+
resp = send_draft(UrllibTransport(), client._config.gateway_base_url, draft)
|
|
68
|
+
|
|
69
|
+
# 校验平台响应(F6 固定顺序:验签 → digest 复核 → DEK 解包 → alg 族比对 → bulk 解密)
|
|
70
|
+
result = client.verify_response(resp.headers, resp.body, "/gateway/order.create")
|
|
71
|
+
if result.ok:
|
|
72
|
+
print(result.plaintext)
|
|
73
|
+
else:
|
|
74
|
+
print(result.reason) # 验签/解密失败对外模糊(I7),格式/完整性/一致性类明确
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## 密钥准备
|
|
78
|
+
|
|
79
|
+
密钥入参为字符串(PEM 或 Base64 单行),SDK 内部解析(D12 分发契约):
|
|
80
|
+
|
|
81
|
+
| 套件 | 商户私钥 | 平台公钥 | 约束 |
|
|
82
|
+
|------|----------|----------|------|
|
|
83
|
+
| `WOP-RSA3072-SHA256` | PKCS#8 DER,Base64/PEM | X.509 SPKI DER,Base64/PEM | 密钥必须 3072 位 |
|
|
84
|
+
| `WOP-RSA4096-SHA256` | 同上 | 同上 | 密钥必须 4096 位 |
|
|
85
|
+
| `WOP-SM2-SM3` | `d` 标量 32 字节,Base64 | 未压缩点 `04‖X‖Y` 65 字节,Base64 | 点必须在 sm2p256v1 曲线上(I5) |
|
|
86
|
+
|
|
87
|
+
- RSA 公钥与私钥均接受 PEM 包装(`-----BEGIN PUBLIC KEY-----`)或裸 Base64;
|
|
88
|
+
- SM2 材料喂给 RSA 套件(或反向)在配置期即拒绝;跨族算法组合(如 `WOP-RSA3072-SM3`)
|
|
89
|
+
在套件解析期拒绝。
|
|
90
|
+
|
|
91
|
+
## L0 / L2 示例
|
|
92
|
+
|
|
93
|
+
### L0(明文,摘要为唯一完整性防线)
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
draft = client.build_request("POST", "/gateway/order.query", {"orderId": 42})
|
|
97
|
+
# 有 body 必产 x-wop-content-digest 且必入 signedHeaders(D2/I1);GET 无 body 则缺席
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### L2(数字信封:AES-256-GCM / SM4-GCM 全文加密)
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
draft = client.build_request("POST", "/gateway/order.create", {"card": "6222..."}, level="L2")
|
|
104
|
+
# wire_body = {"encrypted":"<base64url(ciphertext||tag)>"}
|
|
105
|
+
# x-wop-encrypt: L2;dek=<base64url(OAEP/SM2 包装的 DEK 载荷)>
|
|
106
|
+
# DEK 与 IV 每次调用 CSPRNG 新生成(I4:同一密钥下 IV 永不复用)
|
|
107
|
+
|
|
108
|
+
result = client.verify_response(resp.headers, resp.body, "/gateway/order.create")
|
|
109
|
+
# result.plaintext = 解密后的业务报文
|
|
110
|
+
|
|
111
|
+
# 回调校验(URI 取回调 path,方法恒 POST)
|
|
112
|
+
cb = client.verify_callback(cb_headers, cb_body, "/callback/notify")
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
线上字节格式(F7/D9/D10):全部 base64url **无填充**(严格拒收 `=`);
|
|
116
|
+
RSA 签名 = PKCS#1 v1.5;SM2 签名 = 裸 `r‖s` 64 字节(禁 DER);
|
|
117
|
+
SM2 密文 = `C1C3C2` 裸拼接(C1 = 未压缩点 65B);RSA-OAEP = 显式双 SHA-256 + 空 label。
|
|
118
|
+
|
|
119
|
+
## 向量自测
|
|
120
|
+
|
|
121
|
+
黄金向量 fixture 位于 `tests/fixtures/crypto-vectors.json`(与网关真源字节级一致,
|
|
122
|
+
禁手改)。本地复跑 conformance 套件:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
pip install -e '.[httpx]' coverage
|
|
126
|
+
python3 -m pytest --cov=wop_sdk --cov-branch --cov-fail-under=98
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
覆盖:RSA3072/4096 与 SM2 签名字节级断言、OAEP 包装/解包、AES-256-GCM 与 SM4-GCM
|
|
130
|
+
密文字节级断言、SM3/SHA-256 摘要、DEK 载荷组装、digest 头全部格式规则;负向量含
|
|
131
|
+
tamper / 跨族 / 63B、65B 签名 / 带 `=` 的 base64url / C1C2C3 旧国标顺序 /
|
|
132
|
+
MGF1-SHA1 陷阱密文,全部必须拒绝。CI(3.9–3.14 矩阵)执行同一命令。
|
|
133
|
+
|
|
134
|
+
## 错误处理与模糊化
|
|
135
|
+
|
|
136
|
+
- **明确**(公开协议知识,帮助集成自查):套件格式/跨族、密钥材料、digest 头格式与
|
|
137
|
+
不匹配、DEK alg 与套件族不符;
|
|
138
|
+
- **模糊**(依赖密钥参与,防 oracle,I7):签名验证失败、解密失败——对外消息不区分
|
|
139
|
+
tag 失败 / 密钥不符等原因细节。
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
wop_python_sdk-0.1.0.dist-info/licenses/LICENSE,sha256=9HPK_oKzyRh2oK3x0YiYrE1envRgKMfD1LWgGSStuW8,1069
|
|
2
|
+
wop_sdk/__init__.py,sha256=TKAvYKNbLpjOzkmHpiAiXa6gPmWVE8vdYV165XNUeXM,905
|
|
3
|
+
wop_sdk/canonical.py,sha256=LiWUctDCi71ZeHuTXLCWm_dlMXCxciJLPkgnTnIGM80,1508
|
|
4
|
+
wop_sdk/client.py,sha256=BjISbQ7eIWjHrPcYGUdXafs5Ou4bLxx7a7jSIg8uFRI,10153
|
|
5
|
+
wop_sdk/digest.py,sha256=hm7zXLTQsVsMlrdTw4o2G0HbJBb0dSeVvyl-OUp5dvk,2533
|
|
6
|
+
wop_sdk/encoding.py,sha256=_xFhvErBtm9rwFS1nxLYQX0PriIDQibYmDqDJ9DFzMM,2505
|
|
7
|
+
wop_sdk/envelope.py,sha256=yqSPsBW39As94xux6VsmhabfC2up6ZIyulmGoevqm7o,7241
|
|
8
|
+
wop_sdk/errors.py,sha256=WQs_o-zNRNaxZoOQpPCO9oU47nZ6_2vbK5FJQw1n0AM,1690
|
|
9
|
+
wop_sdk/keys.py,sha256=uEzcoydWFV20eSKCcUEWux_IuI3dCNFrkOD2S1PlzWQ,4343
|
|
10
|
+
wop_sdk/signature.py,sha256=pasDfgPPhbbsDK-5_iG4n2bG5jCI-P8qjzykYCjevJg,2486
|
|
11
|
+
wop_sdk/sm2crypto.py,sha256=IbZHsj8oRSy5sdktSGcYn7sagjngptungn5oS_vrZFQ,4695
|
|
12
|
+
wop_sdk/sm4gcm.py,sha256=I5FViIfsyyFSsog7VYnkwRlTT_NNizFrMss6NRh5cjk,3247
|
|
13
|
+
wop_sdk/suites.py,sha256=ipUumVrVmylvKMzX1JzewCC4bbKVS6btn_4VKp9oG5M,2960
|
|
14
|
+
wop_sdk/transports/__init__.py,sha256=zArPclZXHvZsU73sZh_vMKp9FRp_GZVM4NWq6-J4tT8,1397
|
|
15
|
+
wop_sdk/transports/httpx_transport.py,sha256=nDCCIiRmF2SjQy2Ele_TV9i93JFA3miIoTZhi2oXZLg,1188
|
|
16
|
+
wop_sdk/transports/requests_transport.py,sha256=PBCfN1V6mJlXmcanvVdmSb-2vVv64bJjzgPXJ9fOq30,1212
|
|
17
|
+
wop_sdk/transports/urllib_transport.py,sha256=2MSmKy-8LpV_emh87KOfTAkizy3UtuTE8pLxV-fDxkw,1040
|
|
18
|
+
wop_python_sdk-0.1.0.dist-info/METADATA,sha256=rKrnIkixRxjnFP4h3CrS54QBDrAmzpqSJRwyxby7esM,6484
|
|
19
|
+
wop_python_sdk-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
20
|
+
wop_python_sdk-0.1.0.dist-info/top_level.txt,sha256=TotiKMyRTEgc7yV8T6-Hwjl9NdddNjoBY3miEEKSEyY,8
|
|
21
|
+
wop_python_sdk-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 wop-platform
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
wop_sdk
|
wop_sdk/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""WOP 商户侧官方 Python SDK。
|
|
3
|
+
|
|
4
|
+
协议核心(签名/摘要/数字信封/验签解密,纯函数、零网络 IO)+ 可插拔 HTTP 适配层。
|
|
5
|
+
"""
|
|
6
|
+
from .client import RequestDraft, VerifyResult, WopClient, WopConfig
|
|
7
|
+
from .errors import (
|
|
8
|
+
DecryptError,
|
|
9
|
+
DekConsistencyError,
|
|
10
|
+
DigestMismatchError,
|
|
11
|
+
KeyMaterialError,
|
|
12
|
+
ProtocolFormatError,
|
|
13
|
+
SignatureVerifyError,
|
|
14
|
+
SuiteParseError,
|
|
15
|
+
UnsupportedSuiteError,
|
|
16
|
+
WopSdkError,
|
|
17
|
+
)
|
|
18
|
+
from .suites import Suite, parse_suite
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"WopClient",
|
|
24
|
+
"WopConfig",
|
|
25
|
+
"RequestDraft",
|
|
26
|
+
"VerifyResult",
|
|
27
|
+
"Suite",
|
|
28
|
+
"parse_suite",
|
|
29
|
+
"WopSdkError",
|
|
30
|
+
"SuiteParseError",
|
|
31
|
+
"UnsupportedSuiteError",
|
|
32
|
+
"ProtocolFormatError",
|
|
33
|
+
"KeyMaterialError",
|
|
34
|
+
"DigestMismatchError",
|
|
35
|
+
"SignatureVerifyError",
|
|
36
|
+
"DecryptError",
|
|
37
|
+
"DekConsistencyError",
|
|
38
|
+
"__version__",
|
|
39
|
+
]
|
wop_sdk/canonical.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""canonicalRequest 构造(F2)。
|
|
3
|
+
|
|
4
|
+
结构(5 段 '\\n' 连接,对照网关 CanonicalRequestBuilder):
|
|
5
|
+
|
|
6
|
+
authString\\nHTTPMethod\\ncanonicalURI\\ncanonicalQueryString\\ncanonicalHeaders
|
|
7
|
+
|
|
8
|
+
POST 的 canonicalQueryString 为空字符串(分隔空行不可省略);
|
|
9
|
+
header 值编码 = Java URLEncoder 语义(空格 → %20)。
|
|
10
|
+
"""
|
|
11
|
+
from typing import Dict, Optional
|
|
12
|
+
|
|
13
|
+
from .encoding import java_urlencode, trimall
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def canonical_headers(headers: Optional[Dict[str, str]]) -> str:
|
|
17
|
+
"""规范标头:名称 lowercase+trimall+urlencode,值 trimall+urlencode,
|
|
18
|
+
名称 ASCII 升序,行间 '\\n' 连接,尾行不加 '\\n'。"""
|
|
19
|
+
if not headers:
|
|
20
|
+
return ""
|
|
21
|
+
normalized = {}
|
|
22
|
+
for name, value in headers.items():
|
|
23
|
+
normalized[trimall(name).lower()] = trimall(value)
|
|
24
|
+
return "\n".join(
|
|
25
|
+
"%s:%s" % (java_urlencode(k), java_urlencode(normalized[k]))
|
|
26
|
+
for k in sorted(normalized)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_canonical(
|
|
31
|
+
auth_string: Optional[str],
|
|
32
|
+
method: Optional[str],
|
|
33
|
+
canonical_uri: Optional[str],
|
|
34
|
+
query_string: Optional[str],
|
|
35
|
+
canonical_headers: Optional[str],
|
|
36
|
+
) -> str:
|
|
37
|
+
"""组装规范请求(5 段);None 段落输出空串,保持 5 段结构。"""
|
|
38
|
+
safe_method = (method or "").strip().upper()
|
|
39
|
+
return "\n".join(
|
|
40
|
+
[
|
|
41
|
+
auth_string or "",
|
|
42
|
+
safe_method,
|
|
43
|
+
canonical_uri or "",
|
|
44
|
+
query_string or "",
|
|
45
|
+
canonical_headers or "",
|
|
46
|
+
]
|
|
47
|
+
)
|
wop_sdk/client.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""WopClient:协议核心编排(spec §2 概念 API 的 Python 映射)。
|
|
3
|
+
|
|
4
|
+
- build_request:组装协议头 + 签名 + 可选 L2 信封 → RequestDraft(零网络 IO);
|
|
5
|
+
- verify_response / verify_callback:F6 固定顺序(验签 → digest 复核 → DEK 解包 →
|
|
6
|
+
alg 族比对 → bulk 解密),失败统一 VerifyResult(ok=False, reason),
|
|
7
|
+
验签/解密类 reason 模糊(I7),格式/完整性/一致性类 reason 明确(10.2)。
|
|
8
|
+
"""
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Callable, Dict, Optional
|
|
14
|
+
|
|
15
|
+
from .canonical import build_canonical, canonical_headers
|
|
16
|
+
from .digest import build_digest_header, verify_digest_header
|
|
17
|
+
from .encoding import b64url_encode
|
|
18
|
+
from .envelope import open_l2, seal_l2
|
|
19
|
+
from .errors import (
|
|
20
|
+
DecryptError,
|
|
21
|
+
DekConsistencyError,
|
|
22
|
+
DigestMismatchError,
|
|
23
|
+
ProtocolFormatError,
|
|
24
|
+
SignatureVerifyError,
|
|
25
|
+
SuiteParseError,
|
|
26
|
+
UnsupportedSuiteError,
|
|
27
|
+
WopSdkError,
|
|
28
|
+
)
|
|
29
|
+
from .keys import (
|
|
30
|
+
load_rsa_private_key,
|
|
31
|
+
load_rsa_public_key,
|
|
32
|
+
load_sm2_private_key,
|
|
33
|
+
load_sm2_public_key,
|
|
34
|
+
)
|
|
35
|
+
from .signature import sign, verify
|
|
36
|
+
from .sm2crypto import Sm2Ops, sm2_derive_public_hex
|
|
37
|
+
from .suites import Suite, parse_suite
|
|
38
|
+
|
|
39
|
+
Csprng = Callable[[int], bytes]
|
|
40
|
+
|
|
41
|
+
_LEVELS = ("L0", "L2")
|
|
42
|
+
_DEK_PREFIX = "L2;dek="
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _now_ms() -> int:
|
|
46
|
+
"""毫秒时间戳(F9);独立函数便于测试冻结。"""
|
|
47
|
+
return int(time.time() * 1000)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class WopConfig:
|
|
52
|
+
"""商户接入配置(密钥为材料串:PEM 或 Base64 单行,D12)。"""
|
|
53
|
+
|
|
54
|
+
app_key: str
|
|
55
|
+
suite: str
|
|
56
|
+
merchant_private_key: str
|
|
57
|
+
platform_public_key: str
|
|
58
|
+
gateway_base_url: Optional[str] = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class RequestDraft:
|
|
63
|
+
"""出向请求草稿:headers + wireBody,商户可直接交给任意 HTTP 栈。"""
|
|
64
|
+
|
|
65
|
+
method: str
|
|
66
|
+
path: str
|
|
67
|
+
headers: Dict[str, str]
|
|
68
|
+
wire_body: Optional[bytes]
|
|
69
|
+
level: str
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class VerifyResult:
|
|
74
|
+
"""响应/回调校验结果;reason 对验签/解密类模糊(I7)。"""
|
|
75
|
+
|
|
76
|
+
ok: bool
|
|
77
|
+
plaintext: Optional[bytes] = None
|
|
78
|
+
reason: Optional[str] = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class WopClient:
|
|
82
|
+
"""协议核心客户端(纯函数式产出,无连接状态)。"""
|
|
83
|
+
|
|
84
|
+
def __init__(self, config: WopConfig, csprng: Csprng = os.urandom):
|
|
85
|
+
if not config.app_key or not config.app_key.strip():
|
|
86
|
+
raise WopSdkError("appKey 不能为空")
|
|
87
|
+
self._config = config
|
|
88
|
+
self._suite = parse_suite(config.suite)
|
|
89
|
+
if self._suite.family == "RSA":
|
|
90
|
+
self._signer = load_rsa_private_key(
|
|
91
|
+
config.merchant_private_key, self._suite.key_bits
|
|
92
|
+
)
|
|
93
|
+
self._wrap_pub = load_rsa_public_key(
|
|
94
|
+
config.platform_public_key, self._suite.key_bits
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
d = load_sm2_private_key(config.merchant_private_key)
|
|
98
|
+
merchant_pub_hex = sm2_derive_public_hex(d.hex())
|
|
99
|
+
self._signer = Sm2Ops(private_key_hex=d.hex(), public_xy_hex=merchant_pub_hex)
|
|
100
|
+
platform_pub = load_sm2_public_key(config.platform_public_key)
|
|
101
|
+
self._wrap_pub = Sm2Ops(public_xy_hex=platform_pub.xy_hex)
|
|
102
|
+
self._csprng = csprng
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def suite(self) -> Suite:
|
|
106
|
+
return self._suite
|
|
107
|
+
|
|
108
|
+
# ---------- 出向 ----------
|
|
109
|
+
|
|
110
|
+
def build_request(
|
|
111
|
+
self,
|
|
112
|
+
method: str,
|
|
113
|
+
path: str,
|
|
114
|
+
body: Optional[object] = None,
|
|
115
|
+
*,
|
|
116
|
+
level: str = "L0",
|
|
117
|
+
query_string: str = "",
|
|
118
|
+
expired_seconds: int = 1800,
|
|
119
|
+
extra_headers: Optional[Dict[str, str]] = None,
|
|
120
|
+
) -> RequestDraft:
|
|
121
|
+
"""构造请求(F9):协议头组装 → L2 可选封装 → canonicalRequest → 签名。"""
|
|
122
|
+
if level not in _LEVELS:
|
|
123
|
+
raise ValueError("level 必须为 L0 或 L2,实际 %r" % level)
|
|
124
|
+
safe_method = method.strip().upper()
|
|
125
|
+
wire: Optional[bytes] = None
|
|
126
|
+
encrypt_header: Optional[str] = None
|
|
127
|
+
if level == "L2":
|
|
128
|
+
wire, encrypt_header = seal_l2(
|
|
129
|
+
self._suite, self._wrap_pub, self._normalize_body(body), self._csprng
|
|
130
|
+
)
|
|
131
|
+
elif body is not None:
|
|
132
|
+
wire = self._normalize_body(body)
|
|
133
|
+
|
|
134
|
+
headers: Dict[str, str] = {
|
|
135
|
+
"x-wop-appkey": self._config.app_key,
|
|
136
|
+
"x-wop-timestamp": str(_now_ms()),
|
|
137
|
+
"x-wop-nonce": self._csprng(16).hex(), # F9:CSPRNG nonce
|
|
138
|
+
}
|
|
139
|
+
if wire is not None:
|
|
140
|
+
# D2:有 body 必产 digest;I1:digest 必入 signedHeaders(下方签名集合即全部头)
|
|
141
|
+
headers["x-wop-content-digest"] = build_digest_header(self._suite, wire)
|
|
142
|
+
if encrypt_header is not None:
|
|
143
|
+
headers["x-wop-encrypt"] = encrypt_header
|
|
144
|
+
|
|
145
|
+
app_headers: Dict[str, str] = {}
|
|
146
|
+
for name, value in (extra_headers or {}).items():
|
|
147
|
+
low = name.strip().lower()
|
|
148
|
+
if low.startswith("x-wop-"):
|
|
149
|
+
headers[low] = str(value).strip()
|
|
150
|
+
else:
|
|
151
|
+
app_headers[low] = str(value).strip()
|
|
152
|
+
|
|
153
|
+
auth = "v1/%d" % int(expired_seconds)
|
|
154
|
+
canonical = build_canonical(
|
|
155
|
+
auth, safe_method, path, query_string or "", canonical_headers(headers)
|
|
156
|
+
)
|
|
157
|
+
sig = sign(self._suite, self._signer, canonical.encode("utf-8"), csprng=self._csprng)
|
|
158
|
+
headers["x-wop-sign"] = "%s %s/%s/%s" % (
|
|
159
|
+
self._suite.security_req,
|
|
160
|
+
auth,
|
|
161
|
+
";".join(sorted(k for k in headers)),
|
|
162
|
+
b64url_encode(sig),
|
|
163
|
+
)
|
|
164
|
+
out: Dict[str, str] = dict(headers)
|
|
165
|
+
if wire is not None:
|
|
166
|
+
out.setdefault("content-type", "application/json")
|
|
167
|
+
out.update(app_headers)
|
|
168
|
+
return RequestDraft(safe_method, path, out, wire, level)
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def _normalize_body(body: Optional[object]) -> bytes:
|
|
172
|
+
if body is None:
|
|
173
|
+
raise ValueError("L2 封装需要明文 body")
|
|
174
|
+
if isinstance(body, bytes):
|
|
175
|
+
return body
|
|
176
|
+
if isinstance(body, str):
|
|
177
|
+
return body.encode("utf-8")
|
|
178
|
+
if isinstance(body, dict):
|
|
179
|
+
return json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
180
|
+
raise TypeError("body 仅接受 bytes/str/dict,实际 %r" % type(body).__name__)
|
|
181
|
+
|
|
182
|
+
# ---------- 入向 ----------
|
|
183
|
+
|
|
184
|
+
def verify_response(
|
|
185
|
+
self,
|
|
186
|
+
headers: Dict[str, str],
|
|
187
|
+
body: bytes,
|
|
188
|
+
path: str,
|
|
189
|
+
method: str = "POST",
|
|
190
|
+
query_string: str = "",
|
|
191
|
+
) -> VerifyResult:
|
|
192
|
+
"""校验平台响应(F6 顺序固定)。path/query_string = 触发本次请求的 URI。"""
|
|
193
|
+
lower = {str(k).lower().strip(): str(v).strip() for k, v in (headers or {}).items()}
|
|
194
|
+
try:
|
|
195
|
+
return self._verify_flow(lower, body, path, method.upper(), query_string or "")
|
|
196
|
+
except (SignatureVerifyError, DecryptError, ProtocolFormatError, DigestMismatchError,
|
|
197
|
+
DekConsistencyError, UnsupportedSuiteError, SuiteParseError) as exc:
|
|
198
|
+
return VerifyResult(ok=False, reason=str(exc))
|
|
199
|
+
|
|
200
|
+
def verify_callback(
|
|
201
|
+
self, headers: Dict[str, str], body: bytes, callback_path: str
|
|
202
|
+
) -> VerifyResult:
|
|
203
|
+
"""校验平台回调(URI 取回调 path,方法恒 POST)。"""
|
|
204
|
+
return self.verify_response(headers, body, callback_path, method="POST")
|
|
205
|
+
|
|
206
|
+
def _verify_flow(
|
|
207
|
+
self, lower: Dict[str, str], body: bytes, path: str, method: str, query_string: str
|
|
208
|
+
) -> VerifyResult:
|
|
209
|
+
sign_header = lower.get("x-wop-sign")
|
|
210
|
+
if not sign_header:
|
|
211
|
+
raise ProtocolFormatError("x-wop-sign 头缺席")
|
|
212
|
+
suite_part, sep, rest = sign_header.partition(" ")
|
|
213
|
+
if not suite_part or not sep or not rest:
|
|
214
|
+
raise ProtocolFormatError("x-wop-sign 应为 '<securityReq> <authString>/<signedHeaders>/<signature>'")
|
|
215
|
+
segs = rest.split("/")
|
|
216
|
+
if len(segs) != 4:
|
|
217
|
+
raise ProtocolFormatError(
|
|
218
|
+
"x-wop-sign 应为 <protocolVersion>/<expiredSeconds>/<signedHeaders>/<signature> 四段"
|
|
219
|
+
)
|
|
220
|
+
version, _expired, signed_names, sig_b64u = segs
|
|
221
|
+
if version != "v1":
|
|
222
|
+
raise ProtocolFormatError("不支持的协议版本:%r" % version)
|
|
223
|
+
req_suite = parse_suite(suite_part) # 解析类/支持类错误明确
|
|
224
|
+
if req_suite.security_req != self._suite.security_req:
|
|
225
|
+
raise UnsupportedSuiteError(
|
|
226
|
+
"响应声明套件 %s 与商户配置 %s 不符" % (suite_part, self._suite.security_req)
|
|
227
|
+
)
|
|
228
|
+
sig = _strict_decode_signature(sig_b64u)
|
|
229
|
+
auth = "%s/%s" % (version, _expired)
|
|
230
|
+
signed: Dict[str, str] = {}
|
|
231
|
+
for name in signed_names.split(";"):
|
|
232
|
+
if name not in lower:
|
|
233
|
+
raise ProtocolFormatError("签名声明的头在响应中缺席:%s" % name)
|
|
234
|
+
signed[name] = lower[name]
|
|
235
|
+
canonical = build_canonical(auth, method, path, query_string, canonical_headers(signed))
|
|
236
|
+
# F6 ①先验签(I2:先验签后解密)
|
|
237
|
+
verify(req_suite, self._wrap_pub, canonical.encode("utf-8"), sig)
|
|
238
|
+
# F6 ②digest 复核(D2:有 body 必传;对象 = wire 原始字节)
|
|
239
|
+
if body:
|
|
240
|
+
verify_digest_header(self._suite, lower.get("x-wop-content-digest"), body)
|
|
241
|
+
enc = lower.get("x-wop-encrypt")
|
|
242
|
+
if enc is None:
|
|
243
|
+
return VerifyResult(ok=True, plaintext=body) # L0
|
|
244
|
+
# F6 ③④⑤ DEK 解包 → alg 族比对 → bulk 解密(envelope.open_l2 内序)
|
|
245
|
+
if not enc.startswith(_DEK_PREFIX):
|
|
246
|
+
raise ProtocolFormatError("x-wop-encrypt 头格式错误:应为 %s<base64url>" % _DEK_PREFIX)
|
|
247
|
+
plaintext = open_l2(self._suite, self._signer, body, enc[len(_DEK_PREFIX):])
|
|
248
|
+
return VerifyResult(ok=True, plaintext=plaintext)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _strict_decode_signature(sig_b64u: str) -> bytes:
|
|
252
|
+
"""签名严格解码(F7:拒 '=' / 字母表外字符);失败归解析类。"""
|
|
253
|
+
from .encoding import b64url_decode
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
return b64url_decode(sig_b64u)
|
|
257
|
+
except ValueError as exc:
|
|
258
|
+
raise ProtocolFormatError("签名编码非法:%s" % exc) from exc
|
wop_sdk/digest.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""请求体摘要(D2/F4):`x-wop-content-digest: <alg> <小写hex>`。
|
|
3
|
+
|
|
4
|
+
- 恰好一个半角空格,多余空白拒绝而非容忍;
|
|
5
|
+
- hex 统一小写、固定 64 字符;
|
|
6
|
+
- 标签与套件族强耦合(I5):sha-256 仅 RSA 族、sm3 仅 SM 族;
|
|
7
|
+
- 摘要对象 = wire 原始报文字节;无 body(GET)则 header 缺席。
|
|
8
|
+
"""
|
|
9
|
+
import hashlib
|
|
10
|
+
import re
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from gmssl import sm3 as _sm3
|
|
14
|
+
|
|
15
|
+
from .errors import DigestMismatchError, ProtocolFormatError, UnsupportedSuiteError
|
|
16
|
+
from .suites import Suite
|
|
17
|
+
|
|
18
|
+
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def compute_digest(suite: Suite, data: bytes) -> bytes:
|
|
22
|
+
"""按套件族计算摘要(SHA-256 / SM3)。"""
|
|
23
|
+
if suite.family == "RSA":
|
|
24
|
+
return hashlib.sha256(data).digest()
|
|
25
|
+
return bytes.fromhex(_sm3.sm3_hash(list(data)))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_digest_header(suite: Suite, body: bytes) -> str:
|
|
29
|
+
"""组装 `<alg> <小写hex>`(恰一空格)。"""
|
|
30
|
+
return "%s %s" % (suite.digest_tag, compute_digest(suite, body).hex())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def check_digest_header(suite: Suite, value: Optional[str]) -> str:
|
|
34
|
+
"""格式层校验,通过返回 hex 值。
|
|
35
|
+
|
|
36
|
+
拒绝:非两段/恰一空格不满足、未知标签、跨族标签(I5)、非小写 hex、长度 ≠ 64。
|
|
37
|
+
"""
|
|
38
|
+
if value is None:
|
|
39
|
+
raise ProtocolFormatError("digest 头缺席")
|
|
40
|
+
parts = value.split(" ")
|
|
41
|
+
if len(parts) != 2:
|
|
42
|
+
raise ProtocolFormatError(
|
|
43
|
+
"digest 头格式错误:必须为 '<alg> <hex>' 恰一空格,实际 %r" % value
|
|
44
|
+
)
|
|
45
|
+
tag, hex_value = parts
|
|
46
|
+
expected_tag = suite.digest_tag
|
|
47
|
+
if tag != expected_tag:
|
|
48
|
+
# I5:标签与套件族强耦合(sha-256 仅 RSA 族、sm3 仅 SM 族)
|
|
49
|
+
if tag in ("sha-256", "sm3"):
|
|
50
|
+
raise UnsupportedSuiteError(
|
|
51
|
+
"digest 标签 %s 与套件族 %s 不符(跨族拒绝)" % (tag, suite.family)
|
|
52
|
+
)
|
|
53
|
+
raise ProtocolFormatError("digest 标签未知:%r" % tag)
|
|
54
|
+
if not _HEX64.match(hex_value):
|
|
55
|
+
raise ProtocolFormatError("digest 值必须为 64 字符小写 hex,实际 %r" % hex_value)
|
|
56
|
+
return hex_value
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def verify_digest_header(suite: Suite, value: Optional[str], body: bytes) -> None:
|
|
60
|
+
"""格式 + 值双层校验(F6 第 2 步 digest 复核)。不匹配抛完整性类错误(明确)。"""
|
|
61
|
+
expected = check_digest_header(suite, value)
|
|
62
|
+
actual = compute_digest(suite, body).hex()
|
|
63
|
+
if expected != actual:
|
|
64
|
+
raise DigestMismatchError("内容摘要不匹配")
|
wop_sdk/encoding.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""线上编码工具:base64url 严格无填充(F7/D10)、小写 hex(F5)、Java URLEncoder 语义(F2)。
|
|
3
|
+
|
|
4
|
+
严格模式参照 crypto-strategy-spec §3.4:服务端拒收带 '=' 的输入;
|
|
5
|
+
canonicalRequest 的 header 值编码语义对照网关 CanonicalRequestBuilder(Java)。
|
|
6
|
+
"""
|
|
7
|
+
import base64
|
|
8
|
+
import re
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
_B64URL_ALPHABET = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
12
|
+
|
|
13
|
+
# Java URLEncoder 的保留集:字母数字与 . - * _(空格单独处理为 %20)
|
|
14
|
+
_SAFE_CHARS = frozenset(
|
|
15
|
+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._*-"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def b64url_encode(data: bytes) -> str:
|
|
20
|
+
"""字节 → base64url 无填充字符串。"""
|
|
21
|
+
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def b64url_decode(text: str) -> bytes:
|
|
25
|
+
"""base64url 无填充字符串 → 字节。
|
|
26
|
+
|
|
27
|
+
严格模式(F7/D10):
|
|
28
|
+
- 拒绝空串;
|
|
29
|
+
- 拒绝 '=' 填充字符;
|
|
30
|
+
- 拒绝字母表外字符(含 '+' '/' 空白等);
|
|
31
|
+
- 拒绝长度 % 4 == 1(不可能的 base64 长度)。
|
|
32
|
+
"""
|
|
33
|
+
if not text:
|
|
34
|
+
raise ValueError("base64url 输入为空")
|
|
35
|
+
if "=" in text:
|
|
36
|
+
raise ValueError("base64url 严格模式:拒绝 '=' 填充")
|
|
37
|
+
if not _B64URL_ALPHABET.match(text):
|
|
38
|
+
raise ValueError("base64url 字母表外字符")
|
|
39
|
+
if len(text) % 4 == 1:
|
|
40
|
+
raise ValueError("base64url 长度非法(% 4 == 1)")
|
|
41
|
+
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def hex_lower(data: bytes) -> str:
|
|
45
|
+
"""字节 → 小写 hex(F5/D10)。"""
|
|
46
|
+
return data.hex()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def java_urlencode(text: Optional[str]) -> str:
|
|
50
|
+
"""Java URLEncoder.encode(text, UTF-8) 语义(F2)。
|
|
51
|
+
|
|
52
|
+
- 保留集:字母数字与 . - * _;
|
|
53
|
+
- 空格 → %20(Java 输出 '+' 后替换,等价结果);
|
|
54
|
+
- 其余字符按 UTF-8 字节逐字节 %XX(大写十六进制);
|
|
55
|
+
- None / 空串 → ""。
|
|
56
|
+
"""
|
|
57
|
+
if text is None or text == "":
|
|
58
|
+
return ""
|
|
59
|
+
out = []
|
|
60
|
+
for ch in text:
|
|
61
|
+
if ch in _SAFE_CHARS:
|
|
62
|
+
out.append(ch)
|
|
63
|
+
elif ch == " ":
|
|
64
|
+
out.append("%20")
|
|
65
|
+
else:
|
|
66
|
+
out.append("".join("%%%02X" % b for b in ch.encode("utf-8")))
|
|
67
|
+
return "".join(out)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def trimall(text: Optional[str]) -> str:
|
|
71
|
+
"""去首尾空白,连续空白折叠为单个空格(F2,对照 CanonicalRequestBuilder.trimall)。"""
|
|
72
|
+
if text is None:
|
|
73
|
+
return ""
|
|
74
|
+
return re.sub(r"\s+", " ", text.strip())
|