crossmool 1.3.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.
- crossmool/__init__.py +11 -0
- crossmool/der.py +84 -0
- crossmool/encoding.py +56 -0
- crossmool/sm2_util.py +497 -0
- crossmool/sm3_util.py +117 -0
- crossmool/sm4_util.py +186 -0
- crossmool-1.3.0.dist-info/METADATA +106 -0
- crossmool-1.3.0.dist-info/RECORD +10 -0
- crossmool-1.3.0.dist-info/WHEEL +5 -0
- crossmool-1.3.0.dist-info/top_level.txt +1 -0
crossmool/__init__.py
ADDED
crossmool/der.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Minimal DER reader to parse SPKI (X.509) and PKCS8 used by crossmool JS/Java utils.
|
|
3
|
+
This is a small port of the project's JS `DerReader` used to extract raw EC keys.
|
|
4
|
+
"""
|
|
5
|
+
from typing import Tuple
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DerReader:
|
|
9
|
+
def __init__(self, data: bytes):
|
|
10
|
+
self.data = data
|
|
11
|
+
self.pos = 0
|
|
12
|
+
self.len = len(data)
|
|
13
|
+
|
|
14
|
+
def _read_length(self) -> int:
|
|
15
|
+
first = self._read(1)[0]
|
|
16
|
+
if first & 0x80 == 0:
|
|
17
|
+
return first
|
|
18
|
+
num = first & 0x7F
|
|
19
|
+
val = 0
|
|
20
|
+
for _ in range(num):
|
|
21
|
+
val = (val << 8) | self._read(1)[0]
|
|
22
|
+
return val
|
|
23
|
+
|
|
24
|
+
def _read(self, n: int) -> bytes:
|
|
25
|
+
if self.pos + n > self.len:
|
|
26
|
+
raise ValueError('Truncated DER')
|
|
27
|
+
out = self.data[self.pos:self.pos + n]
|
|
28
|
+
self.pos += n
|
|
29
|
+
return out
|
|
30
|
+
|
|
31
|
+
def read_sequence(self) -> 'DerReader':
|
|
32
|
+
tag = self._read(1)[0]
|
|
33
|
+
if tag != 0x30:
|
|
34
|
+
raise ValueError('Expected SEQUENCE')
|
|
35
|
+
length = self._read_length()
|
|
36
|
+
content = self._read(length)
|
|
37
|
+
return DerReader(content)
|
|
38
|
+
|
|
39
|
+
def read_integer_bytes(self) -> bytes:
|
|
40
|
+
tag = self._read(1)[0]
|
|
41
|
+
if tag != 0x02:
|
|
42
|
+
raise ValueError('Expected INTEGER')
|
|
43
|
+
length = self._read_length()
|
|
44
|
+
return self._read(length)
|
|
45
|
+
|
|
46
|
+
def read_oid(self) -> str:
|
|
47
|
+
tag = self._read(1)[0]
|
|
48
|
+
if tag != 0x06:
|
|
49
|
+
raise ValueError('Expected OID')
|
|
50
|
+
length = self._read_length()
|
|
51
|
+
oid_bytes = self._read(length)
|
|
52
|
+
# decode OID
|
|
53
|
+
first = oid_bytes[0]
|
|
54
|
+
oid_nodes = [str(first // 40), str(first % 40)]
|
|
55
|
+
value = 0
|
|
56
|
+
for b in oid_bytes[1:]:
|
|
57
|
+
value = (value << 7) | (b & 0x7F)
|
|
58
|
+
if not (b & 0x80):
|
|
59
|
+
oid_nodes.append(str(value))
|
|
60
|
+
value = 0
|
|
61
|
+
return '.'.join(oid_nodes)
|
|
62
|
+
|
|
63
|
+
def read_bit_string(self) -> Tuple[int, bytes]:
|
|
64
|
+
tag = self._read(1)[0]
|
|
65
|
+
if tag != 0x03:
|
|
66
|
+
raise ValueError('Expected BIT STRING')
|
|
67
|
+
length = self._read_length()
|
|
68
|
+
content = self._read(length)
|
|
69
|
+
unused_bits = content[0]
|
|
70
|
+
data = content[1:]
|
|
71
|
+
return unused_bits, data
|
|
72
|
+
|
|
73
|
+
def read_octet_string(self) -> bytes:
|
|
74
|
+
tag = self._read(1)[0]
|
|
75
|
+
if tag != 0x04:
|
|
76
|
+
raise ValueError('Expected OCTET STRING')
|
|
77
|
+
length = self._read_length()
|
|
78
|
+
return self._read(length)
|
|
79
|
+
|
|
80
|
+
def read(self, n: int) -> bytes:
|
|
81
|
+
return self._read(n)
|
|
82
|
+
|
|
83
|
+
def eof(self) -> bool:
|
|
84
|
+
return self.pos >= self.len
|
crossmool/encoding.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def is_hex_string(s: str) -> bool:
|
|
6
|
+
if not isinstance(s, str):
|
|
7
|
+
return False
|
|
8
|
+
s = s.strip()
|
|
9
|
+
return bool(re.fullmatch(r"[0-9a-fA-F]+", s))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def is_base64_string(s: str) -> bool:
|
|
13
|
+
if not isinstance(s, str):
|
|
14
|
+
return False
|
|
15
|
+
s = s.strip()
|
|
16
|
+
try:
|
|
17
|
+
# len check: base64 length divisible by 4 (with padding)
|
|
18
|
+
if len(s) % 4 != 0:
|
|
19
|
+
return False
|
|
20
|
+
base64.b64decode(s, validate=True)
|
|
21
|
+
return True
|
|
22
|
+
except Exception:
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def hex_to_bytes(hex_str: str) -> bytes:
|
|
27
|
+
return bytes.fromhex(hex_str.strip())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def bytes_to_hex(b: bytes) -> str:
|
|
31
|
+
return b.hex()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def base64_to_bytes(s: str) -> bytes:
|
|
35
|
+
return base64.b64decode(s)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def bytes_to_base64(b: bytes) -> str:
|
|
39
|
+
return base64.b64encode(b).decode('ascii')
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def detect_string_encoding(s: str) -> str:
|
|
43
|
+
s = s.strip()
|
|
44
|
+
if is_hex_string(s):
|
|
45
|
+
return 'hex'
|
|
46
|
+
if is_base64_string(s):
|
|
47
|
+
return 'base64'
|
|
48
|
+
return 'unknown'
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def utf8_to_bytes(s: str) -> bytes:
|
|
52
|
+
return s.encode('utf-8')
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def bytes_to_utf8(b: bytes) -> str:
|
|
56
|
+
return b.decode('utf-8')
|
crossmool/sm2_util.py
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
from gmssl import sm2, func
|
|
2
|
+
from crossmool.encoding import (
|
|
3
|
+
is_hex_string,
|
|
4
|
+
is_base64_string,
|
|
5
|
+
hex_to_bytes,
|
|
6
|
+
bytes_to_hex,
|
|
7
|
+
base64_to_bytes,
|
|
8
|
+
bytes_to_base64,
|
|
9
|
+
utf8_to_bytes,
|
|
10
|
+
)
|
|
11
|
+
from crossmool.der import DerReader
|
|
12
|
+
|
|
13
|
+
# SM2 signature/verification user ID (kept identical to Java BouncyCastle)
|
|
14
|
+
USER_ID = b"1234567812345678"
|
|
15
|
+
|
|
16
|
+
def _ensure_uncompressed_pubhex(hexstr: str) -> str:
|
|
17
|
+
"""Ensure the public key is in uncompressed form (04 + X + Y, 66 bytes / 130 hex chars)."""
|
|
18
|
+
s = hexstr.strip().lower()
|
|
19
|
+
if len(s) == 128:
|
|
20
|
+
return '04' + s
|
|
21
|
+
if len(s) == 130 and s.startswith('04'):
|
|
22
|
+
return s
|
|
23
|
+
raise ValueError('Unsupported public key hex length')
|
|
24
|
+
|
|
25
|
+
def sm2_public_key_to_raw_hex(key_input) -> str:
|
|
26
|
+
"""
|
|
27
|
+
Convert a public key to raw uncompressed form (04 + X + Y, 66 bytes / 130 hex chars).
|
|
28
|
+
Supports X.509 SPKI DER (HEX/Base64/bytes) and raw 64/128-char HEX.
|
|
29
|
+
"""
|
|
30
|
+
if isinstance(key_input, bytes):
|
|
31
|
+
b = key_input
|
|
32
|
+
else:
|
|
33
|
+
s = key_input.strip()
|
|
34
|
+
if is_hex_string(s):
|
|
35
|
+
b = hex_to_bytes(s)
|
|
36
|
+
elif is_base64_string(s):
|
|
37
|
+
b = base64_to_bytes(s)
|
|
38
|
+
else:
|
|
39
|
+
return _ensure_uncompressed_pubhex(s)
|
|
40
|
+
|
|
41
|
+
# X.509 SPKI DER format (starts with 0x30)
|
|
42
|
+
if len(b) > 2 and b[0] == 0x30:
|
|
43
|
+
try:
|
|
44
|
+
spki = DerReader(b).read_sequence()
|
|
45
|
+
# Read algorithm identifier (SEQUENCE)
|
|
46
|
+
alg = spki.read_sequence()
|
|
47
|
+
try:
|
|
48
|
+
_ = alg.read_oid() # EC OID
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
51
|
+
try:
|
|
52
|
+
_ = alg.read_oid() # Curve OID
|
|
53
|
+
except Exception:
|
|
54
|
+
pass
|
|
55
|
+
# Read public key BIT STRING
|
|
56
|
+
unused_bits, data = spki.read_bit_string()
|
|
57
|
+
if unused_bits != 0:
|
|
58
|
+
raise ValueError('Unsupported BIT STRING with unused bits')
|
|
59
|
+
raw = bytes_to_hex(data)
|
|
60
|
+
return _ensure_uncompressed_pubhex(raw)
|
|
61
|
+
except Exception as e:
|
|
62
|
+
raise ValueError(f'Failed to parse X.509 public key: {e}')
|
|
63
|
+
|
|
64
|
+
# fallback: assume hex string
|
|
65
|
+
return _ensure_uncompressed_pubhex(bytes_to_hex(b))
|
|
66
|
+
|
|
67
|
+
def sm2_private_key_to_raw_hex(key_input) -> str:
|
|
68
|
+
"""
|
|
69
|
+
Convert a private key to raw 32-byte HEX form.
|
|
70
|
+
Supports PKCS#8 DER (HEX/Base64/bytes) and raw 64-char HEX.
|
|
71
|
+
"""
|
|
72
|
+
if isinstance(key_input, bytes):
|
|
73
|
+
b = key_input
|
|
74
|
+
else:
|
|
75
|
+
s = key_input.strip()
|
|
76
|
+
if is_hex_string(s):
|
|
77
|
+
if s.startswith('30'):
|
|
78
|
+
b = hex_to_bytes(s)
|
|
79
|
+
else:
|
|
80
|
+
if len(s) == 64:
|
|
81
|
+
return s.lower()
|
|
82
|
+
b = hex_to_bytes(s)
|
|
83
|
+
elif is_base64_string(s):
|
|
84
|
+
b = base64_to_bytes(s)
|
|
85
|
+
else:
|
|
86
|
+
raise ValueError('Unsupported private key format')
|
|
87
|
+
|
|
88
|
+
if len(b) > 2 and b[0] == 0x30:
|
|
89
|
+
# parse PKCS#8 DER format
|
|
90
|
+
try:
|
|
91
|
+
pki = DerReader(b).read_sequence()
|
|
92
|
+
_ = pki.read_integer_bytes() # version
|
|
93
|
+
_ = pki.read_sequence() # algorithm
|
|
94
|
+
private_octets = pki.read_octet_string()
|
|
95
|
+
# Parse ECPrivateKey from OCTET STRING
|
|
96
|
+
ec = DerReader(private_octets).read_sequence()
|
|
97
|
+
_ = ec.read_integer_bytes() # version
|
|
98
|
+
d_octets = ec.read_octet_string() # private key bytes
|
|
99
|
+
d_hex = bytes_to_hex(d_octets)
|
|
100
|
+
if len(d_hex) < 64:
|
|
101
|
+
d_hex = d_hex.rjust(64, '0')
|
|
102
|
+
return d_hex
|
|
103
|
+
except Exception as e:
|
|
104
|
+
raise ValueError(f'Failed to parse PKCS#8 private key: {e}')
|
|
105
|
+
|
|
106
|
+
# fallback: assume raw bytes
|
|
107
|
+
hex_result = bytes_to_hex(b)
|
|
108
|
+
if len(hex_result) < 64:
|
|
109
|
+
hex_result = hex_result.rjust(64, '0')
|
|
110
|
+
return hex_result
|
|
111
|
+
|
|
112
|
+
def _der_encode_int(i_bytes: bytes) -> bytes:
|
|
113
|
+
"""DER-encode an INTEGER."""
|
|
114
|
+
bi = i_bytes.lstrip(b'\x00')
|
|
115
|
+
if not bi:
|
|
116
|
+
bi = b'\x00'
|
|
117
|
+
if bi[0] & 0x80:
|
|
118
|
+
bi = b'\x00' + bi
|
|
119
|
+
length = len(bi)
|
|
120
|
+
if length < 128:
|
|
121
|
+
return b'\x02' + bytes([length]) + bi
|
|
122
|
+
len_bytes = length.to_bytes((length.bit_length() + 7) // 8, 'big')
|
|
123
|
+
return b'\x02' + bytes([0x80 | len(len_bytes)]) + len_bytes + bi
|
|
124
|
+
|
|
125
|
+
def _der_encode_sequence(elements: bytes) -> bytes:
|
|
126
|
+
"""DER-encode a SEQUENCE."""
|
|
127
|
+
length = len(elements)
|
|
128
|
+
if length < 128:
|
|
129
|
+
return b'\x30' + bytes([length]) + elements
|
|
130
|
+
len_bytes = length.to_bytes((length.bit_length() + 7) // 8, 'big')
|
|
131
|
+
return b'\x30' + bytes([0x80 | len(len_bytes)]) + len_bytes + elements
|
|
132
|
+
|
|
133
|
+
def raw_rs_to_der(r_hex: str, s_hex: str) -> bytes:
|
|
134
|
+
"""Convert raw r||s into a DER-encoded signature."""
|
|
135
|
+
r = bytes.fromhex(r_hex)
|
|
136
|
+
s = bytes.fromhex(s_hex)
|
|
137
|
+
r_enc = _der_encode_int(r)
|
|
138
|
+
s_enc = _der_encode_int(s)
|
|
139
|
+
return _der_encode_sequence(r_enc + s_enc)
|
|
140
|
+
|
|
141
|
+
# ────────────────── DER key encoding helpers ──────────────────
|
|
142
|
+
|
|
143
|
+
OID_EC_PUBLIC_KEY = '1.2.840.10045.2.1'
|
|
144
|
+
OID_SM2_CURVE = '1.2.156.10197.1.301'
|
|
145
|
+
|
|
146
|
+
def _der_encode_oid(oid: str) -> bytes:
|
|
147
|
+
"""DER encode an OID string."""
|
|
148
|
+
parts = [int(x) for x in oid.split('.')]
|
|
149
|
+
result = [parts[0] * 40 + parts[1]]
|
|
150
|
+
for v in parts[2:]:
|
|
151
|
+
stack = [v & 0x7f]
|
|
152
|
+
v >>= 7
|
|
153
|
+
while v > 0:
|
|
154
|
+
stack.insert(0, (v & 0x7f) | 0x80)
|
|
155
|
+
v >>= 7
|
|
156
|
+
result.extend(stack)
|
|
157
|
+
content = bytes(result)
|
|
158
|
+
if len(content) < 128:
|
|
159
|
+
return b'\x06' + bytes([len(content)]) + content
|
|
160
|
+
len_bytes = len(content).to_bytes((len(content).bit_length() + 7) // 8, 'big')
|
|
161
|
+
return b'\x06' + bytes([0x80 | len(len_bytes)]) + len_bytes + content
|
|
162
|
+
|
|
163
|
+
def _der_encode_octet_string(data: bytes) -> bytes:
|
|
164
|
+
if len(data) < 128:
|
|
165
|
+
return b'\x04' + bytes([len(data)]) + data
|
|
166
|
+
len_bytes = len(data).to_bytes((len(data).bit_length() + 7) // 8, 'big')
|
|
167
|
+
return b'\x04' + bytes([0x80 | len(len_bytes)]) + len_bytes + data
|
|
168
|
+
|
|
169
|
+
def _der_encode_bit_string(data: bytes, unused_bits: int = 0) -> bytes:
|
|
170
|
+
content = bytes([unused_bits]) + data
|
|
171
|
+
if len(content) < 128:
|
|
172
|
+
return b'\x03' + bytes([len(content)]) + content
|
|
173
|
+
len_bytes = len(content).to_bytes((len(content).bit_length() + 7) // 8, 'big')
|
|
174
|
+
return b'\x03' + bytes([0x80 | len(len_bytes)]) + len_bytes + content
|
|
175
|
+
|
|
176
|
+
def _der_encode_context_specific(tag_number: int, content: bytes) -> bytes:
|
|
177
|
+
tag = 0xa0 | tag_number
|
|
178
|
+
if len(content) < 128:
|
|
179
|
+
return bytes([tag, len(content)]) + content
|
|
180
|
+
len_bytes = len(content).to_bytes((len(content).bit_length() + 7) // 8, 'big')
|
|
181
|
+
return bytes([tag, 0x80 | len(len_bytes)]) + len_bytes + content
|
|
182
|
+
|
|
183
|
+
def _encode_public_key_to_der(pub_hex: str) -> bytes:
|
|
184
|
+
"""Encode raw-hex public key (130 chars, 04+X+Y) to X.509 SPKI DER."""
|
|
185
|
+
pub_bytes = bytes.fromhex(pub_hex) # 65 bytes
|
|
186
|
+
alg_seq = _der_encode_sequence(
|
|
187
|
+
_der_encode_oid(OID_EC_PUBLIC_KEY) + _der_encode_oid(OID_SM2_CURVE)
|
|
188
|
+
)
|
|
189
|
+
return _der_encode_sequence(alg_seq + _der_encode_bit_string(pub_bytes, 0))
|
|
190
|
+
|
|
191
|
+
def _encode_private_key_to_der(priv_hex: str, pub_hex: str) -> bytes:
|
|
192
|
+
"""Encode raw-hex keys to PKCS#8 PrivateKeyInfo DER."""
|
|
193
|
+
d = bytes.fromhex(priv_hex) # 32 bytes
|
|
194
|
+
pub = bytes.fromhex(pub_hex) # 65 bytes
|
|
195
|
+
# ECPrivateKey (SEC1)
|
|
196
|
+
ec_priv = _der_encode_sequence(
|
|
197
|
+
_der_encode_int(b'\x01') +
|
|
198
|
+
_der_encode_octet_string(d) +
|
|
199
|
+
_der_encode_context_specific(1, _der_encode_bit_string(pub, 0))
|
|
200
|
+
)
|
|
201
|
+
# AlgorithmIdentifier
|
|
202
|
+
alg_seq = _der_encode_sequence(
|
|
203
|
+
_der_encode_oid(OID_EC_PUBLIC_KEY) + _der_encode_oid(OID_SM2_CURVE)
|
|
204
|
+
)
|
|
205
|
+
return _der_encode_sequence(
|
|
206
|
+
_der_encode_int(b'\x00') + alg_seq + _der_encode_octet_string(ec_priv)
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def der_to_raw_rs(der_sig: bytes) -> (str, str):
|
|
210
|
+
"""Convert a DER-encoded signature back to r||s (strip DER INTEGER sign padding)."""
|
|
211
|
+
d = DerReader(der_sig).read_sequence()
|
|
212
|
+
r = d.read_integer_bytes()
|
|
213
|
+
s = d.read_integer_bytes()
|
|
214
|
+
# strip the leading 0x00 that DER INTEGER may add to represent a positive number
|
|
215
|
+
r = r.lstrip(b'\x00') or b'\x00'
|
|
216
|
+
s = s.lstrip(b'\x00') or b'\x00'
|
|
217
|
+
r_hex = bytes_to_hex(r).rjust(64, '0')
|
|
218
|
+
s_hex = bytes_to_hex(s).rjust(64, '0')
|
|
219
|
+
return r_hex, s_hex
|
|
220
|
+
|
|
221
|
+
def _new_crypt(private_key='', public_key_hex=None, mode=0):
|
|
222
|
+
"""
|
|
223
|
+
Construct a gmssl CryptSM2 instance while bypassing its __init__ lstrip("04")
|
|
224
|
+
bug (which strips every leading '0'/'4' char instead of only the "04" prefix).
|
|
225
|
+
gmssl internally expects the public key as 128 hex chars (X+Y, no 04 prefix).
|
|
226
|
+
"""
|
|
227
|
+
crypt = sm2.CryptSM2(private_key=private_key, public_key='', mode=mode)
|
|
228
|
+
if public_key_hex is not None:
|
|
229
|
+
pub = public_key_hex.strip()
|
|
230
|
+
if pub.startswith('04') and len(pub) == 130:
|
|
231
|
+
pub = pub[2:]
|
|
232
|
+
crypt.public_key = pub
|
|
233
|
+
return crypt
|
|
234
|
+
|
|
235
|
+
class SM2Util:
|
|
236
|
+
"""
|
|
237
|
+
SM2 encryption/decryption/signature utility class.
|
|
238
|
+
Compatible with Java BouncyCastle SM2Engine (C1C3C2) and SM2Signer.
|
|
239
|
+
|
|
240
|
+
Fully based on the low-level gmssl library to guarantee interop with Java BouncyCastle.
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
@staticmethod
|
|
244
|
+
def generate_keypair_raw() -> (str, str):
|
|
245
|
+
"""Generate a raw keypair (private_hex, public_hex)."""
|
|
246
|
+
priv = func.random_hex(64)
|
|
247
|
+
temp_crypt = sm2.CryptSM2(private_key=priv, public_key='04' + '00' * 64)
|
|
248
|
+
pub = temp_crypt._kg(int(priv, 16), temp_crypt.ecc_table['g'])
|
|
249
|
+
if not pub.startswith('04'):
|
|
250
|
+
pub = '04' + pub
|
|
251
|
+
return priv.lower(), pub.lower()
|
|
252
|
+
|
|
253
|
+
@staticmethod
|
|
254
|
+
def generate_keypair_hex() -> dict:
|
|
255
|
+
"""Generate a HEX keypair, returning {'publicKey': ..., 'privateKey': ...} (public key 130 chars, private key 64 chars)."""
|
|
256
|
+
priv_hex, pub_hex = SM2Util.generate_keypair_raw()
|
|
257
|
+
return {'publicKey': pub_hex, 'privateKey': priv_hex}
|
|
258
|
+
|
|
259
|
+
@staticmethod
|
|
260
|
+
def generate_keypair_base64() -> dict:
|
|
261
|
+
"""Generate a Base64 keypair, returning {'publicKey': ..., 'privateKey': ...}."""
|
|
262
|
+
priv_hex, pub_hex = SM2Util.generate_keypair_raw()
|
|
263
|
+
return {
|
|
264
|
+
'publicKey': bytes_to_base64(hex_to_bytes(pub_hex)),
|
|
265
|
+
'privateKey': bytes_to_base64(hex_to_bytes(priv_hex))
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
@staticmethod
|
|
269
|
+
def encrypt_to_hex(content: bytes, public_key) -> str:
|
|
270
|
+
"""
|
|
271
|
+
Encrypt SM2 plaintext (C1C3C2 format, compatible with Java/Go).
|
|
272
|
+
|
|
273
|
+
Standard C1C3C2 layout: 04||X(32)||Y(32)||C3(32)||C2(msg_len).
|
|
274
|
+
gmssl's built-in encrypt returns a 64-byte C1 (no 04 prefix);
|
|
275
|
+
we prepend the 04 prefix here to match the standard Go/Java C1C3C2 format.
|
|
276
|
+
"""
|
|
277
|
+
pub_hex = sm2_public_key_to_raw_hex(public_key)
|
|
278
|
+
crypt = _new_crypt(private_key='', public_key_hex=pub_hex, mode=1)
|
|
279
|
+
enc_bytes = crypt.encrypt(content)
|
|
280
|
+
# gmssl: C1(64 bytes) + C3(32) + C2(msg_len)
|
|
281
|
+
# -> standard: C1(65 bytes with 04) + C3(32) + C2(msg_len)
|
|
282
|
+
enc_bytes = b'\x04' + enc_bytes[:64] + enc_bytes[64:]
|
|
283
|
+
return enc_bytes.hex()
|
|
284
|
+
|
|
285
|
+
@staticmethod
|
|
286
|
+
def encrypt_to_bytes(content: bytes, public_key) -> bytes:
|
|
287
|
+
"""Encrypt SM2 plaintext and return the C1C3C2 ciphertext bytes."""
|
|
288
|
+
return bytes.fromhex(SM2Util.encrypt_to_hex(content, public_key))
|
|
289
|
+
|
|
290
|
+
@staticmethod
|
|
291
|
+
def decrypt_from_bytes(cipher_bytes: bytes, private_key) -> bytes:
|
|
292
|
+
"""Decrypt C1C3C2 ciphertext bytes and return the plaintext bytes."""
|
|
293
|
+
return SM2Util.decrypt_from_hex(cipher_bytes.hex(), private_key)
|
|
294
|
+
|
|
295
|
+
@staticmethod
|
|
296
|
+
def encrypt_to_base64(content: bytes, public_key) -> str:
|
|
297
|
+
hex_out = SM2Util.encrypt_to_hex(content, public_key)
|
|
298
|
+
return bytes_to_base64(bytes.fromhex(hex_out))
|
|
299
|
+
|
|
300
|
+
@staticmethod
|
|
301
|
+
def decrypt_from_hex(cipher_hex: str, private_key, public_key=None) -> bytes:
|
|
302
|
+
"""
|
|
303
|
+
Decrypt SM2 ciphertext (C1C3C2 format, compatible with Java/Go/Python).
|
|
304
|
+
|
|
305
|
+
Supports two C1C3C2 layouts:
|
|
306
|
+
- standard (Java/Go): C1(65 bytes, 04+X+Y) + C3(32) + C2(msg_len)
|
|
307
|
+
- gmssl format: C1(64 bytes, X+Y) + C3(32) + C2(msg_len)
|
|
308
|
+
Auto-detects and handles both.
|
|
309
|
+
"""
|
|
310
|
+
priv_hex = sm2_private_key_to_raw_hex(private_key)
|
|
311
|
+
|
|
312
|
+
# get or derive the public key
|
|
313
|
+
if public_key is not None:
|
|
314
|
+
pub_hex = sm2_public_key_to_raw_hex(public_key)
|
|
315
|
+
else:
|
|
316
|
+
temp_crypt = sm2.CryptSM2(private_key=priv_hex, public_key='04' + '00' * 64)
|
|
317
|
+
pub_hex = temp_crypt._kg(int(priv_hex, 16), temp_crypt.ecc_table['g'])
|
|
318
|
+
if not pub_hex.startswith('04'):
|
|
319
|
+
pub_hex = '04' + pub_hex
|
|
320
|
+
|
|
321
|
+
cipher_bytes = bytes.fromhex(cipher_hex)
|
|
322
|
+
|
|
323
|
+
# gmssl's built-in decrypt expects C1 without the 04 prefix (64 bytes)
|
|
324
|
+
# if the input is the standard format (C1 starts with 04, 65 bytes), strip the 04 prefix
|
|
325
|
+
if len(cipher_bytes) > 0 and cipher_bytes[0] == 0x04:
|
|
326
|
+
# standard: 04||X(32)||Y(32)||C3(32)||C2(msg_len) = 1+32+32+32+msg_len
|
|
327
|
+
# -> gmssl: X(32)||Y(32)||C3(32)||C2(msg_len) = 32+32+32+msg_len
|
|
328
|
+
cipher_bytes = cipher_bytes[1:65] + cipher_bytes[65:97] + cipher_bytes[97:]
|
|
329
|
+
|
|
330
|
+
crypt = _new_crypt(private_key=priv_hex, public_key_hex=pub_hex, mode=1)
|
|
331
|
+
result = crypt.decrypt(cipher_bytes)
|
|
332
|
+
if isinstance(result, str):
|
|
333
|
+
result = result.encode('latin-1')
|
|
334
|
+
return bytes(result)
|
|
335
|
+
|
|
336
|
+
@staticmethod
|
|
337
|
+
def decrypt_from_base64(cipher_b64: str, private_key) -> bytes:
|
|
338
|
+
cipher_bytes = base64_to_bytes(cipher_b64)
|
|
339
|
+
return SM2Util.decrypt_from_hex(bytes_to_hex(cipher_bytes), private_key)
|
|
340
|
+
|
|
341
|
+
@staticmethod
|
|
342
|
+
def sign_to_der(content: bytes, private_key) -> bytes:
|
|
343
|
+
"""
|
|
344
|
+
Sign SM2 plaintext and return a DER-encoded signature.
|
|
345
|
+
Java: SM2Signer uses USER_ID="1234567812345678".
|
|
346
|
+
gmssl's sign_with_sm3 uses USER_ID = b"1234567812345678" by default.
|
|
347
|
+
"""
|
|
348
|
+
priv_hex = sm2_private_key_to_raw_hex(private_key)
|
|
349
|
+
|
|
350
|
+
# gmssl sign requires public_key; derive it from the private key first
|
|
351
|
+
temp_crypt = sm2.CryptSM2(private_key=priv_hex, public_key='04' + '00' * 64)
|
|
352
|
+
pub_hex = temp_crypt._kg(int(priv_hex, 16), temp_crypt.ecc_table['g'])
|
|
353
|
+
if not pub_hex.startswith('04'):
|
|
354
|
+
pub_hex = '04' + pub_hex
|
|
355
|
+
|
|
356
|
+
crypt = _new_crypt(private_key=priv_hex, public_key_hex=pub_hex)
|
|
357
|
+
# sign_with_sm3 uses SM3 hash and auto-generates random K
|
|
358
|
+
sig_raw = crypt.sign_with_sm3(content) # returns hex r||s
|
|
359
|
+
|
|
360
|
+
if len(sig_raw) % 2 != 0:
|
|
361
|
+
raise ValueError('Invalid signature length')
|
|
362
|
+
half = len(sig_raw) // 2
|
|
363
|
+
r = sig_raw[:half]
|
|
364
|
+
s = sig_raw[half:]
|
|
365
|
+
return raw_rs_to_der(r, s)
|
|
366
|
+
|
|
367
|
+
@staticmethod
|
|
368
|
+
def sign_to_hex(content: bytes, private_key) -> str:
|
|
369
|
+
return SM2Util.sign_to_der(content, private_key).hex()
|
|
370
|
+
|
|
371
|
+
@staticmethod
|
|
372
|
+
def sign_to_base64(content: bytes, private_key) -> str:
|
|
373
|
+
return bytes_to_base64(SM2Util.sign_to_der(content, private_key))
|
|
374
|
+
|
|
375
|
+
@staticmethod
|
|
376
|
+
def verify(content: bytes, public_key, signature) -> bool:
|
|
377
|
+
"""
|
|
378
|
+
Verify an SM2 signature.
|
|
379
|
+
Java: SM2Signer uses USER_ID="1234567812345678".
|
|
380
|
+
gmssl's verify uses USER_ID = b"1234567812345678" by default.
|
|
381
|
+
"""
|
|
382
|
+
pub_hex = sm2_public_key_to_raw_hex(public_key)
|
|
383
|
+
|
|
384
|
+
# signature may be hex/base64/bytes; convert to raw r||s hex
|
|
385
|
+
sig_bytes = None
|
|
386
|
+
if isinstance(signature, bytes):
|
|
387
|
+
sig_bytes = signature
|
|
388
|
+
else:
|
|
389
|
+
s = signature.strip()
|
|
390
|
+
if is_hex_string(s):
|
|
391
|
+
sig_bytes = bytes.fromhex(s)
|
|
392
|
+
elif is_base64_string(s):
|
|
393
|
+
sig_bytes = base64_to_bytes(s)
|
|
394
|
+
else:
|
|
395
|
+
raise ValueError('Unsupported signature encoding')
|
|
396
|
+
|
|
397
|
+
# if DER (starts with 0x30) decode
|
|
398
|
+
if len(sig_bytes) > 0 and sig_bytes[0] == 0x30:
|
|
399
|
+
r_hex, s_hex = der_to_raw_rs(sig_bytes)
|
|
400
|
+
sig_raw = (r_hex + s_hex)
|
|
401
|
+
else:
|
|
402
|
+
sig_raw = sig_bytes.hex()
|
|
403
|
+
|
|
404
|
+
crypt = _new_crypt(private_key=None, public_key_hex=pub_hex)
|
|
405
|
+
return crypt.verify_with_sm3(sig_raw, content)
|
|
406
|
+
|
|
407
|
+
# convenience wrappers similar to Java API
|
|
408
|
+
@staticmethod
|
|
409
|
+
def encrypt(content: str, public_key, to_base64: bool = False) -> str:
|
|
410
|
+
b = utf8_to_bytes(content)
|
|
411
|
+
return SM2Util.encrypt_to_base64(b, public_key) if to_base64 else SM2Util.encrypt_to_hex(b, public_key)
|
|
412
|
+
|
|
413
|
+
@staticmethod
|
|
414
|
+
def decrypt(cipher_text: str, private_key) -> str:
|
|
415
|
+
s = cipher_text.strip()
|
|
416
|
+
if is_hex_string(s):
|
|
417
|
+
dec = SM2Util.decrypt_from_hex(s, private_key)
|
|
418
|
+
else:
|
|
419
|
+
dec = SM2Util.decrypt_from_base64(s, private_key)
|
|
420
|
+
return dec.decode('utf-8')
|
|
421
|
+
|
|
422
|
+
@staticmethod
|
|
423
|
+
def sign(content: str, private_key, to_base64: bool = False) -> str:
|
|
424
|
+
b = utf8_to_bytes(content)
|
|
425
|
+
if to_base64:
|
|
426
|
+
return SM2Util.sign_to_base64(b, private_key)
|
|
427
|
+
return SM2Util.sign_to_hex(b, private_key)
|
|
428
|
+
|
|
429
|
+
@staticmethod
|
|
430
|
+
def verify_str(content: str, public_key, signature: str) -> bool:
|
|
431
|
+
return SM2Util.verify(utf8_to_bytes(content), public_key, signature)
|
|
432
|
+
|
|
433
|
+
# ────────────────── Encrypt+Sign / Verify+Decrypt ──────────────────
|
|
434
|
+
|
|
435
|
+
@staticmethod
|
|
436
|
+
def encrypt_sign(content: str, enc_public_key, sign_private_key, to_base64: bool = False) -> dict:
|
|
437
|
+
"""Encrypt and sign UTF-8 content. Returns {'ciphertext': ..., 'signature': ...}."""
|
|
438
|
+
ct = SM2Util.encrypt(content, enc_public_key, to_base64=to_base64)
|
|
439
|
+
sig = SM2Util.sign(ct, sign_private_key, to_base64=to_base64)
|
|
440
|
+
return {'ciphertext': ct, 'signature': sig}
|
|
441
|
+
|
|
442
|
+
@staticmethod
|
|
443
|
+
def verify_decrypt(ciphertext: str, signature: str, sign_public_key, dec_private_key) -> str:
|
|
444
|
+
"""Verify signature then decrypt. Returns UTF-8 plaintext."""
|
|
445
|
+
if not SM2Util.verify_str(ciphertext, sign_public_key, signature):
|
|
446
|
+
raise ValueError('SM2 signature verification failed')
|
|
447
|
+
return SM2Util.decrypt(ciphertext, dec_private_key)
|
|
448
|
+
|
|
449
|
+
# ────────────────── PEM Export / Import ──────────────────
|
|
450
|
+
|
|
451
|
+
@staticmethod
|
|
452
|
+
def export_public_key_pem(pub_hex: str) -> str:
|
|
453
|
+
"""Export raw-hex public key (130 chars) as PEM (X.509 SubjectPublicKeyInfo)."""
|
|
454
|
+
der = _encode_public_key_to_der(pub_hex)
|
|
455
|
+
b64 = bytes_to_base64(der)
|
|
456
|
+
lines = ['-----BEGIN PUBLIC KEY-----']
|
|
457
|
+
for i in range(0, len(b64), 64):
|
|
458
|
+
lines.append(b64[i:i + 64])
|
|
459
|
+
lines.append('-----END PUBLIC KEY-----')
|
|
460
|
+
return '\n'.join(lines)
|
|
461
|
+
|
|
462
|
+
@staticmethod
|
|
463
|
+
def export_private_key_pem(priv_hex: str, pub_hex: str) -> str:
|
|
464
|
+
"""Export raw-hex keys as PEM (PKCS#8 PrivateKeyInfo)."""
|
|
465
|
+
der = _encode_private_key_to_der(priv_hex, pub_hex)
|
|
466
|
+
b64 = bytes_to_base64(der)
|
|
467
|
+
lines = ['-----BEGIN PRIVATE KEY-----']
|
|
468
|
+
for i in range(0, len(b64), 64):
|
|
469
|
+
lines.append(b64[i:i + 64])
|
|
470
|
+
lines.append('-----END PRIVATE KEY-----')
|
|
471
|
+
return '\n'.join(lines)
|
|
472
|
+
|
|
473
|
+
@staticmethod
|
|
474
|
+
def import_public_key_pem(pem: str) -> str:
|
|
475
|
+
"""Import PEM (X.509 SPKI) and return raw-hex public key (130 chars)."""
|
|
476
|
+
begin = '-----BEGIN PUBLIC KEY-----'
|
|
477
|
+
end = '-----END PUBLIC KEY-----'
|
|
478
|
+
s = pem.find(begin)
|
|
479
|
+
e = pem.find(end)
|
|
480
|
+
if s == -1 or e == -1:
|
|
481
|
+
raise ValueError('Invalid PEM: missing PUBLIC KEY boundaries')
|
|
482
|
+
b64 = pem[s + len(begin):e].replace('\n', '').replace('\r', '').replace(' ', '')
|
|
483
|
+
der = base64_to_bytes(b64)
|
|
484
|
+
return sm2_public_key_to_raw_hex(der)
|
|
485
|
+
|
|
486
|
+
@staticmethod
|
|
487
|
+
def import_private_key_pem(pem: str) -> str:
|
|
488
|
+
"""Import PEM (PKCS#8) and return raw-hex private key (64 chars)."""
|
|
489
|
+
begin = '-----BEGIN PRIVATE KEY-----'
|
|
490
|
+
end = '-----END PRIVATE KEY-----'
|
|
491
|
+
s = pem.find(begin)
|
|
492
|
+
e = pem.find(end)
|
|
493
|
+
if s == -1 or e == -1:
|
|
494
|
+
raise ValueError('Invalid PEM: missing PRIVATE KEY boundaries')
|
|
495
|
+
b64 = pem[s + len(begin):e].replace('\n', '').replace('\r', '').replace(' ', '')
|
|
496
|
+
der = base64_to_bytes(b64)
|
|
497
|
+
return sm2_private_key_to_raw_hex(der)
|
crossmool/sm3_util.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from gmssl import sm3
|
|
2
|
+
from os import urandom
|
|
3
|
+
from crossmool.encoding import (
|
|
4
|
+
bytes_to_hex, bytes_to_base64, hex_to_bytes, base64_to_bytes,
|
|
5
|
+
is_hex_string, is_base64_string
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
class SM3Util:
|
|
9
|
+
@staticmethod
|
|
10
|
+
def digest(data: bytes) -> bytes:
|
|
11
|
+
return bytes.fromhex(sm3.sm3_hash(list(data)))
|
|
12
|
+
|
|
13
|
+
@staticmethod
|
|
14
|
+
def digest_to_hex(data: bytes) -> str:
|
|
15
|
+
return sm3.sm3_hash(list(data))
|
|
16
|
+
|
|
17
|
+
@staticmethod
|
|
18
|
+
def digest_to_bytes(data: bytes) -> bytes:
|
|
19
|
+
return SM3Util.digest(data)
|
|
20
|
+
|
|
21
|
+
@staticmethod
|
|
22
|
+
def digest_to_base64(data: bytes) -> str:
|
|
23
|
+
return bytes_to_base64(SM3Util.digest(data))
|
|
24
|
+
|
|
25
|
+
@staticmethod
|
|
26
|
+
def digest_to_str(data: bytes, to_base64: bool = False) -> str:
|
|
27
|
+
digest_bytes = SM3Util.digest(data)
|
|
28
|
+
if to_base64:
|
|
29
|
+
return bytes_to_base64(digest_bytes)
|
|
30
|
+
return bytes_to_hex(digest_bytes)
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def digest_str(input_str: str, to_base64: bool = False) -> str:
|
|
34
|
+
if not input_str or not input_str.strip():
|
|
35
|
+
raise ValueError("input_str cannot be empty")
|
|
36
|
+
data = input_str.encode('utf-8')
|
|
37
|
+
digest_bytes = SM3Util.digest(data)
|
|
38
|
+
if to_base64:
|
|
39
|
+
return bytes_to_base64(digest_bytes)
|
|
40
|
+
return bytes_to_hex(digest_bytes)
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def hmac(key: bytes, data: bytes) -> bytes:
|
|
44
|
+
block_size = 64
|
|
45
|
+
if len(key) > block_size:
|
|
46
|
+
key = SM3Util.digest(key)
|
|
47
|
+
if len(key) < block_size:
|
|
48
|
+
key = key + b"\x00" * (block_size - len(key))
|
|
49
|
+
o_key = bytes((b ^ 0x5C) for b in key)
|
|
50
|
+
i_key = bytes((b ^ 0x36) for b in key)
|
|
51
|
+
inner = sm3.sm3_hash(list(i_key + data))
|
|
52
|
+
inner_bytes = bytes.fromhex(inner)
|
|
53
|
+
outer = sm3.sm3_hash(list(o_key + inner_bytes))
|
|
54
|
+
return bytes.fromhex(outer)
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def hmac_from_str(key_str: str, data_str: str) -> bytes:
|
|
58
|
+
if not key_str or not key_str.strip():
|
|
59
|
+
raise ValueError("key_str cannot be empty")
|
|
60
|
+
if not data_str or not data_str.strip():
|
|
61
|
+
raise ValueError("data_str cannot be empty")
|
|
62
|
+
|
|
63
|
+
if is_hex_string(key_str):
|
|
64
|
+
key_bytes = hex_to_bytes(key_str)
|
|
65
|
+
elif is_base64_string(key_str):
|
|
66
|
+
key_bytes = base64_to_bytes(key_str)
|
|
67
|
+
else:
|
|
68
|
+
raise ValueError("key_str must be Hex string or Base64 string")
|
|
69
|
+
|
|
70
|
+
if is_hex_string(data_str):
|
|
71
|
+
data_bytes = hex_to_bytes(data_str)
|
|
72
|
+
elif is_base64_string(data_str):
|
|
73
|
+
data_bytes = base64_to_bytes(data_str)
|
|
74
|
+
else:
|
|
75
|
+
data_bytes = data_str.encode('utf-8')
|
|
76
|
+
|
|
77
|
+
return SM3Util.hmac(key_bytes, data_bytes)
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def hmac_to_hex(key: bytes, data: bytes) -> str:
|
|
81
|
+
return bytes_to_hex(SM3Util.hmac(key, data))
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def hmac_to_base64(key: bytes, data: bytes) -> str:
|
|
85
|
+
return bytes_to_base64(SM3Util.hmac(key, data))
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def hmac_to_str(key: bytes, data: bytes, to_base64: bool = False) -> str:
|
|
89
|
+
hmac_bytes = SM3Util.hmac(key, data)
|
|
90
|
+
if to_base64:
|
|
91
|
+
return bytes_to_base64(hmac_bytes)
|
|
92
|
+
return bytes_to_hex(hmac_bytes)
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def hmac_str(key_str: str, data_str: str, to_base64: bool = False) -> str:
|
|
96
|
+
hmac_bytes = SM3Util.hmac_from_str(key_str, data_str)
|
|
97
|
+
if to_base64:
|
|
98
|
+
return bytes_to_base64(hmac_bytes)
|
|
99
|
+
return bytes_to_hex(hmac_bytes)
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def generate_hmac_key(key_length: int = 32) -> bytes:
|
|
103
|
+
if key_length < 32:
|
|
104
|
+
raise ValueError("key_length must be >= 32")
|
|
105
|
+
return urandom(key_length)
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def generate_hmac_key_to_str(key_length: int = 32, to_base64: bool = False) -> str:
|
|
109
|
+
key_bytes = SM3Util.generate_hmac_key(key_length)
|
|
110
|
+
if to_base64:
|
|
111
|
+
return bytes_to_base64(key_bytes)
|
|
112
|
+
return bytes_to_hex(key_bytes)
|
|
113
|
+
|
|
114
|
+
@staticmethod
|
|
115
|
+
def hmac_verify(key_str: str, data_str: str, received_hmac: str) -> bool:
|
|
116
|
+
"""Verify HMAC by computing expected value and comparing."""
|
|
117
|
+
return SM3Util.hmac_str(key_str, data_str) == received_hmac
|
crossmool/sm4_util.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
from os import urandom
|
|
2
|
+
from crossmool.encoding import (
|
|
3
|
+
bytes_to_hex, hex_to_bytes, bytes_to_base64, base64_to_bytes,
|
|
4
|
+
is_hex_string, is_base64_string
|
|
5
|
+
)
|
|
6
|
+
from gmssl import sm4
|
|
7
|
+
|
|
8
|
+
GCM_IV_SIZE = 12 # 96 bits
|
|
9
|
+
GCM_TAG_SIZE = 16 # 128 bits
|
|
10
|
+
BLOCK_SIZE = 16
|
|
11
|
+
|
|
12
|
+
def _xor_bytes(a: bytes, b: bytes) -> bytes:
|
|
13
|
+
return bytes(x ^ y for x, y in zip(a, b))
|
|
14
|
+
|
|
15
|
+
def _inc32(counter: bytes) -> bytes:
|
|
16
|
+
"""GCM counter increment (increment the rightmost 32 bits)"""
|
|
17
|
+
counter_int = int.from_bytes(counter, 'big')
|
|
18
|
+
low_32 = (counter_int & 0xFFFFFFFF) + 1
|
|
19
|
+
result = (counter_int & ~0xFFFFFFFF) | (low_32 & 0xFFFFFFFF)
|
|
20
|
+
return result.to_bytes(16, 'big')
|
|
21
|
+
|
|
22
|
+
def _gf128_mul(x: int, y: int) -> int:
|
|
23
|
+
"""Multiply two elements in GF(2^128) with reduction polynomial x^128+x^7+x^2+x+1"""
|
|
24
|
+
R = 0xE1000000000000000000000000000000
|
|
25
|
+
MASK = (1 << 128) - 1
|
|
26
|
+
z = 0
|
|
27
|
+
v = y
|
|
28
|
+
for i in range(128):
|
|
29
|
+
if x & (1 << (127 - i)):
|
|
30
|
+
z ^= v
|
|
31
|
+
if v & 1:
|
|
32
|
+
v = (v >> 1) ^ R
|
|
33
|
+
else:
|
|
34
|
+
v >>= 1
|
|
35
|
+
return z & MASK
|
|
36
|
+
|
|
37
|
+
def _ghash(h: bytes, aad: bytes, ciphertext: bytes) -> bytes:
|
|
38
|
+
"""GHASH computation for GCM"""
|
|
39
|
+
h_int = int.from_bytes(h, 'big')
|
|
40
|
+
|
|
41
|
+
aad_padded = aad + b'\x00' * ((16 - len(aad) % 16) % 16)
|
|
42
|
+
ct_padded = ciphertext + b'\x00' * ((16 - len(ciphertext) % 16) % 16)
|
|
43
|
+
len_block = (len(aad) * 8).to_bytes(8, 'big') + (len(ciphertext) * 8).to_bytes(8, 'big')
|
|
44
|
+
|
|
45
|
+
y = 0
|
|
46
|
+
for i in range(0, len(aad_padded), 16):
|
|
47
|
+
y ^= int.from_bytes(aad_padded[i:i + 16], 'big')
|
|
48
|
+
y = _gf128_mul(y, h_int)
|
|
49
|
+
for i in range(0, len(ct_padded), 16):
|
|
50
|
+
y ^= int.from_bytes(ct_padded[i:i + 16], 'big')
|
|
51
|
+
y = _gf128_mul(y, h_int)
|
|
52
|
+
y ^= int.from_bytes(len_block, 'big')
|
|
53
|
+
y = _gf128_mul(y, h_int)
|
|
54
|
+
|
|
55
|
+
return y.to_bytes(16, 'big')
|
|
56
|
+
|
|
57
|
+
def _sm4_ecb_encrypt_block(key: bytes, block: bytes) -> bytes:
|
|
58
|
+
"""Encrypt a single 16-byte block using SM4 ECB (no padding)"""
|
|
59
|
+
crypt = sm4.CryptSM4()
|
|
60
|
+
crypt.set_key(key, sm4.SM4_ENCRYPT)
|
|
61
|
+
return crypt.crypt_ecb(block)[:16]
|
|
62
|
+
|
|
63
|
+
def _sm4_ctr_encrypt(key: bytes, counter: bytes, plaintext: bytes) -> bytes:
|
|
64
|
+
"""SM4 CTR mode encryption"""
|
|
65
|
+
result = bytearray()
|
|
66
|
+
for i in range(0, len(plaintext), BLOCK_SIZE):
|
|
67
|
+
keystream = _sm4_ecb_encrypt_block(key, counter)
|
|
68
|
+
block = plaintext[i:i + BLOCK_SIZE]
|
|
69
|
+
result.extend(_xor_bytes(keystream[:len(block)], block))
|
|
70
|
+
counter = _inc32(counter)
|
|
71
|
+
return bytes(result)
|
|
72
|
+
|
|
73
|
+
def _detect_and_decode(s: str) -> bytes:
|
|
74
|
+
"""Auto-detect Hex/Base64 encoding and decode to bytes"""
|
|
75
|
+
if is_hex_string(s):
|
|
76
|
+
return hex_to_bytes(s)
|
|
77
|
+
if is_base64_string(s):
|
|
78
|
+
return base64_to_bytes(s)
|
|
79
|
+
raise ValueError("Input string must be Hex or Base64 encoded")
|
|
80
|
+
|
|
81
|
+
class SM4Util:
|
|
82
|
+
@staticmethod
|
|
83
|
+
def generate_key() -> bytes:
|
|
84
|
+
return urandom(16)
|
|
85
|
+
|
|
86
|
+
@staticmethod
|
|
87
|
+
def generate_key_hex() -> str:
|
|
88
|
+
return bytes_to_hex(SM4Util.generate_key())
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def generate_key_base64() -> str:
|
|
92
|
+
return bytes_to_base64(SM4Util.generate_key())
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def parse_hex_key(hex_key: str) -> bytes:
|
|
96
|
+
return hex_to_bytes(hex_key)
|
|
97
|
+
|
|
98
|
+
@staticmethod
|
|
99
|
+
def encrypt_gcm(key: bytes, plaintext: bytes, aad: bytes = b'', to_base64: bool = False) -> str:
|
|
100
|
+
"""
|
|
101
|
+
SM4 GCM encrypt; returns Hex(IV[12] + ciphertext + Tag[16]) or Base64.
|
|
102
|
+
"""
|
|
103
|
+
if len(key) != 16:
|
|
104
|
+
raise ValueError('SM4 key must be 16 bytes')
|
|
105
|
+
|
|
106
|
+
iv = urandom(GCM_IV_SIZE)
|
|
107
|
+
j0 = iv + b'\x00\x00\x00\x01'
|
|
108
|
+
h = _sm4_ecb_encrypt_block(key, b'\x00' * 16)
|
|
109
|
+
ciphertext = _sm4_ctr_encrypt(key, _inc32(j0), plaintext)
|
|
110
|
+
ghash_result = _ghash(h, aad, ciphertext)
|
|
111
|
+
j0_encrypted = _sm4_ecb_encrypt_block(key, j0)
|
|
112
|
+
tag = _xor_bytes(ghash_result, j0_encrypted)
|
|
113
|
+
|
|
114
|
+
combined = iv + ciphertext + tag
|
|
115
|
+
if to_base64:
|
|
116
|
+
return bytes_to_base64(combined)
|
|
117
|
+
return bytes_to_hex(combined)
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def decrypt_gcm(key: bytes, ciphertext_str: str, aad: bytes = b'') -> bytes:
|
|
121
|
+
"""
|
|
122
|
+
SM4 GCM decrypt; auto-detects Hex/Base64 input encoding.
|
|
123
|
+
"""
|
|
124
|
+
if len(key) != 16:
|
|
125
|
+
raise ValueError('SM4 key must be 16 bytes')
|
|
126
|
+
|
|
127
|
+
combined = _detect_and_decode(ciphertext_str)
|
|
128
|
+
if len(combined) < GCM_IV_SIZE + GCM_TAG_SIZE:
|
|
129
|
+
raise ValueError('Invalid GCM ciphertext: too short')
|
|
130
|
+
|
|
131
|
+
iv = combined[:GCM_IV_SIZE]
|
|
132
|
+
tag = combined[-GCM_TAG_SIZE:]
|
|
133
|
+
ciphertext = combined[GCM_IV_SIZE:-GCM_TAG_SIZE]
|
|
134
|
+
|
|
135
|
+
j0 = iv + b'\x00\x00\x00\x01'
|
|
136
|
+
h = _sm4_ecb_encrypt_block(key, b'\x00' * 16)
|
|
137
|
+
ghash_result = _ghash(h, aad, ciphertext)
|
|
138
|
+
j0_encrypted = _sm4_ecb_encrypt_block(key, j0)
|
|
139
|
+
computed_tag = _xor_bytes(ghash_result, j0_encrypted)
|
|
140
|
+
|
|
141
|
+
if computed_tag != tag:
|
|
142
|
+
raise ValueError('SM4 GCM decryption failed (authentication failed)')
|
|
143
|
+
|
|
144
|
+
return _sm4_ctr_encrypt(key, _inc32(j0), ciphertext)
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
def encrypt_gcm_hex(key: bytes, plaintext: bytes, aad: bytes = b'') -> str:
|
|
148
|
+
return SM4Util.encrypt_gcm(key, plaintext, aad, to_base64=False)
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def decrypt_gcm_from_hex(key: bytes, hex_str: str, aad: bytes = b'') -> bytes:
|
|
152
|
+
if not is_hex_string(hex_str):
|
|
153
|
+
raise ValueError("Input must be Hex encoded")
|
|
154
|
+
return SM4Util.decrypt_gcm(key, hex_str, aad)
|
|
155
|
+
|
|
156
|
+
@staticmethod
|
|
157
|
+
def encrypt_gcm_string(key: bytes, plain_text: str, to_base64: bool = False) -> str:
|
|
158
|
+
return SM4Util.encrypt_gcm(key, plain_text.encode('utf-8'), to_base64=to_base64)
|
|
159
|
+
|
|
160
|
+
@staticmethod
|
|
161
|
+
def decrypt_gcm_to_string(key: bytes, ciphertext_str: str) -> str:
|
|
162
|
+
return SM4Util.decrypt_gcm(key, ciphertext_str).decode('utf-8')
|
|
163
|
+
|
|
164
|
+
if __name__ == '__main__':
|
|
165
|
+
key = SM4Util.generate_key()
|
|
166
|
+
hex_key = SM4Util.generate_key_hex()
|
|
167
|
+
print(f'Key (HEX): {hex_key}')
|
|
168
|
+
|
|
169
|
+
plain_text = 'Hello, 這是国密SM4測試數據'
|
|
170
|
+
print(f'Plaintext: {plain_text}')
|
|
171
|
+
|
|
172
|
+
encrypted_hex = SM4Util.encrypt_gcm_string(key, plain_text)
|
|
173
|
+
print(f'Encrypted (Hex): {encrypted_hex}')
|
|
174
|
+
|
|
175
|
+
encrypted_b64 = SM4Util.encrypt_gcm_string(key, plain_text, to_base64=True)
|
|
176
|
+
print(f'Encrypted (Base64): {encrypted_b64}')
|
|
177
|
+
|
|
178
|
+
decrypted = SM4Util.decrypt_gcm_to_string(key, encrypted_hex)
|
|
179
|
+
print(f'Decrypted from Hex: {decrypted}')
|
|
180
|
+
|
|
181
|
+
decrypted_b64 = SM4Util.decrypt_gcm_to_string(key, encrypted_b64)
|
|
182
|
+
print(f'Decrypted from Base64: {decrypted_b64}')
|
|
183
|
+
|
|
184
|
+
assert decrypted == plain_text, 'Decryption failed!'
|
|
185
|
+
assert decrypted_b64 == plain_text, 'Decryption from Base64 failed!'
|
|
186
|
+
print('GCM test passed!')
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: crossmool
|
|
3
|
+
Version: 1.3.0
|
|
4
|
+
Summary: Cross-language SM2/SM3/SM4 cryptographic utilities, interoperable with the Java/Go/Rust/C/C++/C#/JavaScript implementations
|
|
5
|
+
Author: crossmool
|
|
6
|
+
License-Expression: LGPL-3.0-only
|
|
7
|
+
Project-URL: Homepage, https://gitee.com/iilic/crossmool
|
|
8
|
+
Project-URL: Repository, https://gitee.com/iilic/crossmool
|
|
9
|
+
Keywords: sm2,sm3,sm4,cryptography,gmssl,sm-crypto,chinese-cryptography,encryption
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Security :: Cryptography
|
|
22
|
+
Requires-Python: >=3.8
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
Requires-Dist: gmssl>=3.2.2
|
|
25
|
+
Provides-Extra: test
|
|
26
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
27
|
+
|
|
28
|
+
# crossmool
|
|
29
|
+
|
|
30
|
+
Cross-language SM2/SM3/SM4 cryptographic utilities. The Python implementation is fully interoperable with the Java/Go/Rust/C/C++/C#/JavaScript implementations in the [crossmool](https://gitee.com/iilic/crossmool) project.
|
|
31
|
+
|
|
32
|
+
## Features
|
|
33
|
+
|
|
34
|
+
- **SM2** — asymmetric encryption/decryption and signature (C1C3C2 ciphertext, X.509 / PKCS#8 DER keys, DER signatures).
|
|
35
|
+
- **SM3** — hash and HMAC-SM3.
|
|
36
|
+
- **SM4** — symmetric GCM encryption/decryption.
|
|
37
|
+
- Hex and Base64 encodings supported throughout.
|
|
38
|
+
- Guaranteed cross-language interop (simplified Chinese, traditional Chinese and English Unicode input).
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install crossmool
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Requires Python 3.8+.
|
|
47
|
+
|
|
48
|
+
## Quick start
|
|
49
|
+
|
|
50
|
+
### SM2 — encrypt / decrypt / sign / verify
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from crossmool import SM2Util
|
|
54
|
+
|
|
55
|
+
priv, pub = SM2Util.generate_keypair_raw()
|
|
56
|
+
|
|
57
|
+
# encrypt then decrypt (Hex)
|
|
58
|
+
cipher = SM2Util.encrypt('hello', pub)
|
|
59
|
+
assert SM2Util.decrypt(cipher, priv) == 'hello'
|
|
60
|
+
|
|
61
|
+
# sign then verify
|
|
62
|
+
sig = SM2Util.sign(cipher, priv)
|
|
63
|
+
assert SM2Util.verify_str(cipher, pub, sig)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### SM3 — hash / HMAC
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from crossmool import SM3Util
|
|
70
|
+
|
|
71
|
+
digest = SM3Util.digest_str('hello 国密')
|
|
72
|
+
key = SM3Util.generate_hmac_key_to_str()
|
|
73
|
+
mac = SM3Util.hmac_str(key, 'hello 国密')
|
|
74
|
+
assert SM3Util.hmac_verify(key, 'hello 国密', mac)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### SM4 — GCM encrypt / decrypt
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from crossmool import SM4Util
|
|
81
|
+
|
|
82
|
+
key = SM4Util.generate_key()
|
|
83
|
+
cipher = SM4Util.encrypt_gcm_string(key, 'hello 国密')
|
|
84
|
+
plain = SM4Util.decrypt_gcm_to_string(key, cipher)
|
|
85
|
+
assert plain == 'hello 国密'
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Development
|
|
89
|
+
|
|
90
|
+
Editable install for development:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
cd <repo-root>
|
|
94
|
+
pip install -e python/crossmool
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Run the test suite:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
cd python/crossmool
|
|
101
|
+
pytest -q
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
LGPL-v3. See the [LICENSE](https://gitee.com/iilic/crossmool) file for details.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
crossmool/__init__.py,sha256=tvacNRkKMlEiwnsI9iCrUxay76-CRrG79Ero76ddKWI,200
|
|
2
|
+
crossmool/der.py,sha256=JU_nsecWDM3GIew5skqbHpT5zccRznOHSzt5esf4k1U,2591
|
|
3
|
+
crossmool/encoding.py,sha256=8TFB878y7VJDAIzz4ic1SabrFNK5LggjuR3Qr6RVy8Q,1195
|
|
4
|
+
crossmool/sm2_util.py,sha256=xDsfreRd8Gb6ckRTjhqQpF7Q-16_JhUvOcRLNpvM-n0,20641
|
|
5
|
+
crossmool/sm3_util.py,sha256=vpncOd9FJ__fyrohNDDipGnU9xK28WF5UvYYx3cgiCM,4123
|
|
6
|
+
crossmool/sm4_util.py,sha256=cyM4WniSngDGK-jJzhMeshJJ8OHJD4CENIQQ2EVT3Cc,6625
|
|
7
|
+
crossmool-1.3.0.dist-info/METADATA,sha256=Hp0Gy-KaKjRcedNCOJv_TtWXjQ5Srj6z7zXqlKFPpZ4,3109
|
|
8
|
+
crossmool-1.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
crossmool-1.3.0.dist-info/top_level.txt,sha256=8ct3CncA4ykxKOlngLTUo6I02vZ2YVnKrSHaUwtgd6I,10
|
|
10
|
+
crossmool-1.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
crossmool
|