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
wop_sdk/envelope.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""L2 数字信封(F5/D10/I3/I4/I7)。
|
|
3
|
+
|
|
4
|
+
- 报文加密:AES-256-GCM(cryptography)/ SM4-GCM(自研 GCM × gmssl SM4);
|
|
5
|
+
线上密文 = ciphertext‖tag 尾拼,整体 base64url 无填充;
|
|
6
|
+
- DEK 包装:RSA-OAEP(显式双 SHA-256 + 空 label,F2 头号漂移源)/ SM2(C1C3C2 裸拼接);
|
|
7
|
+
- DEK 载荷:alg$base64url(key)$base64url(iv);alg 族比对在解包后、bulk 解密前(D8/I3);
|
|
8
|
+
- 解密失败(GCM tag、KDF、C3、OAEP)对外一律"解密失败"(I7 模糊化)。
|
|
9
|
+
"""
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from typing import Callable, Tuple, Union, cast
|
|
13
|
+
|
|
14
|
+
from cryptography.hazmat.primitives import hashes
|
|
15
|
+
from cryptography.hazmat.primitives.asymmetric import padding as _rsa_padding
|
|
16
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
17
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
18
|
+
|
|
19
|
+
from .encoding import b64url_decode, b64url_encode
|
|
20
|
+
from .errors import DecryptError, DekConsistencyError, ProtocolFormatError
|
|
21
|
+
from .sm2crypto import Sm2Ops, sm2_decrypt, sm2_encrypt
|
|
22
|
+
from .sm4gcm import sm4_gcm_decrypt, sm4_gcm_encrypt
|
|
23
|
+
from .suites import Suite
|
|
24
|
+
|
|
25
|
+
Csprng = Callable[[int], bytes]
|
|
26
|
+
KeyMaterial = Union[rsa.RSAPublicKey, rsa.RSAPrivateKey, Sm2Ops]
|
|
27
|
+
|
|
28
|
+
_AES_KEY_LEN = 32
|
|
29
|
+
_SM4_KEY_LEN = 16
|
|
30
|
+
_IV_LEN = 12
|
|
31
|
+
_TAG_LEN = 16
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def message_encrypt(suite: Suite, key: bytes, iv: bytes, plaintext: bytes, aad: bytes = b"") -> bytes:
|
|
35
|
+
"""报文对称加密 → ciphertext‖tag(F4 尾拼格式)。"""
|
|
36
|
+
if suite.family == "RSA":
|
|
37
|
+
if len(key) != _AES_KEY_LEN or len(iv) != _IV_LEN:
|
|
38
|
+
raise DecryptError("解密失败")
|
|
39
|
+
enc = Cipher(algorithms.AES(key), modes.GCM(iv)).encryptor()
|
|
40
|
+
return enc.update(plaintext) + enc.finalize() + enc.tag
|
|
41
|
+
if len(key) != _SM4_KEY_LEN or len(iv) != _IV_LEN:
|
|
42
|
+
raise DecryptError("解密失败")
|
|
43
|
+
return sm4_gcm_encrypt(key, iv, plaintext, aad)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def message_decrypt(suite: Suite, key: bytes, iv: bytes, cipher_tag: bytes, aad: bytes = b"") -> bytes:
|
|
47
|
+
"""报文对称解密(输入 ciphertext‖tag);任何失败对外模糊(I7)。"""
|
|
48
|
+
if suite.family == "RSA":
|
|
49
|
+
if len(key) != _AES_KEY_LEN or len(iv) != _IV_LEN or len(cipher_tag) < _TAG_LEN:
|
|
50
|
+
raise DecryptError("解密失败")
|
|
51
|
+
cipher, tag = cipher_tag[:-_TAG_LEN], cipher_tag[-_TAG_LEN:]
|
|
52
|
+
dec = Cipher(algorithms.AES(key), modes.GCM(iv, tag)).decryptor()
|
|
53
|
+
try:
|
|
54
|
+
return dec.update(cipher) + dec.finalize()
|
|
55
|
+
except Exception:
|
|
56
|
+
raise DecryptError() from None
|
|
57
|
+
if len(key) != _SM4_KEY_LEN or len(iv) != _IV_LEN or len(cipher_tag) < _TAG_LEN:
|
|
58
|
+
raise DecryptError("解密失败")
|
|
59
|
+
try:
|
|
60
|
+
return sm4_gcm_decrypt(key, iv, cipher_tag, aad)
|
|
61
|
+
except ValueError:
|
|
62
|
+
raise DecryptError() from None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# OAEP 显式参数化(F2/D10):OAEP 摘要 SHA-256 + MGF1 摘要显式钉死 SHA-256 + 空 label。
|
|
66
|
+
# JCA 串 OAEPWithSHA-256AndMGF1Padding 的 MGF1 默认 SHA-1,禁止依赖默认值。
|
|
67
|
+
def _oaep_params() -> _rsa_padding.OAEP:
|
|
68
|
+
return _rsa_padding.OAEP(
|
|
69
|
+
mgf=_rsa_padding.MGF1(algorithm=hashes.SHA256()),
|
|
70
|
+
algorithm=hashes.SHA256(),
|
|
71
|
+
label=None,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def wrap_dek(suite: Suite, wrap_pub: KeyMaterial, payload: bytes, csprng: Csprng = os.urandom) -> bytes:
|
|
76
|
+
"""DEK 非对称包装(出向,平台公钥)。SM2 的 k 走 csprng(I4)。"""
|
|
77
|
+
if suite.family == "RSA":
|
|
78
|
+
return cast(rsa.RSAPublicKey, wrap_pub).encrypt(payload, _oaep_params())
|
|
79
|
+
return sm2_encrypt(cast(Sm2Ops, wrap_pub), csprng, payload)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def unwrap_dek(suite: Suite, wrap_priv: KeyMaterial, wrapped: bytes) -> bytes:
|
|
83
|
+
"""DEK 解包(入向);失败对外模糊(I7)。"""
|
|
84
|
+
if suite.family == "RSA":
|
|
85
|
+
try:
|
|
86
|
+
return cast(rsa.RSAPrivateKey, wrap_priv).decrypt(wrapped, _oaep_params())
|
|
87
|
+
except Exception:
|
|
88
|
+
raise DecryptError() from None
|
|
89
|
+
try:
|
|
90
|
+
return sm2_decrypt(cast(Sm2Ops, wrap_priv), wrapped)
|
|
91
|
+
except DecryptError:
|
|
92
|
+
raise
|
|
93
|
+
except Exception:
|
|
94
|
+
raise DecryptError() from None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def build_dek_payload(suite: Suite, key: bytes, iv: bytes) -> str:
|
|
98
|
+
"""DEK 载荷 alg$base64url(key)$base64url(iv)(§6.1)。"""
|
|
99
|
+
return "%s$%s$%s" % (suite.message_alg, b64url_encode(key), b64url_encode(iv))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def parse_dek_payload(suite: Suite, payload: str) -> Tuple[bytes, bytes]:
|
|
103
|
+
"""解析 DEK 载荷(明文,已解包后调用)。
|
|
104
|
+
|
|
105
|
+
时序(D8/I3):alg 段在载荷明文内部 → 解包之后、bulk 解密之前完成族比对。
|
|
106
|
+
alg 与套件族不符 → 一致性类(明确);结构/编码错误 → 解析类(明确)。
|
|
107
|
+
"""
|
|
108
|
+
parts = payload.split("$")
|
|
109
|
+
if len(parts) != 3:
|
|
110
|
+
raise ProtocolFormatError("DEK 载荷必须为 alg$key$iv 三段,实际 %d 段" % len(parts))
|
|
111
|
+
alg, key_b64u, iv_b64u = parts
|
|
112
|
+
if alg not in ("AES-256-GCM", "SM4-GCM"):
|
|
113
|
+
raise ProtocolFormatError("DEK 载荷 alg 未知:%r" % alg)
|
|
114
|
+
if alg != suite.message_alg: # I3/I5:族比对先于 bulk 解密
|
|
115
|
+
raise DekConsistencyError(
|
|
116
|
+
"DEK 算法 %s 与套件 %s 要求的 %s 不符" % (alg, suite.security_req, suite.message_alg)
|
|
117
|
+
)
|
|
118
|
+
try:
|
|
119
|
+
key = b64url_decode(key_b64u)
|
|
120
|
+
iv = b64url_decode(iv_b64u)
|
|
121
|
+
except ValueError as exc:
|
|
122
|
+
raise ProtocolFormatError("DEK 载荷 key/iv 编码非法:%s" % exc) from exc
|
|
123
|
+
expected_key_len = _AES_KEY_LEN if suite.family == "RSA" else _SM4_KEY_LEN
|
|
124
|
+
if len(key) != expected_key_len or len(iv) != _IV_LEN:
|
|
125
|
+
raise ProtocolFormatError(
|
|
126
|
+
"DEK 载荷 key/iv 长度非法(key %d、iv %d)" % (len(key), len(iv))
|
|
127
|
+
)
|
|
128
|
+
return key, iv
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def seal_l2(
|
|
132
|
+
suite: Suite, platform_pub: KeyMaterial, plaintext: bytes, csprng: Csprng = os.urandom
|
|
133
|
+
) -> Tuple[bytes, str]:
|
|
134
|
+
"""L2 加密封装 → (wireBody, x-wop-encrypt 头)。
|
|
135
|
+
|
|
136
|
+
DEK 与 IV 均由 csprng 生成(I4:同一密钥下 IV 永不复用,生成点唯一)。
|
|
137
|
+
"""
|
|
138
|
+
key_len = _AES_KEY_LEN if suite.family == "RSA" else _SM4_KEY_LEN
|
|
139
|
+
key = csprng(key_len)
|
|
140
|
+
iv = csprng(_IV_LEN)
|
|
141
|
+
cipher_tag = message_encrypt(suite, key, iv, plaintext)
|
|
142
|
+
wire_body = json.dumps({"encrypted": b64url_encode(cipher_tag)}, separators=(",", ":")).encode()
|
|
143
|
+
payload = build_dek_payload(suite, key, iv).encode("utf-8")
|
|
144
|
+
wrapped = wrap_dek(suite, platform_pub, payload, csprng)
|
|
145
|
+
return wire_body, "L2;dek=" + b64url_encode(wrapped)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def open_l2(suite: Suite, wrap_priv: KeyMaterial, wire_body: bytes, dek_b64u: str) -> bytes:
|
|
149
|
+
"""L2 解密(F6 第 3–5 步):DEK 解包(模糊)→ alg 族比对(明确)→ bulk 解密(模糊)。"""
|
|
150
|
+
try:
|
|
151
|
+
wrapped = b64url_decode(dek_b64u)
|
|
152
|
+
except ValueError:
|
|
153
|
+
raise DecryptError() from None
|
|
154
|
+
payload = unwrap_dek(suite, wrap_priv, wrapped).decode("utf-8", errors="strict")
|
|
155
|
+
key, iv = parse_dek_payload(suite, payload)
|
|
156
|
+
try:
|
|
157
|
+
obj = json.loads(wire_body)
|
|
158
|
+
encrypted = obj["encrypted"]
|
|
159
|
+
except Exception:
|
|
160
|
+
raise DecryptError() from None
|
|
161
|
+
try:
|
|
162
|
+
cipher_tag = b64url_decode(encrypted)
|
|
163
|
+
except (ValueError, TypeError):
|
|
164
|
+
raise DecryptError() from None
|
|
165
|
+
return message_decrypt(suite, key, iv, cipher_tag)
|
wop_sdk/errors.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""WOP SDK 错误分类(crypto-strategy-spec §10.2)。
|
|
3
|
+
|
|
4
|
+
明确/模糊分界原则(I7):鉴权前可判定的公开协议知识 → 明确(帮助商户集成自查);
|
|
5
|
+
依赖密钥参与的判定 → 模糊(防 oracle)。验签/解密失败的对外消息不区分原因细节。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class WopSdkError(Exception):
|
|
10
|
+
"""SDK 错误基类。"""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SuiteParseError(WopSdkError):
|
|
14
|
+
"""解析类:securityReq 三段式/前缀/格式错误。对外语义明确。"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class UnsupportedSuiteError(WopSdkError):
|
|
18
|
+
"""支持类:算法不在列表、跨族、密钥长度非法(I5)。对外语义明确。"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ProtocolFormatError(WopSdkError):
|
|
22
|
+
"""解析类:线上头结构(x-wop-sign / x-wop-encrypt / digest header)格式错误。明确。"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class KeyMaterialError(WopSdkError):
|
|
26
|
+
"""配置类:密钥材料缺失/格式不符/不在指定曲线(D12、I5)。配置期可判定,明确。"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DigestMismatchError(WopSdkError):
|
|
30
|
+
"""完整性类:摘要不匹配。公开协议知识,明确。"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SignatureVerifyError(WopSdkError):
|
|
34
|
+
"""验签类:签名验证失败。对外模糊——不区分原因细节(I7)。"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, message: str = "签名验证失败"):
|
|
37
|
+
super().__init__(message)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DecryptError(WopSdkError):
|
|
41
|
+
"""解密类:DEK 解包失败、GCM tag 失败、SM2 密文校验失败。对外模糊(I7)。"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, message: str = "解密失败"):
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class DekConsistencyError(WopSdkError):
|
|
48
|
+
"""一致性类:DEK alg 与套件族不符。公开映射知识,明确(D8/I3)。"""
|
wop_sdk/keys.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""密钥材料解析(D12 分发契约)。
|
|
3
|
+
|
|
4
|
+
- RSA 公钥 = X.509 SPKI DER,Base64 编码(PEM 仅作可选包装)
|
|
5
|
+
- RSA 私钥 = PKCS#8 DER,Base64 编码(PEM 可选包装)
|
|
6
|
+
- SM2 公钥 = 未压缩点 04‖X‖Y(65 字节),Base64 编码;必须在 sm2p256v1 曲线上(I5)
|
|
7
|
+
- SM2 私钥 = d 标量(32 字节大端),1 ≤ d < n
|
|
8
|
+
"""
|
|
9
|
+
import base64
|
|
10
|
+
import binascii
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from cryptography.exceptions import InvalidKey
|
|
15
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
16
|
+
from cryptography.hazmat.primitives.serialization import load_der_private_key, load_der_public_key
|
|
17
|
+
|
|
18
|
+
from .errors import KeyMaterialError
|
|
19
|
+
|
|
20
|
+
# sm2p256v1 曲线参数(GB/T 32918.5)
|
|
21
|
+
_SM2_P = 0xFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFF
|
|
22
|
+
_SM2_A = 0xFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFC
|
|
23
|
+
_SM2_B = 0x28E9FA9E9D9F5E344D5A9E4BCF6509A7F39789F515AB8F92DDBCBD414D940E93
|
|
24
|
+
_SM2_N = 0xFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFF7203DF6B21C6052B53BBF40939D54123
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Sm2PublicKey:
|
|
29
|
+
"""SM2 公钥点(X‖Y 128 hex,无 04 前缀;uncompressed 含 04)。"""
|
|
30
|
+
|
|
31
|
+
xy_hex: str
|
|
32
|
+
uncompressed: bytes
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _material_to_der(material: str) -> bytes:
|
|
36
|
+
"""密钥材料 → DER 字节:接受 PEM 包装或单行 Base64(标准/URL 字母表,允许 padding)。"""
|
|
37
|
+
text = material.strip()
|
|
38
|
+
if "-----BEGIN" in text:
|
|
39
|
+
body = "".join(line for line in text.splitlines() if "-----" not in line)
|
|
40
|
+
else:
|
|
41
|
+
body = "".join(text.split())
|
|
42
|
+
if not body:
|
|
43
|
+
raise KeyMaterialError("密钥内容为空")
|
|
44
|
+
try:
|
|
45
|
+
return base64.b64decode(body, validate=True)
|
|
46
|
+
except (binascii.Error, ValueError):
|
|
47
|
+
pass
|
|
48
|
+
try:
|
|
49
|
+
return base64.urlsafe_b64decode(body + "=" * (-len(body) % 4))
|
|
50
|
+
except (binascii.Error, ValueError):
|
|
51
|
+
raise KeyMaterialError("密钥材料无法解码为 Base64") from None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_rsa_public_key(material: str, expected_bits: Optional[int] = None) -> rsa.RSAPublicKey:
|
|
55
|
+
der = _material_to_der(material)
|
|
56
|
+
try:
|
|
57
|
+
key = load_der_public_key(der)
|
|
58
|
+
except (InvalidKey, ValueError, TypeError) as exc:
|
|
59
|
+
raise KeyMaterialError("RSA 公钥解析失败(应为 SPKI DER Base64/PEM): %s" % exc) from exc
|
|
60
|
+
if not isinstance(key, rsa.RSAPublicKey):
|
|
61
|
+
raise KeyMaterialError("非 RSA 公钥材料")
|
|
62
|
+
if expected_bits is not None and key.key_size != expected_bits:
|
|
63
|
+
raise KeyMaterialError(
|
|
64
|
+
"RSA 公钥长度 %d 与套件要求 %d 不符" % (key.key_size, expected_bits)
|
|
65
|
+
)
|
|
66
|
+
return key
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def load_rsa_private_key(material: str, expected_bits: Optional[int] = None) -> rsa.RSAPrivateKey:
|
|
70
|
+
der = _material_to_der(material)
|
|
71
|
+
try:
|
|
72
|
+
key = load_der_private_key(der, password=None)
|
|
73
|
+
except (InvalidKey, ValueError, TypeError) as exc:
|
|
74
|
+
raise KeyMaterialError("RSA 私钥解析失败(应为 PKCS#8 DER Base64/PEM): %s" % exc) from exc
|
|
75
|
+
if not isinstance(key, rsa.RSAPrivateKey):
|
|
76
|
+
raise KeyMaterialError("非 RSA 私钥材料")
|
|
77
|
+
if expected_bits is not None and key.key_size != expected_bits:
|
|
78
|
+
raise KeyMaterialError(
|
|
79
|
+
"RSA 私钥长度 %d 与套件要求 %d 不符" % (key.key_size, expected_bits)
|
|
80
|
+
)
|
|
81
|
+
return key
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def load_sm2_public_key(material: str) -> Sm2PublicKey:
|
|
85
|
+
der = _material_to_der(material)
|
|
86
|
+
if len(der) != 65 or der[0] != 0x04:
|
|
87
|
+
raise KeyMaterialError(
|
|
88
|
+
"SM2 公钥必须为未压缩点 04‖X‖Y(65 字节),实际 %d 字节" % len(der)
|
|
89
|
+
)
|
|
90
|
+
x = int.from_bytes(der[1:33], "big")
|
|
91
|
+
y = int.from_bytes(der[33:65], "big")
|
|
92
|
+
# I5:点必须在 sm2p256v1 曲线上(y² ≡ x³ + ax + b mod p)
|
|
93
|
+
if (y * y - (x * x * x + _SM2_A * x + _SM2_B)) % _SM2_P != 0:
|
|
94
|
+
raise KeyMaterialError("SM2 公钥点不在 sm2p256v1 曲线上")
|
|
95
|
+
return Sm2PublicKey(xy_hex=der[1:].hex(), uncompressed=der)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def load_sm2_private_key(material: str) -> bytes:
|
|
99
|
+
der = _material_to_der(material)
|
|
100
|
+
if len(der) != 32:
|
|
101
|
+
raise KeyMaterialError("SM2 私钥必须为 32 字节大端标量 d,实际 %d 字节" % len(der))
|
|
102
|
+
d = int.from_bytes(der, "big")
|
|
103
|
+
if not 1 <= d < _SM2_N:
|
|
104
|
+
raise KeyMaterialError("SM2 私钥标量 d 超出 [1, n) 范围")
|
|
105
|
+
return der
|
wop_sdk/signature.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""结构化签名(F3/F7/D9):SHA256withRSA(PKCS#1 v1.5)与 SM3withSM2(裸 r‖s 64B)。
|
|
3
|
+
|
|
4
|
+
定长编码前置校验:长度不符按解析类拒绝,先于任何密码学运算(§3.3①)。
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
from typing import Callable, Union, cast
|
|
8
|
+
|
|
9
|
+
from cryptography.exceptions import InvalidSignature
|
|
10
|
+
from cryptography.hazmat.primitives import hashes
|
|
11
|
+
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
|
12
|
+
|
|
13
|
+
from .errors import ProtocolFormatError, SignatureVerifyError
|
|
14
|
+
from .sm2crypto import _N, Sm2Ops, sm2_sign_with_sm3, sm2_verify_with_sm3
|
|
15
|
+
from .suites import Suite
|
|
16
|
+
|
|
17
|
+
Csprng = Callable[[int], bytes]
|
|
18
|
+
Signer = Union[rsa.RSAPrivateKey, Sm2Ops]
|
|
19
|
+
Verifier = Union[rsa.RSAPublicKey, Sm2Ops]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def sign(suite: Suite, signer: Signer, data: bytes, csprng: Csprng = os.urandom) -> bytes:
|
|
23
|
+
"""加签:RSA → PKCS#1 v1.5 + SHA-256;SM2 → SM3withSM2 裸 r‖s(k 走 csprng,I4)。"""
|
|
24
|
+
if suite.family == "RSA":
|
|
25
|
+
key = cast(rsa.RSAPrivateKey, signer)
|
|
26
|
+
return key.sign(data, padding.PKCS1v15(), hashes.SHA256())
|
|
27
|
+
for _ in range(256):
|
|
28
|
+
k = int.from_bytes(csprng(32), "big")
|
|
29
|
+
if 1 <= k < _N:
|
|
30
|
+
return sm2_sign_with_sm3(signer, data, "%064x" % k)
|
|
31
|
+
raise SignatureVerifyError("签名验证失败") # pragma: no cover —— 2^-2048 概率
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def verify(suite: Suite, verifier: Verifier, data: bytes, signature: bytes) -> None:
|
|
35
|
+
"""验签;失败抛 SignatureVerifyError(I7:对外模糊,不区分原因细节)。
|
|
36
|
+
|
|
37
|
+
长度前置校验(解析类,先于密码学运算):
|
|
38
|
+
RSA3072 → 384B;RSA4096 → 512B;SM2 → 恒 64B(63B/65B/DER 一律拒绝)。
|
|
39
|
+
"""
|
|
40
|
+
if suite.family == "RSA":
|
|
41
|
+
if len(signature) != suite.key_bits // 8:
|
|
42
|
+
raise ProtocolFormatError(
|
|
43
|
+
"RSA 签名长度 %d 与套件 %d 位要求的 %d 字节不符"
|
|
44
|
+
% (len(signature), suite.key_bits, suite.key_bits // 8)
|
|
45
|
+
)
|
|
46
|
+
try:
|
|
47
|
+
pub = cast(rsa.RSAPublicKey, verifier)
|
|
48
|
+
pub.verify(signature, data, padding.PKCS1v15(), hashes.SHA256())
|
|
49
|
+
except InvalidSignature:
|
|
50
|
+
raise SignatureVerifyError() from None
|
|
51
|
+
return
|
|
52
|
+
if len(signature) != 64:
|
|
53
|
+
raise ProtocolFormatError(
|
|
54
|
+
"SM2 签名必须为裸 r||s 64 字节(禁 DER),实际 %d 字节" % len(signature)
|
|
55
|
+
)
|
|
56
|
+
if not sm2_verify_with_sm3(verifier, signature.hex(), data):
|
|
57
|
+
raise SignatureVerifyError()
|
wop_sdk/sm2crypto.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""SM2 底层操作:签名(裸 r‖s,D9)、加密(C1C3C2,D9)、解密(含 gmssl 缺失的 C3 校验)。
|
|
3
|
+
|
|
4
|
+
与 gmssl 原生 API 的差异(均为刻意为之):
|
|
5
|
+
1. 绕开 ``CryptSM2.__init__`` 的 ``public_key.lstrip("04")`` 缺陷——合法 X 以 '0'/'4' 字符
|
|
6
|
+
开头时(概率 ≈ 1/8)会被误剥,本类覆写 public_key 为标准 X‖Y 128+128 hex;
|
|
7
|
+
2. gmssl ``decrypt`` 计算摘要 u 却从不比对 C3——本模块自研解密补上完整性校验,
|
|
8
|
+
C1C2C3 旧国标顺序密文在此必然失败(顺序钉死负向量的拦截点);
|
|
9
|
+
3. gmssl ``encrypt``/``random_hex`` 使用 ``random.choice``(非 CSPRNG)——本模块的 k
|
|
10
|
+
一律由调用方注入(生产走 csprng,测试走固定向量,I4)。
|
|
11
|
+
"""
|
|
12
|
+
from typing import Optional, cast
|
|
13
|
+
|
|
14
|
+
from gmssl import sm3 as _sm3
|
|
15
|
+
from gmssl.sm2 import CryptSM2, default_ecc_table
|
|
16
|
+
|
|
17
|
+
from .errors import DecryptError, KeyMaterialError
|
|
18
|
+
|
|
19
|
+
_N = int(default_ecc_table["n"], 16)
|
|
20
|
+
_P = int(default_ecc_table["p"], 16)
|
|
21
|
+
_A = int(default_ecc_table["a"], 16)
|
|
22
|
+
_B = int(default_ecc_table["b"], 16)
|
|
23
|
+
|
|
24
|
+
_MAX_K_RETRY = 256
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Sm2Ops(CryptSM2):
|
|
28
|
+
"""SM2 曲线运算封装(无可变共享状态)。"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, private_key_hex: Optional[str] = None, public_xy_hex: Optional[str] = None):
|
|
31
|
+
super().__init__(private_key_hex or "00" * 32, "00" * 128)
|
|
32
|
+
# 覆写绕过 lstrip 缺陷;public_key 恒为 X‖Y(128+128 hex,无 04 前缀)
|
|
33
|
+
if public_xy_hex is not None:
|
|
34
|
+
if len(public_xy_hex) != 64 * 2:
|
|
35
|
+
raise KeyMaterialError("SM2 公钥 hex 必须为 X||Y 共 128 字符(X‖Y 各 32 字节)")
|
|
36
|
+
self.public_key = public_xy_hex
|
|
37
|
+
if private_key_hex is not None:
|
|
38
|
+
self.private_key = private_key_hex
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _point_on_curve(xy_hex: str) -> bool:
|
|
42
|
+
x = int(xy_hex[:64], 16)
|
|
43
|
+
y = int(xy_hex[64:], 16)
|
|
44
|
+
return (y * y - (x * x * x + _A * x + _B)) % _P == 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def sm2_sign_with_sm3(ops: Sm2Ops, data: bytes, k_hex: str) -> bytes:
|
|
48
|
+
"""SM3withSM2 签名:e = SM3(ZA‖M),ZA userId = '1234567812345678';输出裸 r‖s 64B。"""
|
|
49
|
+
e_hex = ops._sm3_z(data)
|
|
50
|
+
sig_hex = cast(str, ops.sign(bytes.fromhex(e_hex), k_hex))
|
|
51
|
+
return bytes.fromhex(sig_hex)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def sm2_verify_with_sm3(ops: Sm2Ops, sig_hex: str, data: bytes) -> bool:
|
|
55
|
+
"""SM3withSM2 验签(裸 r‖s hex)。"""
|
|
56
|
+
return bool(ops.verify_with_sm3(sig_hex, data))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def sm2_encrypt(ops: Sm2Ops, csprng, plaintext: bytes) -> bytes:
|
|
60
|
+
"""SM2 加密,线上格式 C1C3C2 裸拼接(C1 = 未压缩点 04‖X‖Y 65B)。
|
|
61
|
+
|
|
62
|
+
k 由 csprng 注入(I4:CSPRNG,随机数生成点收敛于调用方)。
|
|
63
|
+
"""
|
|
64
|
+
for _ in range(_MAX_K_RETRY):
|
|
65
|
+
k = int.from_bytes(csprng(32), "big")
|
|
66
|
+
if 1 <= k < _N:
|
|
67
|
+
break
|
|
68
|
+
else: # pragma: no cover —— CSPRNG 连续 256 次不可用采样概率 ≈ 2^-2048
|
|
69
|
+
raise DecryptError("解密失败")
|
|
70
|
+
c1_xy = cast(str, ops._kg(k, ops.ecc_table["g"]))
|
|
71
|
+
x2y2 = cast(str, ops._kg(k, ops.public_key))
|
|
72
|
+
x2, y2 = x2y2[:64], x2y2[64:]
|
|
73
|
+
c2 = _xor_kdf(plaintext, x2y2)
|
|
74
|
+
c3 = _sm3.sm3_hash(list(bytes.fromhex(x2 + plaintext.hex() + y2)))
|
|
75
|
+
return bytes.fromhex("04" + c1_xy + c3) + c2
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def sm2_decrypt(ops: Sm2Ops, cipher: bytes) -> bytes:
|
|
79
|
+
"""SM2 解密(C1C3C2),C3 摘要校验失败抛 DecryptError(I7 模糊,不区分细节)。"""
|
|
80
|
+
if len(cipher) < 65 + 32 + 1 or cipher[0] != 0x04:
|
|
81
|
+
raise DecryptError("解密失败")
|
|
82
|
+
c1_xy_hex = cipher[1:65].hex()
|
|
83
|
+
if not _point_on_curve(c1_xy_hex):
|
|
84
|
+
raise DecryptError("解密失败")
|
|
85
|
+
c3_hex = cipher[65:97].hex()
|
|
86
|
+
c2 = cipher[97:]
|
|
87
|
+
x2y2 = cast(str, ops._kg(int(ops.private_key, 16), c1_xy_hex))
|
|
88
|
+
x2, y2 = x2y2[:64], x2y2[64:]
|
|
89
|
+
plaintext = _xor_kdf(c2, x2y2)
|
|
90
|
+
u = _sm3.sm3_hash(list(bytes.fromhex(x2 + plaintext.hex() + y2)))
|
|
91
|
+
if u != c3_hex:
|
|
92
|
+
raise DecryptError("解密失败")
|
|
93
|
+
return plaintext
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _xor_kdf(data: bytes, xy_hex: str) -> bytes:
|
|
97
|
+
"""SM2 KDF(x2‖y2 作种子)与数据异或;密钥流全零视为失败(概率 2^-128)。"""
|
|
98
|
+
t = _sm3.sm3_kdf(xy_hex.encode("utf-8"), len(data))
|
|
99
|
+
if int(t, 16) == 0: # pragma: no cover —— KDF 全零概率 2^-128,不可确定性构造
|
|
100
|
+
raise DecryptError("解密失败")
|
|
101
|
+
form = "%%0%dx" % (len(data) * 2)
|
|
102
|
+
return bytes.fromhex(form % (int(data.hex(), 16) ^ int(t, 16)))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def sm2_derive_public_hex(private_key_hex: str) -> str:
|
|
106
|
+
"""由私钥标量 d 推导公钥 X‖Y(128 hex,签名 ZA 需要)。"""
|
|
107
|
+
ops = CryptSM2(private_key_hex, "00" * 128)
|
|
108
|
+
return cast(str, ops._kg(int(private_key_hex, 16), ops.ecc_table["g"]))
|
wop_sdk/sm4gcm.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""SM4-GCM(NIST SP 800-38D GCM 构造 × GB/T 32907 SM4 分组函数)。
|
|
3
|
+
|
|
4
|
+
gmssl ≥3.2.2 仅提供 SM4 ECB/CBC,无 GCM;本模块以 gmssl ``one_round``(无 padding
|
|
5
|
+
的单块加密原语)为底层分组函数实现 GCM(CTR + GHASH),正确性由黄金向量
|
|
6
|
+
sm4gcm-encrypt 字节级锚定(D11:官方 SDK 即 SM 生态答案)。
|
|
7
|
+
"""
|
|
8
|
+
from gmssl.sm4 import SM4_ENCRYPT, CryptSM4
|
|
9
|
+
|
|
10
|
+
_R = 0xE1 << 120 # GF(2^128) 约减多项式 x^128+x^7+x^2+x+1
|
|
11
|
+
|
|
12
|
+
def _gf_mult(x: int, y: int) -> int:
|
|
13
|
+
"""GF(2^128) 乘法(MSB-first 位串,SP 800-38D Algorithm 1)。"""
|
|
14
|
+
z, v = 0, x
|
|
15
|
+
for i in range(127, -1, -1):
|
|
16
|
+
if (y >> i) & 1:
|
|
17
|
+
z ^= v
|
|
18
|
+
if v & 1:
|
|
19
|
+
v = (v >> 1) ^ _R
|
|
20
|
+
else:
|
|
21
|
+
v >>= 1
|
|
22
|
+
return z
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _Sm4Block:
|
|
26
|
+
"""SM4 单块加密函数对象(密钥扩展一次,线程封闭使用)。"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, key: bytes):
|
|
29
|
+
self._cipher = CryptSM4()
|
|
30
|
+
self._cipher.set_key(key, SM4_ENCRYPT)
|
|
31
|
+
|
|
32
|
+
def __call__(self, block: bytes) -> bytes:
|
|
33
|
+
return bytes(self._cipher.one_round(self._cipher.sk, list(block)))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _ghash(h: int, aad: bytes, cipher: bytes) -> int:
|
|
37
|
+
pad_a = (16 - len(aad) % 16) % 16
|
|
38
|
+
pad_c = (16 - len(cipher) % 16) % 16
|
|
39
|
+
data = (
|
|
40
|
+
aad
|
|
41
|
+
+ b"\x00" * pad_a
|
|
42
|
+
+ cipher
|
|
43
|
+
+ b"\x00" * pad_c
|
|
44
|
+
+ (len(aad) * 8).to_bytes(8, "big")
|
|
45
|
+
+ (len(cipher) * 8).to_bytes(8, "big")
|
|
46
|
+
)
|
|
47
|
+
y = 0
|
|
48
|
+
for i in range(0, len(data), 16):
|
|
49
|
+
y = _gf_mult(y ^ int.from_bytes(data[i : i + 16], "big"), h)
|
|
50
|
+
return y
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _gcm_core(block_fn, iv: bytes, data: bytes, aad: bytes, ghash_over_input: bool):
|
|
54
|
+
"""GCM 公共路径:返回 (输出块, tag)。
|
|
55
|
+
|
|
56
|
+
GHASH 恒作用于密文:加密方向密文 = CTR 输出,解密方向密文 = 输入 data。
|
|
57
|
+
"""
|
|
58
|
+
h = int.from_bytes(block_fn(b"\x00" * 16), "big")
|
|
59
|
+
j0 = iv + b"\x00\x00\x00\x01"
|
|
60
|
+
ctr = int.from_bytes(j0, "big")
|
|
61
|
+
out = bytearray()
|
|
62
|
+
for off in range(0, len(data), 16):
|
|
63
|
+
ctr = (ctr & ~0xFFFFFFFF) | ((ctr + 1) & 0xFFFFFFFF) # inc32:仅低 32 位
|
|
64
|
+
keystream = block_fn(ctr.to_bytes(16, "big"))
|
|
65
|
+
out += bytes(p ^ k for p, k in zip(data[off : off + 16], keystream))
|
|
66
|
+
ghash_input = data if ghash_over_input else bytes(out)
|
|
67
|
+
s = _ghash(h, aad, ghash_input)
|
|
68
|
+
tag = bytes(a ^ b for a, b in zip(block_fn(j0), s.to_bytes(16, "big")))
|
|
69
|
+
return bytes(out), tag
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def sm4_gcm_encrypt(key: bytes, iv: bytes, plaintext: bytes, aad: bytes = b"") -> bytes:
|
|
73
|
+
"""SM4-GCM 加密,返回 ciphertext‖tag(F4:tag 128bit 尾拼)。"""
|
|
74
|
+
out, tag = _gcm_core(_Sm4Block(key), iv, plaintext, aad, ghash_over_input=False)
|
|
75
|
+
return out + tag
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def sm4_gcm_decrypt(key: bytes, iv: bytes, cipher_tag: bytes, aad: bytes = b"") -> bytes:
|
|
79
|
+
"""SM4-GCM 解密(输入 ciphertext‖tag);tag 不符抛 ValueError。"""
|
|
80
|
+
if len(cipher_tag) < 16:
|
|
81
|
+
raise ValueError("密文短于 tag 长度")
|
|
82
|
+
cipher, tag = cipher_tag[:-16], cipher_tag[-16:]
|
|
83
|
+
out, expect = _gcm_core(_Sm4Block(key), iv, cipher, aad, ghash_over_input=True)
|
|
84
|
+
if bytes(a ^ b for a, b in zip(tag, expect)) != b"\x00" * 16:
|
|
85
|
+
raise ValueError("GCM tag 校验失败")
|
|
86
|
+
return out
|
wop_sdk/suites.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""securityReq 套件解析(F1,crypto-strategy-spec §2)。
|
|
3
|
+
|
|
4
|
+
格式:WOP-<密钥算法标识>-<摘要算法标识>,三段式;
|
|
5
|
+
映射关系集中注册于代码(单一注册表,D13),无运行时配置入口。
|
|
6
|
+
"""
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from .errors import SuiteParseError, UnsupportedSuiteError
|
|
11
|
+
|
|
12
|
+
# 密钥算法 → (族, RSA 位长)。SM2 族 key_bits=0。
|
|
13
|
+
_KEY_ALGORITHMS = {
|
|
14
|
+
"RSA3072": ("RSA", 3072),
|
|
15
|
+
"RSA4096": ("RSA", 4096),
|
|
16
|
+
"SM2": ("SM", 0),
|
|
17
|
+
}
|
|
18
|
+
# 摘要算法 → (族, header 标签);族与密钥算法族同名(I5 比对基准)
|
|
19
|
+
_DIGEST_ALGORITHMS = {
|
|
20
|
+
"SHA256": ("RSA", "sha-256"),
|
|
21
|
+
"SM3": ("SM", "sm3"),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# 族 → 报文对称算法 / DEK 包装算法 / 签名算法名
|
|
25
|
+
_FAMILY_MESSAGE_ALG = {"RSA": "AES-256-GCM", "SM": "SM4-GCM"}
|
|
26
|
+
_FAMILY_KEY_WRAP = {3072: "RSA-3072-OAEP", 4096: "RSA-4096-OAEP"}
|
|
27
|
+
_FAMILY_SIGN = {"RSA": "SHA256withRSA", "SM": "SM3withSM2"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Suite:
|
|
32
|
+
"""一次请求的算法上下文(不可变;spec §4.4 AlgorithmSuite 的 SDK 侧映像)。"""
|
|
33
|
+
|
|
34
|
+
security_req: str
|
|
35
|
+
family: str # "RSA" | "SM"
|
|
36
|
+
key_bits: int # 3072 | 4096 | 0(SM2)
|
|
37
|
+
digest_alg: str # "SHA256" | "SM3"
|
|
38
|
+
digest_tag: str # "sha-256" | "sm3"
|
|
39
|
+
sign_alg: str
|
|
40
|
+
message_alg: str # "AES-256-GCM" | "SM4-GCM"
|
|
41
|
+
key_wrap_alg: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_suite(security_req: Optional[str]) -> Suite:
|
|
45
|
+
"""解析 securityReq;失败按 §2.4 分类(解析类/支持类)拒绝。"""
|
|
46
|
+
if security_req is None or not security_req.strip():
|
|
47
|
+
raise SuiteParseError("securityReq 为空")
|
|
48
|
+
parts = security_req.split("-")
|
|
49
|
+
if len(parts) != 3 or parts[0] != "WOP":
|
|
50
|
+
raise SuiteParseError(
|
|
51
|
+
"securityReq 格式错误:应为 WOP-<密钥算法>-<摘要算法> 三段式,实际 %r" % security_req
|
|
52
|
+
)
|
|
53
|
+
_, key_alg, digest_alg = parts
|
|
54
|
+
if key_alg not in _KEY_ALGORITHMS:
|
|
55
|
+
raise UnsupportedSuiteError("不支持的密钥算法:%s" % key_alg)
|
|
56
|
+
if digest_alg not in _DIGEST_ALGORITHMS:
|
|
57
|
+
raise UnsupportedSuiteError("不支持的摘要算法:%s" % digest_alg)
|
|
58
|
+
family, key_bits = _KEY_ALGORITHMS[key_alg]
|
|
59
|
+
digest_family, digest_tag = _DIGEST_ALGORITHMS[digest_alg]
|
|
60
|
+
# I5:国际/国密跨族组合禁止(§2.3)
|
|
61
|
+
if family != digest_family:
|
|
62
|
+
raise UnsupportedSuiteError(
|
|
63
|
+
"跨族算法组合被拒绝:%s(密钥族 %s 与摘要族 %s 不一致)"
|
|
64
|
+
% (security_req, family, digest_family)
|
|
65
|
+
)
|
|
66
|
+
if family == "RSA":
|
|
67
|
+
key_wrap_alg = _FAMILY_KEY_WRAP[key_bits]
|
|
68
|
+
else:
|
|
69
|
+
key_wrap_alg = "SM2"
|
|
70
|
+
return Suite(
|
|
71
|
+
security_req=security_req,
|
|
72
|
+
family=family,
|
|
73
|
+
key_bits=key_bits,
|
|
74
|
+
digest_alg=digest_alg,
|
|
75
|
+
digest_tag=digest_tag,
|
|
76
|
+
sign_alg=_FAMILY_SIGN[family],
|
|
77
|
+
message_alg=_FAMILY_MESSAGE_ALG[family],
|
|
78
|
+
key_wrap_alg=key_wrap_alg,
|
|
79
|
+
)
|