cryptnox-id-cli 1.0.2__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.
- cryptnox_id_cli/__init__.py +22 -0
- cryptnox_id_cli/__main__.py +6 -0
- cryptnox_id_cli/applets/__init__.py +2 -0
- cryptnox_id_cli/applets/fido/__init__.py +7 -0
- cryptnox_id_cli/applets/fido/authdata.py +81 -0
- cryptnox_id_cli/applets/fido/constants.py +155 -0
- cryptnox_id_cli/applets/fido/ctap.py +430 -0
- cryptnox_id_cli/applets/fido/errors.py +90 -0
- cryptnox_id_cli/applets/fido/pinproto.py +166 -0
- cryptnox_id_cli/applets/genuine/__init__.py +12 -0
- cryptnox_id_cli/applets/genuine/constants.py +32 -0
- cryptnox_id_cli/applets/genuine/genuine.py +85 -0
- cryptnox_id_cli/applets/genuine/verify.py +150 -0
- cryptnox_id_cli/applets/mifare/__init__.py +10 -0
- cryptnox_id_cli/applets/mifare/desfire.py +353 -0
- cryptnox_id_cli/applets/mifare/ev2.py +421 -0
- cryptnox_id_cli/applets/piv/__init__.py +9 -0
- cryptnox_id_cli/applets/piv/admin.py +182 -0
- cryptnox_id_cli/applets/piv/apt.py +58 -0
- cryptnox_id_cli/applets/piv/constants.py +66 -0
- cryptnox_id_cli/applets/piv/keyimport.py +244 -0
- cryptnox_id_cli/applets/piv/objects.py +126 -0
- cryptnox_id_cli/applets/piv/perso.py +138 -0
- cryptnox_id_cli/applets/piv/piv.py +171 -0
- cryptnox_id_cli/applets/piv/preperso.py +174 -0
- cryptnox_id_cli/applets/piv/profiles.py +486 -0
- cryptnox_id_cli/applets/piv/slots.py +44 -0
- cryptnox_id_cli/cli/__init__.py +1 -0
- cryptnox_id_cli/cli/commands/__init__.py +1 -0
- cryptnox_id_cli/cli/commands/apdu.py +123 -0
- cryptnox_id_cli/cli/commands/doctor.py +144 -0
- cryptnox_id_cli/cli/commands/factory.py +312 -0
- cryptnox_id_cli/cli/commands/fido.py +729 -0
- cryptnox_id_cli/cli/commands/genuine.py +192 -0
- cryptnox_id_cli/cli/commands/info.py +94 -0
- cryptnox_id_cli/cli/commands/mifare.py +998 -0
- cryptnox_id_cli/cli/commands/piv.py +2558 -0
- cryptnox_id_cli/cli/commands/readers.py +64 -0
- cryptnox_id_cli/cli/commands/report.py +217 -0
- cryptnox_id_cli/cli/commands/shell.py +130 -0
- cryptnox_id_cli/cli/context.py +71 -0
- cryptnox_id_cli/cli/dryrun.py +181 -0
- cryptnox_id_cli/cli/main.py +117 -0
- cryptnox_id_cli/crypto/__init__.py +1 -0
- cryptnox_id_cli/crypto/attestation.py +169 -0
- cryptnox_id_cli/crypto/csr.py +135 -0
- cryptnox_id_cli/crypto/piv_objects.py +79 -0
- cryptnox_id_cli/crypto/x509util.py +37 -0
- cryptnox_id_cli/output/__init__.py +5 -0
- cryptnox_id_cli/output/render.py +106 -0
- cryptnox_id_cli/secrets/__init__.py +5 -0
- cryptnox_id_cli/secrets/redaction.py +141 -0
- cryptnox_id_cli/secrets/resolver.py +93 -0
- cryptnox_id_cli/state/__init__.py +19 -0
- cryptnox_id_cli/state/detector.py +273 -0
- cryptnox_id_cli/state/model.py +147 -0
- cryptnox_id_cli/transport/__init__.py +28 -0
- cryptnox_id_cli/transport/apdu.py +72 -0
- cryptnox_id_cli/transport/elevation.py +165 -0
- cryptnox_id_cli/transport/errors.py +185 -0
- cryptnox_id_cli/transport/pcsc.py +351 -0
- cryptnox_id_cli/transport/scp02.py +247 -0
- cryptnox_id_cli/transport/scp03.py +213 -0
- cryptnox_id_cli/trust/__init__.py +90 -0
- cryptnox_id_cli/trust/genuine/cryptnox-attestation-ca.pem +18 -0
- cryptnox_id_cli/trust/genuine/cryptnox-dlt-cards-ca.pem +17 -0
- cryptnox_id_cli/trust/genuine/cryptnox-genuineness-ca.pem +18 -0
- cryptnox_id_cli/trust/genuine/cryptnox-intermediate-ca-2.pem +19 -0
- cryptnox_id_cli/trust/genuine/cryptnox-intermediate-ca.pem +19 -0
- cryptnox_id_cli/trust/genuine/cryptnox-root-ca.pem +19 -0
- cryptnox_id_cli/util/__init__.py +1 -0
- cryptnox_id_cli/util/hexutil.py +36 -0
- cryptnox_id_cli/util/tlv.py +144 -0
- cryptnox_id_cli-1.0.2.dist-info/METADATA +227 -0
- cryptnox_id_cli-1.0.2.dist-info/RECORD +79 -0
- cryptnox_id_cli-1.0.2.dist-info/WHEEL +5 -0
- cryptnox_id_cli-1.0.2.dist-info/entry_points.txt +4 -0
- cryptnox_id_cli-1.0.2.dist-info/licenses/LICENSE +165 -0
- cryptnox_id_cli-1.0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""SCP03 secure channel (GlobalPlatform Amendment D), used for PIV admin commands.
|
|
2
|
+
|
|
3
|
+
Implements the SP 800-108 counter-mode KDF (AES-CMAC PRF), session-key derivation,
|
|
4
|
+
card/host cryptograms, C-MAC chaining and C-ENC command wrapping. Response security
|
|
5
|
+
(R-MAC/R-ENC) is not requested by default (security level 0x03 = C-MAC + C-DECRYPTION).
|
|
6
|
+
|
|
7
|
+
Correctness is proven end-to-end by the card: if our derived keys are wrong, the card
|
|
8
|
+
cryptogram check fails locally and EXTERNAL AUTHENTICATE is refused by the card.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
17
|
+
from cryptography.hazmat.primitives.cmac import CMAC
|
|
18
|
+
|
|
19
|
+
from cryptnox_id_cli.transport.apdu import APDU, Response
|
|
20
|
+
from cryptnox_id_cli.transport.errors import Scp03Error
|
|
21
|
+
|
|
22
|
+
_BLOCK = 16
|
|
23
|
+
|
|
24
|
+
# KDF derivation constants (GP Amd D, Table 4-1).
|
|
25
|
+
_DERIV_CARD_CRYPTOGRAM = 0x00
|
|
26
|
+
_DERIV_HOST_CRYPTOGRAM = 0x01
|
|
27
|
+
_DERIV_S_ENC = 0x04
|
|
28
|
+
_DERIV_S_MAC = 0x06
|
|
29
|
+
_DERIV_S_RMAC = 0x07
|
|
30
|
+
|
|
31
|
+
# Security-level bits (EXTERNAL AUTHENTICATE P1).
|
|
32
|
+
SL_C_MAC = 0x01
|
|
33
|
+
SL_C_DECRYPTION = 0x02
|
|
34
|
+
SL_R_MAC = 0x10
|
|
35
|
+
SL_R_ENCRYPTION = 0x20
|
|
36
|
+
SL_CMAC_CENC = SL_C_MAC | SL_C_DECRYPTION # 0x03
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _aes_cmac(key: bytes, data: bytes) -> bytes:
|
|
40
|
+
c = CMAC(algorithms.AES(key))
|
|
41
|
+
c.update(data)
|
|
42
|
+
return c.finalize()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _aes_ecb(key: bytes, block: bytes) -> bytes:
|
|
46
|
+
enc = Cipher(algorithms.AES(key), modes.ECB()).encryptor() # noqa: S305 - single-block ICV
|
|
47
|
+
return enc.update(block) + enc.finalize()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _aes_cbc_encrypt(key: bytes, iv: bytes, data: bytes) -> bytes:
|
|
51
|
+
enc = Cipher(algorithms.AES(key), modes.CBC(iv)).encryptor()
|
|
52
|
+
return enc.update(data) + enc.finalize()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _kdf(key: bytes, constant: int, context: bytes, length_bits: int) -> bytes:
|
|
56
|
+
"""SP 800-108 counter-mode KDF with an AES-CMAC PRF (SCP03 flavour)."""
|
|
57
|
+
out = b""
|
|
58
|
+
counter = 1
|
|
59
|
+
while len(out) * 8 < length_bits:
|
|
60
|
+
block = (
|
|
61
|
+
b"\x00" * 11
|
|
62
|
+
+ bytes([constant])
|
|
63
|
+
+ b"\x00"
|
|
64
|
+
+ length_bits.to_bytes(2, "big")
|
|
65
|
+
+ bytes([counter])
|
|
66
|
+
+ context
|
|
67
|
+
)
|
|
68
|
+
out += _aes_cmac(key, block)
|
|
69
|
+
counter += 1
|
|
70
|
+
return out[: length_bits // 8]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _pad80(data: bytes) -> bytes:
|
|
74
|
+
"""ISO 7816-4 / SCP03 padding: 0x80 then 0x00s to the next block boundary."""
|
|
75
|
+
padded = data + b"\x80"
|
|
76
|
+
if len(padded) % _BLOCK:
|
|
77
|
+
padded += b"\x00" * (_BLOCK - len(padded) % _BLOCK)
|
|
78
|
+
return padded
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class Scp03Keys:
|
|
83
|
+
enc: bytes
|
|
84
|
+
mac: bytes
|
|
85
|
+
dek: bytes
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def same(cls, key: bytes) -> Scp03Keys:
|
|
89
|
+
return cls(key, key, key)
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def key_bits(self) -> int:
|
|
93
|
+
return len(self.enc) * 8
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Scp03Session:
|
|
97
|
+
"""An open SCP03 session: holds session keys and wraps/unwraps command APDUs."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, s_enc: bytes, s_mac: bytes, s_rmac: bytes, security_level: int) -> None:
|
|
100
|
+
self.s_enc = s_enc
|
|
101
|
+
self.s_mac = s_mac
|
|
102
|
+
self.s_rmac = s_rmac
|
|
103
|
+
self.security_level = security_level
|
|
104
|
+
self.mac_chaining = b"\x00" * _BLOCK
|
|
105
|
+
self.encrypt_counter = 1
|
|
106
|
+
|
|
107
|
+
def _encrypt(self, data: bytes) -> bytes:
|
|
108
|
+
if not data:
|
|
109
|
+
return data
|
|
110
|
+
icv = _aes_ecb(self.s_enc, self.encrypt_counter.to_bytes(_BLOCK, "big"))
|
|
111
|
+
return _aes_cbc_encrypt(self.s_enc, icv, _pad80(data))
|
|
112
|
+
|
|
113
|
+
def wrap(self, apdu: APDU) -> bytes:
|
|
114
|
+
"""Return the wrapped command bytes (CLA|0x04, optional C-ENC, C-MAC appended).
|
|
115
|
+
|
|
116
|
+
Supports short and extended Lc (the pre-perso BULK is ~316 bytes -> extended).
|
|
117
|
+
The C-MAC is computed over the command exactly as transmitted (incl. the Lc
|
|
118
|
+
field), then its first 8 bytes are appended.
|
|
119
|
+
"""
|
|
120
|
+
data = apdu.data
|
|
121
|
+
if self.security_level & SL_C_DECRYPTION:
|
|
122
|
+
data = self._encrypt(data)
|
|
123
|
+
lc = len(data) + 8 # +8 for the C-MAC
|
|
124
|
+
cla = apdu.cla | 0x04
|
|
125
|
+
if lc <= 0xFF:
|
|
126
|
+
header = bytes([cla, apdu.ins, apdu.p1, apdu.p2, lc])
|
|
127
|
+
le_bytes = (
|
|
128
|
+
b"" if apdu.le is None else bytes([0x00 if apdu.le >= 256 else apdu.le & 0xFF])
|
|
129
|
+
)
|
|
130
|
+
else: # extended length
|
|
131
|
+
header = bytes([cla, apdu.ins, apdu.p1, apdu.p2, 0x00, (lc >> 8) & 0xFF, lc & 0xFF])
|
|
132
|
+
le_bytes = b"" if apdu.le is None else (apdu.le & 0xFFFF).to_bytes(2, "big")
|
|
133
|
+
full_mac = _aes_cmac(self.s_mac, self.mac_chaining + header + data)
|
|
134
|
+
self.mac_chaining = full_mac
|
|
135
|
+
self.encrypt_counter += 1
|
|
136
|
+
return header + data + full_mac[:8] + le_bytes
|
|
137
|
+
|
|
138
|
+
def unwrap(self, resp: Response) -> Response:
|
|
139
|
+
"""With level 0x03 the response is plaintext; pass it through."""
|
|
140
|
+
if self.security_level & (SL_R_MAC | SL_R_ENCRYPTION):
|
|
141
|
+
raise Scp03Error("R-MAC/R-ENC responses are not implemented")
|
|
142
|
+
return resp
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def derive_session_keys(
|
|
146
|
+
keys: Scp03Keys, host_challenge: bytes, card_challenge: bytes
|
|
147
|
+
) -> tuple[bytes, bytes, bytes]:
|
|
148
|
+
context = host_challenge + card_challenge
|
|
149
|
+
bits = keys.key_bits
|
|
150
|
+
s_enc = _kdf(keys.enc, _DERIV_S_ENC, context, bits)
|
|
151
|
+
s_mac = _kdf(keys.mac, _DERIV_S_MAC, context, bits)
|
|
152
|
+
s_rmac = _kdf(keys.mac, _DERIV_S_RMAC, context, bits)
|
|
153
|
+
return s_enc, s_mac, s_rmac
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _cryptogram(s_mac: bytes, constant: int, host_challenge: bytes, card_challenge: bytes) -> bytes:
|
|
157
|
+
return _kdf(s_mac, constant, host_challenge + card_challenge, 64)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def open_channel(
|
|
161
|
+
transmit,
|
|
162
|
+
keys: Scp03Keys,
|
|
163
|
+
*,
|
|
164
|
+
key_version: int = 0,
|
|
165
|
+
security_level: int = SL_CMAC_CENC,
|
|
166
|
+
host_challenge: bytes | None = None,
|
|
167
|
+
init_response: Response | None = None,
|
|
168
|
+
) -> Scp03Session:
|
|
169
|
+
"""Run INITIALIZE UPDATE + EXTERNAL AUTHENTICATE. ``transmit(APDU|bytes)->Response``.
|
|
170
|
+
|
|
171
|
+
If ``init_response`` is given (the caller already issued INITIALIZE UPDATE to
|
|
172
|
+
auto-detect the SCP version), it is reused with the matching ``host_challenge``
|
|
173
|
+
instead of issuing a second one.
|
|
174
|
+
|
|
175
|
+
Raises Scp03Error on a wrong key (card cryptogram mismatch) or a card rejection.
|
|
176
|
+
"""
|
|
177
|
+
if init_response is None:
|
|
178
|
+
host_challenge = host_challenge or os.urandom(8)
|
|
179
|
+
resp = transmit(APDU(0x80, 0x50, key_version, 0x00, data=host_challenge, le=256))
|
|
180
|
+
else:
|
|
181
|
+
if host_challenge is None:
|
|
182
|
+
raise Scp03Error("host_challenge is required when init_response is supplied.")
|
|
183
|
+
resp = init_response
|
|
184
|
+
if not resp.ok:
|
|
185
|
+
raise Scp03Error(f"INITIALIZE UPDATE rejected (SW={resp.sw_hex()}).")
|
|
186
|
+
body = resp.data
|
|
187
|
+
if len(body) < 29:
|
|
188
|
+
raise Scp03Error(f"INITIALIZE UPDATE response too short ({len(body)} bytes).")
|
|
189
|
+
card_challenge = body[13:21]
|
|
190
|
+
card_cryptogram = body[21:29]
|
|
191
|
+
|
|
192
|
+
s_enc, s_mac, s_rmac = derive_session_keys(keys, host_challenge, card_challenge)
|
|
193
|
+
|
|
194
|
+
expected = _cryptogram(s_mac, _DERIV_CARD_CRYPTOGRAM, host_challenge, card_challenge)
|
|
195
|
+
if expected != card_cryptogram:
|
|
196
|
+
raise Scp03Error("Card cryptogram mismatch - the SCP03 keys are wrong for this card.")
|
|
197
|
+
|
|
198
|
+
session = Scp03Session(s_enc, s_mac, s_rmac, security_level)
|
|
199
|
+
host_cryptogram = _cryptogram(s_mac, _DERIV_HOST_CRYPTOGRAM, host_challenge, card_challenge)
|
|
200
|
+
|
|
201
|
+
# EXTERNAL AUTHENTICATE: CLA 0x84, Lc=0x10 (8-byte host cryptogram + 8-byte C-MAC).
|
|
202
|
+
header = bytes([0x84, 0x82, security_level, 0x00, 0x10])
|
|
203
|
+
full_mac = _aes_cmac(session.s_mac, session.mac_chaining + header + host_cryptogram)
|
|
204
|
+
session.mac_chaining = full_mac
|
|
205
|
+
ext_auth = header + host_cryptogram + full_mac[:8]
|
|
206
|
+
|
|
207
|
+
resp = transmit(ext_auth)
|
|
208
|
+
if not resp.ok:
|
|
209
|
+
raise Scp03Error(
|
|
210
|
+
f"EXTERNAL AUTHENTICATE rejected (SW={resp.sw_hex()}); "
|
|
211
|
+
"keys or security level may be wrong."
|
|
212
|
+
)
|
|
213
|
+
return session
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Pinned trust anchors for offline attestation-chain verification.
|
|
2
|
+
|
|
3
|
+
PEM certificates dropped into ``trust/genuine/`` are bundled into the package (and the
|
|
4
|
+
PyInstaller binary) and loaded here. Anchors are classified by inspection, not by filename:
|
|
5
|
+
|
|
6
|
+
* self-issued (issuer == subject) **CA** certificates -> **roots** (pinned anchors)
|
|
7
|
+
* non-self-issued **CA** certificates -> **intermediates**
|
|
8
|
+
|
|
9
|
+
A relying party trusts a chain only because it terminates at one of these pinned roots — never
|
|
10
|
+
because a root happens to appear in a chain read off a card. If the directory is empty, the
|
|
11
|
+
loaders return empty lists and callers must report that the chain cannot be verified rather
|
|
12
|
+
than silently passing.
|
|
13
|
+
|
|
14
|
+
The bundled set is the Cryptnox production PKI: ``CN=CRYPTNOX ROOT CA`` (the anchor) plus its
|
|
15
|
+
intermediates. Only the root grants trust; the intermediates are shipped so chain building
|
|
16
|
+
works offline when a card omits part of its chain.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
from importlib.resources import files
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from cryptography import x509
|
|
26
|
+
from cryptography.hazmat.primitives import serialization
|
|
27
|
+
|
|
28
|
+
_GENUINE_PKG = "cryptnox_id_cli.trust"
|
|
29
|
+
_GENUINE_DIR = "genuine"
|
|
30
|
+
|
|
31
|
+
#: Point this at a directory of PEM anchors to use a trust store without rebuilding
|
|
32
|
+
#: the package - e.g. anchors fetched from the Cryptnox PKI, or a lab/dev CA. Its
|
|
33
|
+
#: certificates are added to whatever ships bundled; it never disables the bundled set.
|
|
34
|
+
TRUST_DIR_ENV = "CRYPTNOX_TRUST_DIR"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _parse_pem_dir(root) -> list[x509.Certificate]:
|
|
38
|
+
"""Parse every ``*.pem`` in a directory (each file may hold >1 certificate)."""
|
|
39
|
+
out: list[x509.Certificate] = []
|
|
40
|
+
if not root.is_dir():
|
|
41
|
+
return out
|
|
42
|
+
for entry in sorted(root.iterdir(), key=lambda e: e.name):
|
|
43
|
+
if entry.name.lower().endswith(".pem") and entry.is_file():
|
|
44
|
+
out.extend(x509.load_pem_x509_certificates(entry.read_bytes()))
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _load_all(extra_dir: str | os.PathLike[str] | None = None) -> list[x509.Certificate]:
|
|
49
|
+
"""Bundled anchors, plus any from ``$CRYPTNOX_TRUST_DIR`` and ``extra_dir``."""
|
|
50
|
+
out = _parse_pem_dir(files(_GENUINE_PKG).joinpath(_GENUINE_DIR))
|
|
51
|
+
for candidate in (os.environ.get(TRUST_DIR_ENV), extra_dir):
|
|
52
|
+
if candidate:
|
|
53
|
+
out.extend(_parse_pem_dir(Path(candidate)))
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _is_ca(cert: x509.Certificate) -> bool:
|
|
58
|
+
try:
|
|
59
|
+
return bool(cert.extensions.get_extension_for_class(x509.BasicConstraints).value.ca)
|
|
60
|
+
except x509.ExtensionNotFound:
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _der(cert: x509.Certificate) -> bytes:
|
|
65
|
+
return cert.public_bytes(serialization.Encoding.DER)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def load_anchors(
|
|
69
|
+
extra_dir: str | os.PathLike[str] | None = None,
|
|
70
|
+
) -> tuple[list[bytes], list[bytes]]:
|
|
71
|
+
"""Return ``(roots_der, intermediates_der)`` from the configured trust store.
|
|
72
|
+
|
|
73
|
+
Sources, in order and additive: the bundled ``trust/genuine/``, the directory named
|
|
74
|
+
by ``$CRYPTNOX_TRUST_DIR``, then ``extra_dir``. Duplicates are collapsed, so the same
|
|
75
|
+
anchor supplied twice is harmless. Roots are self-issued CAs (the pinned anchors);
|
|
76
|
+
intermediates are the remaining CAs. An empty result means callers must report that
|
|
77
|
+
the chain cannot be verified - never that it passed.
|
|
78
|
+
"""
|
|
79
|
+
roots: list[bytes] = []
|
|
80
|
+
intermediates: list[bytes] = []
|
|
81
|
+
seen: set[bytes] = set()
|
|
82
|
+
for cert in _load_all(extra_dir):
|
|
83
|
+
if not _is_ca(cert):
|
|
84
|
+
continue # ignore non-CA material in the trust dir
|
|
85
|
+
der = _der(cert)
|
|
86
|
+
if der in seen:
|
|
87
|
+
continue
|
|
88
|
+
seen.add(der)
|
|
89
|
+
(roots if cert.issuer == cert.subject else intermediates).append(der)
|
|
90
|
+
return roots, intermediates
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# subject : C=CH, ST=GENEVA, O=CRYPTNOX SA, CN=CRYPTNOX ATTESTATION CA
|
|
2
|
+
# issuer : C=CH, ST=GENEVA, O=CRYPTNOX SA, CN=CRYPTNOX INTERMEDIATE CA #2
|
|
3
|
+
# not after : Dec 30 14:55:00 2049 GMT
|
|
4
|
+
# sha256 (DER): DCC01FEBE28E6A5B21AA443B3A5EE0DAEB284F0F5D612AD815586F56EAEC46E5
|
|
5
|
+
-----BEGIN CERTIFICATE-----
|
|
6
|
+
MIICLDCCAbKgAwIBAgIIUEneQtSD39UwCgYIKoZIzj0EAwMwWjELMAkGA1UEBhMC
|
|
7
|
+
Q0gxDzANBgNVBAgTBkdFTkVWQTEUMBIGA1UEChMLQ1JZUFROT1ggU0ExJDAiBgNV
|
|
8
|
+
BAMMG0NSWVBUTk9YIElOVEVSTUVESUFURSBDQSAjMjAeFw0yNjA2MjkxNDU1MDBa
|
|
9
|
+
Fw00OTEyMzAxNDU1MDBaMFYxCzAJBgNVBAYTAkNIMQ8wDQYDVQQIDAZHRU5FVkEx
|
|
10
|
+
FDASBgNVBAoMC0NSWVBUTk9YIFNBMSAwHgYDVQQDDBdDUllQVE5PWCBBVFRFU1RB
|
|
11
|
+
VElPTiBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABM2dnWQbl7PHnP3Y5bMP
|
|
12
|
+
AD3kuhOj28tryE9uZSYcXFYJeqoAegRtMdoN0vzBopeDMevvfHPE5EODpJ5N1q7A
|
|
13
|
+
wrWjZjBkMB8GA1UdIwQYMBaAFLOIBaait0D7A0fxVv5E2aPBeqmZMBIGA1UdEwEB
|
|
14
|
+
/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBRK99VS1M/S3cuq
|
|
15
|
+
KYeBk5E0w7TfXzAKBggqhkjOPQQDAwNoADBlAjBLhsipCcuwxUZpGwfNW/UJNdhq
|
|
16
|
+
td4PShNDeOLKS1YG2miJ3Q+HEv9oH4cM9cYV1hwCMQDUEGDsatjZXcnNCN4CFrRO
|
|
17
|
+
sXrBy3Z3Z4Iipw9uFWfCJOLvGQA8cW+tXRFJ+DPxICA=
|
|
18
|
+
-----END CERTIFICATE-----
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# subject : C=CH, ST=GENEVA, O=CRYPTNOX SA, CN=CRYPTNOX DLT CARDS CA
|
|
2
|
+
# issuer : C=CH, ST=GENEVA, O=CRYPTNOX SA, CN=CRYPTNOX INTERMEDIATE CA #2
|
|
3
|
+
# not after : Dec 30 23:59:00 2049 GMT
|
|
4
|
+
# sha256 (DER): 474A05AB2FA4543CD4C03C2EBDC8140A29496E97F6D0F53FF72FEDC9D10BE3E0
|
|
5
|
+
-----BEGIN CERTIFICATE-----
|
|
6
|
+
MIICBDCCAYmgAwIBAgIIE3MdP3JzJZMwCgYIKoZIzj0EAwQwWjELMAkGA1UEBhMC
|
|
7
|
+
Q0gxDzANBgNVBAgTBkdFTkVWQTEUMBIGA1UEChMLQ1JZUFROT1ggU0ExJDAiBgNV
|
|
8
|
+
BAMMG0NSWVBUTk9YIElOVEVSTUVESUFURSBDQSAjMjAeFw0yMzA0MDkxNjQ4MDBa
|
|
9
|
+
Fw00OTEyMzAyMzU5MDBaMFQxCzAJBgNVBAYTAkNIMQ8wDQYDVQQIEwZHRU5FVkEx
|
|
10
|
+
FDASBgNVBAoTC0NSWVBUTk9YIFNBMR4wHAYDVQQDExVDUllQVE5PWCBETFQgQ0FS
|
|
11
|
+
RFMgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARkehEsKs9aSbUrMhy9z1qe
|
|
12
|
+
owf8TM0zqni5uAVd2E0DrtqwLMcgp4DLH/KAr1B3SmzcFX76I16iUxGkK0z1fYhh
|
|
13
|
+
oz8wPTAPBgNVHRMECDAGAQH/AgEAMB0GA1UdDgQWBBRgo23D82CwTUbrY+ZzvVla
|
|
14
|
+
jaO+RjALBgNVHQ8EBAMCAQYwCgYIKoZIzj0EAwQDaQAwZgIxANVDF6ucXBS9+KlA
|
|
15
|
+
3Po/tlMbVz5qttJgTGJhiGIbUqdIcSj5O9EdqBc0cl+G/bYZywIxAJSjBG01D0Qa
|
|
16
|
+
L1qs8BGfmndV6g0vISjQYhXuwDQxVewN5It12yHlzlx0o+AIETxu+A==
|
|
17
|
+
-----END CERTIFICATE-----
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# subject : C=CH, ST=GENEVA, O=CRYPTNOX SA, CN=CRYPTNOX GENUINENESS CA
|
|
2
|
+
# issuer : C=CH, ST=GENEVA, O=CRYPTNOX SA, CN=CRYPTNOX INTERMEDIATE CA #2
|
|
3
|
+
# not after : Dec 30 15:07:00 2049 GMT
|
|
4
|
+
# sha256 (DER): 6B4F7DDE0366DF547FEB4AC66F79CD7C5AD56AB4A8EAD678EEBA293F48DBFDBB
|
|
5
|
+
-----BEGIN CERTIFICATE-----
|
|
6
|
+
MIICKzCCAbKgAwIBAgIIGQjzKGS8xbMwCgYIKoZIzj0EAwMwWjELMAkGA1UEBhMC
|
|
7
|
+
Q0gxDzANBgNVBAgTBkdFTkVWQTEUMBIGA1UEChMLQ1JZUFROT1ggU0ExJDAiBgNV
|
|
8
|
+
BAMMG0NSWVBUTk9YIElOVEVSTUVESUFURSBDQSAjMjAeFw0yNjA2MjkxNTA3MDBa
|
|
9
|
+
Fw00OTEyMzAxNTA3MDBaMFYxCzAJBgNVBAYTAkNIMQ8wDQYDVQQIDAZHRU5FVkEx
|
|
10
|
+
FDASBgNVBAoMC0NSWVBUTk9YIFNBMSAwHgYDVQQDDBdDUllQVE5PWCBHRU5VSU5F
|
|
11
|
+
TkVTUyBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABEs4rWlxYxtChOWR3mTO
|
|
12
|
+
9P1jlthn2Cv8p4mCOMI/uvm+bjbUVrtozu9wbtcY2kJUaRqKVzOI3H8enCea5L+v
|
|
13
|
+
X/ujZjBkMB8GA1UdIwQYMBaAFLOIBaait0D7A0fxVv5E2aPBeqmZMBIGA1UdEwEB
|
|
14
|
+
/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBTx6oiDpR/k6AzR
|
|
15
|
+
BjV2/PYc4viJrzAKBggqhkjOPQQDAwNnADBkAjBYqQrqfT/RvWzd4VmstWLf2Sba
|
|
16
|
+
WAyoTW1VOD6YlJkAt/WlWa5xXFH5oSTjS7io3sMCMHsF+jDdVUe+3DmzOhJZa70b
|
|
17
|
+
+b0n0wAG9cWuGqUp9u1bqpYbvVIqXSKqFzG5vcpTMw==
|
|
18
|
+
-----END CERTIFICATE-----
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# subject : C=CH, ST=GENEVA, O=CRYPTNOX SA, CN=CRYPTNOX INTERMEDIATE CA #2
|
|
2
|
+
# issuer : C=CH, ST=GENEVA, O=CRYPTNOX SA, CN=CRYPTNOX ROOT CA
|
|
3
|
+
# not after : Dec 30 23:59:00 2049 GMT
|
|
4
|
+
# sha256 (DER): 94DB7D0A3EE6FAAB717C249E28370EF50E6CDF57DC930C878641D57E989B69BC
|
|
5
|
+
-----BEGIN CERTIFICATE-----
|
|
6
|
+
MIICPzCCAaGgAwIBAgIIXrvLbag/zUAwCgYIKoZIzj0EAwMwTzELMAkGA1UEBhMC
|
|
7
|
+
Q0gxDzANBgNVBAgTBkdFTkVWQTEUMBIGA1UEChMLQ1JZUFROT1ggU0ExGTAXBgNV
|
|
8
|
+
BAMTEENSWVBUTk9YIFJPT1QgQ0EwHhcNMjMwNDA5MTY0MjAwWhcNNDkxMjMwMjM1
|
|
9
|
+
OTAwWjBaMQswCQYDVQQGEwJDSDEPMA0GA1UECBMGR0VORVZBMRQwEgYDVQQKEwtD
|
|
10
|
+
UllQVE5PWCBTQTEkMCIGA1UEAwwbQ1JZUFROT1ggSU5URVJNRURJQVRFIENBICMy
|
|
11
|
+
MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEEflDieulDZW610ppouL3ISytLWoUFbKl
|
|
12
|
+
03SZDujzHps/mRbR2VGCn385vOHzEY+4LpB1aS8uhYwrOt5ELJtMpM1M0JIKwr2S
|
|
13
|
+
dtOSv3iAzy0yAZq25id61an56sVZ/FP8oz8wPTALBgNVHQ8EBAMCAQYwDwYDVR0T
|
|
14
|
+
BAgwBgEB/wIBATAdBgNVHQ4EFgQUs4gFpqK3QPsDR/FW/kTZo8F6qZkwCgYIKoZI
|
|
15
|
+
zj0EAwMDgYsAMIGHAkIAwY7rXvM4h4XXMuXnZe/wjLc8kbPb8fx26v3ARY1ymb61
|
|
16
|
+
G62WutOrEUG0SJr2qzwyyWBh6SSaz2W/PnTXGGCysM0CQU4IZwYuY2uW5c0fGKnK
|
|
17
|
+
ecWkXTtARXiIjFn33zIbIcpwPHZ02zI2SH1pWSj6uRZBXqBQmtQY46nsNzjgUWge
|
|
18
|
+
+8vv
|
|
19
|
+
-----END CERTIFICATE-----
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# subject : C=CH, ST=GENEVA, O=CRYPTNOX SA, CN=CRYPTNOX INTERMEDIATE CA
|
|
2
|
+
# issuer : C=CH, ST=GENEVA, O=CRYPTNOX SA, CN=CRYPTNOX ROOT CA
|
|
3
|
+
# not after : Dec 29 23:59:59 2049 GMT
|
|
4
|
+
# sha256 (DER): 8CCF5B7AE498A2E3B7C21E9EB80843198380202C5AB57E60D2C1240587A050CA
|
|
5
|
+
-----BEGIN CERTIFICATE-----
|
|
6
|
+
MIICQTCCAaKgAwIBAgIGAIFDUXQrMAoGCCqGSM49BAMDME8xCzAJBgNVBAYTAkNI
|
|
7
|
+
MQ8wDQYDVQQIEwZHRU5FVkExFDASBgNVBAoTC0NSWVBUTk9YIFNBMRkwFwYDVQQD
|
|
8
|
+
ExBDUllQVE5PWCBST09UIENBMB4XDTIwMDYxNDAwMDAwMFoXDTQ5MTIyOTIzNTk1
|
|
9
|
+
OVowVzELMAkGA1UEBhMCQ0gxDzANBgNVBAgTBkdFTkVWQTEUMBIGA1UEChMLQ1JZ
|
|
10
|
+
UFROT1ggU0ExITAfBgNVBAMTGENSWVBUTk9YIElOVEVSTUVESUFURSBDQTB2MBAG
|
|
11
|
+
ByqGSM49AgEGBSuBBAAiA2IABGhcikuRYgNkak0pO8XgPWwG9RZXLQeqQEvKHfCn
|
|
12
|
+
37gaR5U9rhp4W8JTbfU9t8CFgD0DxNXEJE9KGNnLAtL708hCHeo6S5oeznGDFn9J
|
|
13
|
+
80bO7bdSDYHS5bzkEKPLNuamWKNFMEMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNV
|
|
14
|
+
HQ4EFgQUdrEtUyWaMtpVAQ7JGqQp3jzNShcwDgYDVR0PAQH/BAQDAgEGMAoGCCqG
|
|
15
|
+
SM49BAMDA4GMADCBiAJCAbWa/xcKx7KCcoUsp8SVZnWSxD8n91TqCJKajs7kJBz2
|
|
16
|
+
DzvZZtPxKsMY38lH/8MYTC3wlRwdZc5s61EhlbdnOguBAkIBFmJLsOPyMQWUxmWl
|
|
17
|
+
2zuAS7ctqU03U74C3UUHSKDH3WSJH+y3i/XsCs/FM+Dk/3FjOgQTxioaqIc3hrwh
|
|
18
|
+
PlmorNo=
|
|
19
|
+
-----END CERTIFICATE-----
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# subject : C=CH, ST=GENEVA, O=CRYPTNOX SA, CN=CRYPTNOX ROOT CA
|
|
2
|
+
# issuer : C=CH, ST=GENEVA, O=CRYPTNOX SA, CN=CRYPTNOX ROOT CA
|
|
3
|
+
# not after : Dec 30 23:59:59 2049 GMT
|
|
4
|
+
# sha256 (DER): 9EA907A7CBDA4E3D7F8EA06E6B6B759320DFE6F09272101F5E8EA85B3433EE2E
|
|
5
|
+
-----BEGIN CERTIFICATE-----
|
|
6
|
+
MIICWTCCAbqgAwIBAgIGAIFDUXQpMAoGCCqGSM49BAMEME8xCzAJBgNVBAYTAkNI
|
|
7
|
+
MQ8wDQYDVQQIEwZHRU5FVkExFDASBgNVBAoTC0NSWVBUTk9YIFNBMRkwFwYDVQQD
|
|
8
|
+
ExBDUllQVE5PWCBST09UIENBMB4XDTIwMDYxNDAwMDAwMFoXDTQ5MTIzMDIzNTk1
|
|
9
|
+
OVowTzELMAkGA1UEBhMCQ0gxDzANBgNVBAgTBkdFTkVWQTEUMBIGA1UEChMLQ1JZ
|
|
10
|
+
UFROT1ggU0ExGTAXBgNVBAMTEENSWVBUTk9YIFJPT1QgQ0EwgZswEAYHKoZIzj0C
|
|
11
|
+
AQYFK4EEACMDgYYABAEJtmrMYc48nPSp0RRmEwT2U5aq0D1b3URLpmJP277ImEXK
|
|
12
|
+
EbjVQA8P3UyTdZinELTQ5g6E+lnN3GhUWif2/VmSbQDnu9f+ebyIZfBabosKK3SQ
|
|
13
|
+
Vv4KmuP9pb0f7P2vMPAUFJNZvKeH1DPtaZkFybMKZsg8CJEs7A9KW3RI/8QDpN1n
|
|
14
|
+
J6M/MD0wDAYDVR0TBAUwAwEB/zAdBgNVHQ4EFgQUUnfLODDaku1o8CSuwWfWylj4
|
|
15
|
+
OvAwDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMEA4GMADCBiAJCAKd8DeNhbPey
|
|
16
|
+
fh7UD3M9lPp5a77M1LmC2M2o7zRiHlePt+Xcr/I/xGK124gEuHjbOfyanre63EMT
|
|
17
|
+
uVW8ts5kR906AkIBgSIhJoENkMVFbeMRUDnDtbv+gKi+htqORGysid5sg2VvCX9Q
|
|
18
|
+
fnUqBKtZVUBeQVPk3E5GUEamLk4jljdvlDGSXpU=
|
|
19
|
+
-----END CERTIFICATE-----
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Low-level utilities shared across the CLI (hex, BER-TLV)."""
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Hex helpers. Centralised so formatting stays consistent everywhere."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def to_hex(data: Iterable[int] | bytes | bytearray, *, sep: str = "") -> str:
|
|
9
|
+
"""Render bytes as uppercase hex, optionally separated (e.g. ``sep=" "``)."""
|
|
10
|
+
b = bytes(data)
|
|
11
|
+
if sep:
|
|
12
|
+
return sep.join(f"{x:02X}" for x in b)
|
|
13
|
+
return b.hex().upper()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def from_hex(text: str) -> bytes:
|
|
17
|
+
"""Parse a hex string, tolerating spaces, colons and ``0x`` prefixes."""
|
|
18
|
+
cleaned = (
|
|
19
|
+
text.strip()
|
|
20
|
+
.replace("0x", "")
|
|
21
|
+
.replace("0X", "")
|
|
22
|
+
.replace(" ", "")
|
|
23
|
+
.replace(":", "")
|
|
24
|
+
.replace("-", "")
|
|
25
|
+
)
|
|
26
|
+
if len(cleaned) % 2 != 0:
|
|
27
|
+
raise ValueError(f"hex string has an odd number of digits: {text!r}")
|
|
28
|
+
try:
|
|
29
|
+
return bytes.fromhex(cleaned)
|
|
30
|
+
except ValueError as exc:
|
|
31
|
+
raise ValueError(f"invalid hex string: {text!r}") from exc
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def to_list(data: bytes | bytearray | Iterable[int]) -> list[int]:
|
|
35
|
+
"""pyscard wants ``list[int]`` for APDUs."""
|
|
36
|
+
return list(bytes(data))
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""A small, dependency-free BER-TLV parser.
|
|
2
|
+
|
|
3
|
+
PIV data objects and the Application Property Template use BER-TLV with
|
|
4
|
+
multi-byte tags (e.g. ``5FC102``, ``7F61``) and short/long length forms. We need
|
|
5
|
+
read-only parsing only; this stays intentionally minimal and well-tested.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class TLV:
|
|
15
|
+
tag: int
|
|
16
|
+
value: bytes
|
|
17
|
+
children: list[TLV] = field(default_factory=list)
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def constructed(self) -> bool:
|
|
21
|
+
"""True if the tag's constructed bit (0x20 in the leading byte) is set."""
|
|
22
|
+
lead = self.tag
|
|
23
|
+
while lead > 0xFF:
|
|
24
|
+
lead >>= 8
|
|
25
|
+
return bool(lead & 0x20)
|
|
26
|
+
|
|
27
|
+
def tag_hex(self) -> str:
|
|
28
|
+
length = max(1, (self.tag.bit_length() + 7) // 8)
|
|
29
|
+
return self.tag.to_bytes(length, "big").hex().upper()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _read_tag(data: bytes, off: int) -> tuple[int, int]:
|
|
33
|
+
if off >= len(data):
|
|
34
|
+
raise ValueError("truncated tag")
|
|
35
|
+
start = off
|
|
36
|
+
first = data[off]
|
|
37
|
+
off += 1
|
|
38
|
+
tag = first
|
|
39
|
+
if (first & 0x1F) == 0x1F: # multi-byte tag
|
|
40
|
+
while True:
|
|
41
|
+
if off >= len(data):
|
|
42
|
+
raise ValueError("truncated multi-byte tag")
|
|
43
|
+
b = data[off]
|
|
44
|
+
tag = (tag << 8) | b
|
|
45
|
+
off += 1
|
|
46
|
+
if not (b & 0x80):
|
|
47
|
+
break
|
|
48
|
+
_ = start
|
|
49
|
+
return tag, off
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _read_len(data: bytes, off: int) -> tuple[int, int]:
|
|
53
|
+
if off >= len(data):
|
|
54
|
+
raise ValueError("truncated length")
|
|
55
|
+
first = data[off]
|
|
56
|
+
off += 1
|
|
57
|
+
if first < 0x80:
|
|
58
|
+
return first, off
|
|
59
|
+
num = first & 0x7F
|
|
60
|
+
if num == 0 or num > 4:
|
|
61
|
+
raise ValueError(f"unsupported BER length form: 0x{first:02X}")
|
|
62
|
+
if off + num > len(data):
|
|
63
|
+
raise ValueError("truncated length field")
|
|
64
|
+
length = int.from_bytes(data[off : off + num], "big")
|
|
65
|
+
return length, off + num
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse(data: bytes | bytearray, *, recurse: bool = True) -> list[TLV]:
|
|
69
|
+
"""Parse a concatenation of TLVs at the top level."""
|
|
70
|
+
data = bytes(data)
|
|
71
|
+
out: list[TLV] = []
|
|
72
|
+
off = 0
|
|
73
|
+
n = len(data)
|
|
74
|
+
while off < n:
|
|
75
|
+
tag, off = _read_tag(data, off)
|
|
76
|
+
length, off = _read_len(data, off)
|
|
77
|
+
if off + length > n:
|
|
78
|
+
raise ValueError("TLV value extends beyond buffer")
|
|
79
|
+
value = data[off : off + length]
|
|
80
|
+
off += length
|
|
81
|
+
node = TLV(tag=tag, value=value)
|
|
82
|
+
if recurse and node.constructed:
|
|
83
|
+
try:
|
|
84
|
+
node.children = parse(value, recurse=True)
|
|
85
|
+
except ValueError:
|
|
86
|
+
node.children = [] # not actually constructed payload; keep raw value
|
|
87
|
+
out.append(node)
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def find(tlvs: list[TLV], tag: int) -> TLV | None:
|
|
92
|
+
"""Depth-first search for the first TLV with ``tag``."""
|
|
93
|
+
for t in tlvs:
|
|
94
|
+
if t.tag == tag:
|
|
95
|
+
return t
|
|
96
|
+
if t.children:
|
|
97
|
+
hit = find(t.children, tag)
|
|
98
|
+
if hit is not None:
|
|
99
|
+
return hit
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# --------------------------------------------------------------------------- #
|
|
104
|
+
# Encoders (BER-TLV) — mirror the applet's perso toolkit byte-for-byte. #
|
|
105
|
+
# --------------------------------------------------------------------------- #
|
|
106
|
+
def encode_tag(tag: int) -> bytes:
|
|
107
|
+
"""Encode a numeric tag big-endian by its natural byte width (1..3 bytes)."""
|
|
108
|
+
if tag < 0:
|
|
109
|
+
raise ValueError("tag must be non-negative")
|
|
110
|
+
if tag <= 0xFF:
|
|
111
|
+
return bytes([tag])
|
|
112
|
+
if tag <= 0xFFFF:
|
|
113
|
+
return bytes([(tag >> 8) & 0xFF, tag & 0xFF])
|
|
114
|
+
if tag <= 0xFFFFFF:
|
|
115
|
+
return bytes([(tag >> 16) & 0xFF, (tag >> 8) & 0xFF, tag & 0xFF])
|
|
116
|
+
raise ValueError(f"tag too large: {tag:#x}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def encode_length(n: int) -> bytes:
|
|
120
|
+
"""BER length: short form < 0x80, else long form (0x81/0x82/0x83 + bytes)."""
|
|
121
|
+
if n < 0x80:
|
|
122
|
+
return bytes([n])
|
|
123
|
+
if n < 0x100:
|
|
124
|
+
return bytes([0x81, n])
|
|
125
|
+
if n < 0x10000:
|
|
126
|
+
return bytes([0x82, (n >> 8) & 0xFF, n & 0xFF])
|
|
127
|
+
if n < 0x1000000:
|
|
128
|
+
return bytes([0x83, (n >> 16) & 0xFF, (n >> 8) & 0xFF, n & 0xFF])
|
|
129
|
+
raise ValueError(f"value too long: {n}")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def build(tag: int, value: bytes = b"") -> bytes:
|
|
133
|
+
"""Build a single primitive/constructed TLV."""
|
|
134
|
+
value = bytes(value)
|
|
135
|
+
return encode_tag(tag) + encode_length(len(value)) + value
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def concat(*parts: bytes) -> bytes:
|
|
139
|
+
return b"".join(bytes(p) for p in parts)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def build_constructed(tag: int, *children: bytes) -> bytes:
|
|
143
|
+
"""Build a constructed TLV whose value is the concatenation of children."""
|
|
144
|
+
return build(tag, concat(*children))
|