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,430 @@
|
|
|
1
|
+
"""CTAP2-over-APDU client (read-only commands for Phase 7A).
|
|
2
|
+
|
|
3
|
+
Framing (verified against the applet source and the Android client):
|
|
4
|
+
``CLA 0x80, INS 0x10, P1 0x00, P2 0x00``, data = ``[CTAP cmd byte][CBOR params]``.
|
|
5
|
+
The response is ``[CTAP status byte][CBOR payload]``; ``61xx``/GET RESPONSE
|
|
6
|
+
chaining is handled by the transport layer.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import uuid
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import cbor2
|
|
15
|
+
|
|
16
|
+
from cryptnox_id_cli.applets.fido import constants as c
|
|
17
|
+
from cryptnox_id_cli.applets.fido import pinproto
|
|
18
|
+
from cryptnox_id_cli.applets.fido.authdata import parse_authenticator_data
|
|
19
|
+
from cryptnox_id_cli.applets.fido.errors import CtapStatusError
|
|
20
|
+
from cryptnox_id_cli.transport.apdu import APDU
|
|
21
|
+
from cryptnox_id_cli.transport.errors import AppletNotFoundError, StatusWordError
|
|
22
|
+
from cryptnox_id_cli.transport.pcsc import CardSession
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Ctap2Client:
|
|
26
|
+
def __init__(self, session: CardSession) -> None:
|
|
27
|
+
self.s = session
|
|
28
|
+
|
|
29
|
+
# -- selection ----------------------------------------------------------- #
|
|
30
|
+
def select(self) -> str:
|
|
31
|
+
"""SELECT the FIDO applet; returns the version string from the response."""
|
|
32
|
+
resp = self.s.transmit(
|
|
33
|
+
APDU(0x00, 0xA4, 0x04, 0x00, data=c.FIDO_AID, le=256), context="SELECT FIDO"
|
|
34
|
+
)
|
|
35
|
+
if resp.sw == 0x6A82:
|
|
36
|
+
raise AppletNotFoundError("FIDO2 applet not found on this card.")
|
|
37
|
+
if not resp.ok:
|
|
38
|
+
raise StatusWordError(resp.sw1, resp.sw2, context="SELECT FIDO")
|
|
39
|
+
return resp.data.decode("ascii", "replace") or "(no version string)"
|
|
40
|
+
|
|
41
|
+
# -- CTAP message -------------------------------------------------------- #
|
|
42
|
+
def command(self, cmd: int, params: dict | None = None, *, context: str | None = None) -> Any:
|
|
43
|
+
# CTAP2 mandates canonical CBOR (map keys sorted by encoded form). Without it,
|
|
44
|
+
# a strict authenticator misparses out-of-order maps -> CTAP2_ERR_MISSING_PARAMETER.
|
|
45
|
+
payload = bytes([cmd]) + (
|
|
46
|
+
cbor2.dumps(params, canonical=True) if params is not None else b""
|
|
47
|
+
)
|
|
48
|
+
resp = self.s.transmit(
|
|
49
|
+
APDU(0x80, c.INS_CTAP_MSG, 0x00, 0x00, data=payload, le=256),
|
|
50
|
+
context=context or f"CTAP 0x{cmd:02X}",
|
|
51
|
+
)
|
|
52
|
+
if not resp.ok:
|
|
53
|
+
raise StatusWordError(resp.sw1, resp.sw2, context=context or "CTAP")
|
|
54
|
+
if not resp.data:
|
|
55
|
+
raise CtapStatusError(0x7F, context=context)
|
|
56
|
+
status = resp.data[0]
|
|
57
|
+
if status != 0x00:
|
|
58
|
+
raise CtapStatusError(status, context=context)
|
|
59
|
+
return cbor2.loads(resp.data[1:]) if len(resp.data) > 1 else None
|
|
60
|
+
|
|
61
|
+
def get_info(self) -> dict:
|
|
62
|
+
info = self.command(c.CTAP_GET_INFO, context="authenticatorGetInfo")
|
|
63
|
+
if not isinstance(info, dict):
|
|
64
|
+
raise CtapStatusError(0x12, context="authenticatorGetInfo")
|
|
65
|
+
return info
|
|
66
|
+
|
|
67
|
+
def pin_retries(self) -> tuple[int | None, bool | None]:
|
|
68
|
+
"""clientPIN getPinRetries → (retries, powerCycleState). Consumes nothing."""
|
|
69
|
+
resp = self.command(
|
|
70
|
+
c.CTAP_CLIENT_PIN,
|
|
71
|
+
{0x01: 1, 0x02: c.PIN_GET_RETRIES},
|
|
72
|
+
context="clientPIN getPinRetries",
|
|
73
|
+
)
|
|
74
|
+
if not isinstance(resp, dict):
|
|
75
|
+
return None, None
|
|
76
|
+
retries = resp.get(0x03)
|
|
77
|
+
power_cycle = resp.get(0x04)
|
|
78
|
+
return (
|
|
79
|
+
int(retries) if isinstance(retries, int) else None,
|
|
80
|
+
bool(power_cycle) if power_cycle is not None else None,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# -- PIN/UV auth protocol (write ops) ----------------------------------- #
|
|
84
|
+
def get_key_agreement(self, protocol: int = 1) -> dict:
|
|
85
|
+
"""clientPIN getKeyAgreement -> the authenticator's COSE public key."""
|
|
86
|
+
resp = self.command(
|
|
87
|
+
c.CTAP_CLIENT_PIN,
|
|
88
|
+
{c.CP_PROTOCOL: protocol, c.CP_SUBCOMMAND: c.PIN_GET_KEY_AGREEMENT},
|
|
89
|
+
context="clientPIN getKeyAgreement",
|
|
90
|
+
)
|
|
91
|
+
key = resp.get(c.CPR_KEY_AGREEMENT) if isinstance(resp, dict) else None
|
|
92
|
+
if not isinstance(key, dict):
|
|
93
|
+
raise CtapStatusError(0x12, context="getKeyAgreement")
|
|
94
|
+
return key
|
|
95
|
+
|
|
96
|
+
def set_pin(self, new_pin: str, protocol: int = 1) -> None:
|
|
97
|
+
"""Set the initial PIN on an authenticator that has none."""
|
|
98
|
+
platform_key, proto = pinproto.encapsulate(self.get_key_agreement(protocol), protocol)
|
|
99
|
+
new_pin_enc = proto.encrypt(pinproto.pad_pin(new_pin))
|
|
100
|
+
self.command(
|
|
101
|
+
c.CTAP_CLIENT_PIN,
|
|
102
|
+
{
|
|
103
|
+
c.CP_PROTOCOL: protocol,
|
|
104
|
+
c.CP_SUBCOMMAND: c.PIN_SET_PIN,
|
|
105
|
+
c.CP_KEY_AGREEMENT: platform_key,
|
|
106
|
+
c.CP_NEW_PIN_ENC: new_pin_enc,
|
|
107
|
+
c.CP_PIN_UV_AUTH_PARAM: proto.authenticate(new_pin_enc),
|
|
108
|
+
},
|
|
109
|
+
context="clientPIN setPIN",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def change_pin(self, current_pin: str, new_pin: str, protocol: int = 1) -> None:
|
|
113
|
+
"""Change an existing PIN (decrements the retry counter on a wrong current PIN)."""
|
|
114
|
+
platform_key, proto = pinproto.encapsulate(self.get_key_agreement(protocol), protocol)
|
|
115
|
+
pin_hash_enc = proto.encrypt(pinproto.pin_hash_left16(current_pin))
|
|
116
|
+
new_pin_enc = proto.encrypt(pinproto.pad_pin(new_pin))
|
|
117
|
+
self.command(
|
|
118
|
+
c.CTAP_CLIENT_PIN,
|
|
119
|
+
{
|
|
120
|
+
c.CP_PROTOCOL: protocol,
|
|
121
|
+
c.CP_SUBCOMMAND: c.PIN_CHANGE_PIN,
|
|
122
|
+
c.CP_KEY_AGREEMENT: platform_key,
|
|
123
|
+
c.CP_PIN_HASH_ENC: pin_hash_enc,
|
|
124
|
+
c.CP_NEW_PIN_ENC: new_pin_enc,
|
|
125
|
+
c.CP_PIN_UV_AUTH_PARAM: proto.authenticate(new_pin_enc + pin_hash_enc),
|
|
126
|
+
},
|
|
127
|
+
context="clientPIN changePIN",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def get_pin_token(
|
|
131
|
+
self,
|
|
132
|
+
pin: str,
|
|
133
|
+
*,
|
|
134
|
+
protocol: int = 1,
|
|
135
|
+
permissions: int | None = None,
|
|
136
|
+
rp_id: str | None = None,
|
|
137
|
+
) -> bytes:
|
|
138
|
+
"""Obtain a pinUvAuthToken (decrypted). Uses the permissions-scoped subcommand
|
|
139
|
+
(0x09) when ``permissions`` is given, else the legacy getPinToken (0x05)."""
|
|
140
|
+
platform_key, proto = pinproto.encapsulate(self.get_key_agreement(protocol), protocol)
|
|
141
|
+
pin_hash_enc = proto.encrypt(pinproto.pin_hash_left16(pin))
|
|
142
|
+
params = {
|
|
143
|
+
c.CP_PROTOCOL: protocol,
|
|
144
|
+
c.CP_KEY_AGREEMENT: platform_key,
|
|
145
|
+
c.CP_PIN_HASH_ENC: pin_hash_enc,
|
|
146
|
+
}
|
|
147
|
+
if permissions is not None:
|
|
148
|
+
params[c.CP_SUBCOMMAND] = c.PIN_GET_TOKEN_USING_PIN
|
|
149
|
+
params[c.CP_PERMISSIONS] = permissions
|
|
150
|
+
if rp_id is not None:
|
|
151
|
+
params[c.CP_RP_ID] = rp_id
|
|
152
|
+
ctx = "clientPIN getPinUvAuthTokenUsingPinWithPermissions"
|
|
153
|
+
else:
|
|
154
|
+
params[c.CP_SUBCOMMAND] = c.PIN_GET_PIN_TOKEN
|
|
155
|
+
ctx = "clientPIN getPinToken"
|
|
156
|
+
resp = self.command(c.CTAP_CLIENT_PIN, params, context=ctx)
|
|
157
|
+
enc = resp.get(c.CPR_PIN_UV_AUTH_TOKEN) if isinstance(resp, dict) else None
|
|
158
|
+
if not isinstance(enc, (bytes, bytearray)):
|
|
159
|
+
raise CtapStatusError(0x12, context=ctx)
|
|
160
|
+
return proto.decrypt(bytes(enc))
|
|
161
|
+
|
|
162
|
+
# -- credentials -------------------------------------------------------- #
|
|
163
|
+
def make_credential(
|
|
164
|
+
self,
|
|
165
|
+
*,
|
|
166
|
+
client_data_hash: bytes,
|
|
167
|
+
rp_id: str,
|
|
168
|
+
user_id: bytes,
|
|
169
|
+
user_name: str = "cryptnox",
|
|
170
|
+
resident_key: bool = False,
|
|
171
|
+
pin_uv_token: bytes | None = None,
|
|
172
|
+
protocol: int = 1,
|
|
173
|
+
algorithms: tuple[int, ...] = (c.COSE_ALG_ES256,),
|
|
174
|
+
) -> dict[str, object]:
|
|
175
|
+
"""authenticatorMakeCredential (register). Returns the parsed credential id +
|
|
176
|
+
public key. Requires user presence (a tap) on the authenticator."""
|
|
177
|
+
params: dict[int, object] = {
|
|
178
|
+
c.MC_CLIENT_DATA_HASH: client_data_hash,
|
|
179
|
+
c.MC_RP: {"id": rp_id, "name": rp_id},
|
|
180
|
+
c.MC_USER: {"id": user_id, "name": user_name, "displayName": user_name},
|
|
181
|
+
c.MC_PUB_KEY_CRED_PARAMS: [{"alg": a, "type": "public-key"} for a in algorithms],
|
|
182
|
+
}
|
|
183
|
+
if resident_key:
|
|
184
|
+
params[c.MC_OPTIONS] = {"rk": True}
|
|
185
|
+
if pin_uv_token is not None:
|
|
186
|
+
params[c.MC_PIN_UV_AUTH_PARAM] = pinproto.pin_uv_authenticate(
|
|
187
|
+
protocol, pin_uv_token, client_data_hash
|
|
188
|
+
)
|
|
189
|
+
params[c.MC_PIN_UV_AUTH_PROTOCOL] = protocol
|
|
190
|
+
resp = self.command(c.CTAP_MAKE_CREDENTIAL, params, context="authenticatorMakeCredential")
|
|
191
|
+
auth_data = resp.get(c.MCR_AUTH_DATA) if isinstance(resp, dict) else None
|
|
192
|
+
if not isinstance(auth_data, (bytes, bytearray)):
|
|
193
|
+
raise CtapStatusError(0x12, context="makeCredential")
|
|
194
|
+
parsed = parse_authenticator_data(bytes(auth_data))
|
|
195
|
+
return {
|
|
196
|
+
"fmt": resp.get(c.MCR_FMT),
|
|
197
|
+
"auth_data": bytes(auth_data),
|
|
198
|
+
"att_stmt": resp.get(c.MCR_ATT_STMT),
|
|
199
|
+
"credential_id": parsed.credential_id,
|
|
200
|
+
"credential_public_key": parsed.credential_public_key,
|
|
201
|
+
"sign_count": parsed.sign_count,
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
def get_assertion(
|
|
205
|
+
self,
|
|
206
|
+
*,
|
|
207
|
+
rp_id: str,
|
|
208
|
+
client_data_hash: bytes,
|
|
209
|
+
allow_credential_ids: list[bytes] | None = None,
|
|
210
|
+
pin_uv_token: bytes | None = None,
|
|
211
|
+
protocol: int = 1,
|
|
212
|
+
user_presence: bool = True,
|
|
213
|
+
) -> dict[str, object]:
|
|
214
|
+
"""authenticatorGetAssertion (authenticate). Returns authData + signature."""
|
|
215
|
+
params: dict[int, object] = {
|
|
216
|
+
c.GA_RP_ID: rp_id,
|
|
217
|
+
c.GA_CLIENT_DATA_HASH: client_data_hash,
|
|
218
|
+
c.GA_OPTIONS: {"up": user_presence},
|
|
219
|
+
}
|
|
220
|
+
if allow_credential_ids:
|
|
221
|
+
params[c.GA_ALLOW_LIST] = [
|
|
222
|
+
{"id": cid, "type": "public-key"} for cid in allow_credential_ids
|
|
223
|
+
]
|
|
224
|
+
if pin_uv_token is not None:
|
|
225
|
+
params[c.GA_PIN_UV_AUTH_PARAM] = pinproto.pin_uv_authenticate(
|
|
226
|
+
protocol, pin_uv_token, client_data_hash
|
|
227
|
+
)
|
|
228
|
+
params[c.GA_PIN_UV_AUTH_PROTOCOL] = protocol
|
|
229
|
+
resp = self.command(c.CTAP_GET_ASSERTION, params, context="authenticatorGetAssertion")
|
|
230
|
+
auth_data = resp.get(c.GAR_AUTH_DATA) if isinstance(resp, dict) else None
|
|
231
|
+
signature = resp.get(c.GAR_SIGNATURE) if isinstance(resp, dict) else None
|
|
232
|
+
credential = resp.get(c.GAR_CREDENTIAL) if isinstance(resp, dict) else None
|
|
233
|
+
if not isinstance(auth_data, (bytes, bytearray)) or not isinstance(
|
|
234
|
+
signature, (bytes, bytearray)
|
|
235
|
+
):
|
|
236
|
+
raise CtapStatusError(0x12, context="getAssertion")
|
|
237
|
+
return {
|
|
238
|
+
"auth_data": bytes(auth_data),
|
|
239
|
+
"signature": bytes(signature),
|
|
240
|
+
"credential_id": credential.get("id") if isinstance(credential, dict) else None,
|
|
241
|
+
"number_of_credentials": resp.get(c.GAR_NUMBER_OF_CREDENTIALS),
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
def reset(self) -> None:
|
|
245
|
+
"""authenticatorReset - IRREVERSIBLY wipes all credentials and the PIN. Many
|
|
246
|
+
authenticators only honor it shortly after power-up and require user presence."""
|
|
247
|
+
self.command(c.CTAP_RESET, context="authenticatorReset")
|
|
248
|
+
|
|
249
|
+
# -- credential management (0x0A) --------------------------------------- #
|
|
250
|
+
def credential_management(
|
|
251
|
+
self,
|
|
252
|
+
subcommand: int,
|
|
253
|
+
*,
|
|
254
|
+
sub_params: dict | None = None,
|
|
255
|
+
pin_uv_token: bytes | None = None,
|
|
256
|
+
protocol: int = 1,
|
|
257
|
+
) -> Any:
|
|
258
|
+
"""authenticatorCredentialManagement. pinUvAuthParam is over
|
|
259
|
+
``subCommand || subCommandParams`` (omitted for the GetNext sub-commands)."""
|
|
260
|
+
params: dict[int, object] = {c.CM_SUBCOMMAND: subcommand}
|
|
261
|
+
if sub_params is not None:
|
|
262
|
+
params[c.CM_SUBCOMMAND_PARAMS] = sub_params
|
|
263
|
+
if pin_uv_token is not None:
|
|
264
|
+
msg = bytes([subcommand]) + (
|
|
265
|
+
cbor2.dumps(sub_params, canonical=True) if sub_params is not None else b""
|
|
266
|
+
)
|
|
267
|
+
params[c.CM_PIN_UV_AUTH_PROTOCOL] = protocol
|
|
268
|
+
params[c.CM_PIN_UV_AUTH_PARAM] = pinproto.pin_uv_authenticate(
|
|
269
|
+
protocol, pin_uv_token, msg
|
|
270
|
+
)
|
|
271
|
+
return self.command(
|
|
272
|
+
c.CTAP_CREDENTIAL_MANAGEMENT, params, context="authenticatorCredentialManagement"
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
def get_creds_metadata(self, pin_uv_token: bytes, protocol: int = 1) -> tuple[int, int]:
|
|
276
|
+
"""(existing resident credentials, max remaining)."""
|
|
277
|
+
resp = self.credential_management(
|
|
278
|
+
c.CM_GET_CREDS_METADATA, pin_uv_token=pin_uv_token, protocol=protocol
|
|
279
|
+
)
|
|
280
|
+
if not isinstance(resp, dict):
|
|
281
|
+
return 0, 0
|
|
282
|
+
return int(resp.get(c.CMR_EXISTING_RESIDENT_COUNT, 0)), int(
|
|
283
|
+
resp.get(c.CMR_MAX_REMAINING, 0)
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def enumerate_credentials(self, pin_uv_token: bytes, protocol: int = 1) -> list[dict]:
|
|
287
|
+
"""Walk every resident credential -> list of {rp_id, user, credential_id, public_key}."""
|
|
288
|
+
out: list[dict] = []
|
|
289
|
+
try:
|
|
290
|
+
first_rp = self.credential_management(
|
|
291
|
+
c.CM_ENUMERATE_RPS_BEGIN, pin_uv_token=pin_uv_token, protocol=protocol
|
|
292
|
+
)
|
|
293
|
+
except CtapStatusError as exc:
|
|
294
|
+
if exc.status == 0x2E: # CTAP2_ERR_NO_CREDENTIALS - none stored
|
|
295
|
+
return out
|
|
296
|
+
raise
|
|
297
|
+
if not isinstance(first_rp, dict):
|
|
298
|
+
return out
|
|
299
|
+
total_rps = int(first_rp.get(c.CMR_TOTAL_RPS, 0))
|
|
300
|
+
rps = [first_rp] if total_rps else []
|
|
301
|
+
for _ in range(max(0, total_rps - 1)):
|
|
302
|
+
rps.append(self.credential_management(c.CM_ENUMERATE_RPS_NEXT))
|
|
303
|
+
for rp in rps:
|
|
304
|
+
rp_info = rp.get(c.CMR_RP) if isinstance(rp, dict) else None
|
|
305
|
+
rp_id = rp_info.get("id") if isinstance(rp_info, dict) else None
|
|
306
|
+
rp_id_hash = rp.get(c.CMR_RP_ID_HASH) if isinstance(rp, dict) else None
|
|
307
|
+
first = self.credential_management(
|
|
308
|
+
c.CM_ENUMERATE_CREDS_BEGIN,
|
|
309
|
+
sub_params={c.CMP_RP_ID_HASH: rp_id_hash},
|
|
310
|
+
pin_uv_token=pin_uv_token,
|
|
311
|
+
protocol=protocol,
|
|
312
|
+
)
|
|
313
|
+
if not isinstance(first, dict):
|
|
314
|
+
continue
|
|
315
|
+
total = int(first.get(c.CMR_TOTAL_CREDENTIALS, 0))
|
|
316
|
+
creds = [first]
|
|
317
|
+
for _ in range(max(0, total - 1)):
|
|
318
|
+
creds.append(self.credential_management(c.CM_ENUMERATE_CREDS_NEXT))
|
|
319
|
+
for cred in creds:
|
|
320
|
+
if not isinstance(cred, dict):
|
|
321
|
+
continue
|
|
322
|
+
cid = cred.get(c.CMR_CREDENTIAL_ID)
|
|
323
|
+
out.append(
|
|
324
|
+
{
|
|
325
|
+
"rp_id": rp_id,
|
|
326
|
+
"user": cred.get(c.CMR_USER),
|
|
327
|
+
"credential_id": cid.get("id") if isinstance(cid, dict) else None,
|
|
328
|
+
"public_key": cred.get(c.CMR_PUBLIC_KEY),
|
|
329
|
+
}
|
|
330
|
+
)
|
|
331
|
+
return out
|
|
332
|
+
|
|
333
|
+
def delete_credential(
|
|
334
|
+
self, credential_id: bytes, pin_uv_token: bytes, protocol: int = 1
|
|
335
|
+
) -> None:
|
|
336
|
+
self.credential_management(
|
|
337
|
+
c.CM_DELETE_CREDENTIAL,
|
|
338
|
+
sub_params={c.CMP_CREDENTIAL_ID: {"id": credential_id, "type": "public-key"}},
|
|
339
|
+
pin_uv_token=pin_uv_token,
|
|
340
|
+
protocol=protocol,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
# -- authenticator configuration (0x0D) --------------------------------- #
|
|
344
|
+
def authenticator_config(
|
|
345
|
+
self,
|
|
346
|
+
subcommand: int,
|
|
347
|
+
*,
|
|
348
|
+
sub_params: dict | None = None,
|
|
349
|
+
pin_uv_token: bytes | None = None,
|
|
350
|
+
protocol: int = 1,
|
|
351
|
+
) -> Any:
|
|
352
|
+
"""authenticatorConfig. When a pinUvAuthToken is supplied the pinUvAuthParam is
|
|
353
|
+
a MAC over ``32×0xFF || 0x0D || subCommand || subCommandParams`` (the 0xFF prefix
|
|
354
|
+
is mandated by the CTAP 2.1 spec, not a card quirk). The token needs the
|
|
355
|
+
authenticatorConfiguration (acfg) permission. On an unprotected authenticator
|
|
356
|
+
(no clientPIN, alwaysUv false) the card may accept the call with no token."""
|
|
357
|
+
params: dict[int, object] = {c.AC_SUBCOMMAND: subcommand}
|
|
358
|
+
if sub_params is not None:
|
|
359
|
+
params[c.AC_SUBCOMMAND_PARAMS] = sub_params
|
|
360
|
+
if pin_uv_token is not None:
|
|
361
|
+
msg = (
|
|
362
|
+
b"\xff" * 32
|
|
363
|
+
+ bytes([c.CTAP_AUTHENTICATOR_CONFIG, subcommand])
|
|
364
|
+
+ (cbor2.dumps(sub_params, canonical=True) if sub_params is not None else b"")
|
|
365
|
+
)
|
|
366
|
+
params[c.AC_PIN_UV_AUTH_PROTOCOL] = protocol
|
|
367
|
+
params[c.AC_PIN_UV_AUTH_PARAM] = pinproto.pin_uv_authenticate(
|
|
368
|
+
protocol, pin_uv_token, msg
|
|
369
|
+
)
|
|
370
|
+
return self.command(c.CTAP_AUTHENTICATOR_CONFIG, params, context="authenticatorConfig")
|
|
371
|
+
|
|
372
|
+
def toggle_always_uv(self, *, pin_uv_token: bytes | None = None, protocol: int = 1) -> None:
|
|
373
|
+
"""Flip the alwaysUv flag (require user verification for every operation)."""
|
|
374
|
+
self.authenticator_config(
|
|
375
|
+
c.AC_TOGGLE_ALWAYS_UV, pin_uv_token=pin_uv_token, protocol=protocol
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
def set_min_pin_length(
|
|
379
|
+
self,
|
|
380
|
+
length: int,
|
|
381
|
+
*,
|
|
382
|
+
rp_ids: list[str] | None = None,
|
|
383
|
+
force_change_pin: bool | None = None,
|
|
384
|
+
pin_uv_token: bytes | None = None,
|
|
385
|
+
protocol: int = 1,
|
|
386
|
+
) -> None:
|
|
387
|
+
"""Raise the minimum PIN length. The value can only INCREASE; lowering it again
|
|
388
|
+
requires authenticatorReset."""
|
|
389
|
+
sub: dict[int, object] = {c.ACP_NEW_MIN_PIN_LENGTH: length}
|
|
390
|
+
if rp_ids:
|
|
391
|
+
sub[c.ACP_MIN_PIN_LENGTH_RP_IDS] = rp_ids
|
|
392
|
+
if force_change_pin is not None:
|
|
393
|
+
sub[c.ACP_FORCE_CHANGE_PIN] = force_change_pin
|
|
394
|
+
self.authenticator_config(
|
|
395
|
+
c.AC_SET_MIN_PIN_LENGTH,
|
|
396
|
+
sub_params=sub,
|
|
397
|
+
pin_uv_token=pin_uv_token,
|
|
398
|
+
protocol=protocol,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def describe_get_info(info: dict) -> dict[str, object]:
|
|
403
|
+
"""Turn the int-keyed getInfo map into a readable, JSON-able structure."""
|
|
404
|
+
aaguid = info.get(c.INFO_AAGUID)
|
|
405
|
+
aaguid_str = None
|
|
406
|
+
cryptnox = None
|
|
407
|
+
if isinstance(aaguid, bytes) and len(aaguid) == 16:
|
|
408
|
+
aaguid_str = str(uuid.UUID(bytes=aaguid))
|
|
409
|
+
cryptnox = aaguid.hex() in c.CRYPTNOX_AAGUIDS
|
|
410
|
+
algorithms = info.get(c.INFO_ALGORITHMS)
|
|
411
|
+
alg_list = None
|
|
412
|
+
if isinstance(algorithms, list):
|
|
413
|
+
alg_list = [
|
|
414
|
+
{"alg": a.get("alg"), "type": a.get("type")} for a in algorithms if isinstance(a, dict)
|
|
415
|
+
]
|
|
416
|
+
return {
|
|
417
|
+
"versions": info.get(c.INFO_VERSIONS),
|
|
418
|
+
"extensions": info.get(c.INFO_EXTENSIONS),
|
|
419
|
+
"aaguid": aaguid_str,
|
|
420
|
+
"cryptnox_aaguid": cryptnox,
|
|
421
|
+
"options": info.get(c.INFO_OPTIONS),
|
|
422
|
+
"max_msg_size": info.get(c.INFO_MAX_MSG_SIZE),
|
|
423
|
+
"pin_uv_auth_protocols": info.get(c.INFO_PIN_UV_AUTH_PROTOCOLS),
|
|
424
|
+
"max_creds_in_list": info.get(c.INFO_MAX_CREDS_IN_LIST),
|
|
425
|
+
"max_cred_id_length": info.get(c.INFO_MAX_CRED_ID_LENGTH),
|
|
426
|
+
"transports": info.get(c.INFO_TRANSPORTS),
|
|
427
|
+
"algorithms": alg_list,
|
|
428
|
+
"min_pin_length": info.get(c.INFO_MIN_PIN_LENGTH),
|
|
429
|
+
"firmware_version": info.get(c.INFO_FIRMWARE_VERSION),
|
|
430
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""CTAP2 status-code decoding (CTAP 2.1; matches the Android client's table)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from cryptnox_id_cli.transport.errors import CryptnoxError
|
|
6
|
+
|
|
7
|
+
_CTAP_ERRORS: dict[int, tuple[str, str]] = {
|
|
8
|
+
0x01: ("CTAP1_ERR_INVALID_COMMAND", "The command is not valid or not supported."),
|
|
9
|
+
0x02: ("CTAP1_ERR_INVALID_PARAMETER", "Invalid parameter."),
|
|
10
|
+
0x03: ("CTAP1_ERR_INVALID_LENGTH", "Invalid message length."),
|
|
11
|
+
0x04: ("CTAP1_ERR_INVALID_SEQ", "Invalid message sequencing."),
|
|
12
|
+
0x05: ("CTAP1_ERR_TIMEOUT", "Message timed out."),
|
|
13
|
+
0x06: ("CTAP1_ERR_CHANNEL_BUSY", "Channel busy."),
|
|
14
|
+
0x11: ("CTAP2_ERR_CBOR_UNEXPECTED_TYPE", "Invalid/unexpected CBOR type."),
|
|
15
|
+
0x12: ("CTAP2_ERR_INVALID_CBOR", "Error when parsing CBOR."),
|
|
16
|
+
0x14: ("CTAP2_ERR_MISSING_PARAMETER", "Missing non-optional parameter."),
|
|
17
|
+
0x15: ("CTAP2_ERR_LIMIT_EXCEEDED", "Limit for number of items exceeded."),
|
|
18
|
+
0x17: ("CTAP2_ERR_LARGE_BLOB_STORAGE_FULL", "Large blob storage is full."),
|
|
19
|
+
0x19: ("CTAP2_ERR_CREDENTIAL_EXCLUDED", "A credential in the exclude list already exists."),
|
|
20
|
+
0x21: ("CTAP2_ERR_PROCESSING", "Processing (lengthy operation underway)."),
|
|
21
|
+
0x22: ("CTAP2_ERR_INVALID_CREDENTIAL", "Credential not valid for the authenticator."),
|
|
22
|
+
0x23: ("CTAP2_ERR_USER_ACTION_PENDING", "Authentication is waiting for user interaction."),
|
|
23
|
+
0x24: ("CTAP2_ERR_OPERATION_PENDING", "Another processing operation is pending."),
|
|
24
|
+
0x25: ("CTAP2_ERR_NO_OPERATIONS", "No outstanding operations."),
|
|
25
|
+
0x26: (
|
|
26
|
+
"CTAP2_ERR_UNSUPPORTED_ALGORITHM",
|
|
27
|
+
"Authenticator does not support the requested algorithm.",
|
|
28
|
+
),
|
|
29
|
+
0x27: ("CTAP2_ERR_OPERATION_DENIED", "The operation was denied (user presence / policy)."),
|
|
30
|
+
0x28: ("CTAP2_ERR_KEY_STORE_FULL", "Internal key storage is full."),
|
|
31
|
+
0x2B: ("CTAP2_ERR_UNSUPPORTED_OPTION", "Unsupported option."),
|
|
32
|
+
0x2C: ("CTAP2_ERR_INVALID_OPTION", "Not a valid option for the current operation."),
|
|
33
|
+
0x2D: ("CTAP2_ERR_KEEPALIVE_CANCEL", "The operation was cancelled."),
|
|
34
|
+
0x2E: ("CTAP2_ERR_NO_CREDENTIALS", "No valid credentials provided / found."),
|
|
35
|
+
0x2F: ("CTAP2_ERR_USER_ACTION_TIMEOUT", "Timeout waiting for user interaction."),
|
|
36
|
+
0x30: ("CTAP2_ERR_NOT_ALLOWED", "Command not allowed on this CID/transport."),
|
|
37
|
+
0x31: ("CTAP2_ERR_PIN_INVALID", "The PIN is invalid (wrong PIN entered)."),
|
|
38
|
+
0x32: (
|
|
39
|
+
"CTAP2_ERR_PIN_BLOCKED",
|
|
40
|
+
"The PIN is blocked. Reset is required to use the authenticator again.",
|
|
41
|
+
),
|
|
42
|
+
0x33: (
|
|
43
|
+
"CTAP2_ERR_PIN_AUTH_INVALID",
|
|
44
|
+
"PIN authentication (pinUvAuthParam) verification failed.",
|
|
45
|
+
),
|
|
46
|
+
0x34: (
|
|
47
|
+
"CTAP2_ERR_PIN_AUTH_BLOCKED",
|
|
48
|
+
"PIN authentication is blocked for this power cycle - remove and re-present the card.",
|
|
49
|
+
),
|
|
50
|
+
0x35: ("CTAP2_ERR_PIN_NOT_SET", "No PIN is set on this authenticator."),
|
|
51
|
+
0x36: ("CTAP2_ERR_PUAT_REQUIRED", "A PIN/UV auth token is required for this operation."),
|
|
52
|
+
0x37: (
|
|
53
|
+
"CTAP2_ERR_PIN_POLICY_VIOLATION",
|
|
54
|
+
"The new PIN violates the PIN policy (e.g. too short).",
|
|
55
|
+
),
|
|
56
|
+
0x39: ("CTAP2_ERR_REQUEST_TOO_LARGE", "The request is larger than the authenticator supports."),
|
|
57
|
+
0x3A: ("CTAP2_ERR_ACTION_TIMEOUT", "The action timed out."),
|
|
58
|
+
0x3B: ("CTAP2_ERR_UP_REQUIRED", "User presence is required."),
|
|
59
|
+
0x3C: ("CTAP2_ERR_UV_BLOCKED", "Built-in user verification is blocked."),
|
|
60
|
+
0x3E: ("CTAP2_ERR_UV_INVALID", "User verification failed."),
|
|
61
|
+
0x7F: ("CTAP1_ERR_OTHER", "Other unspecified error."),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def describe_ctap(status: int) -> tuple[str, str]:
|
|
66
|
+
return _CTAP_ERRORS.get(
|
|
67
|
+
status, (f"CTAP_ERR_0x{status:02X}", f"Unknown CTAP error 0x{status:02X}.")
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class CtapStatusError(CryptnoxError):
|
|
72
|
+
"""A CTAP command returned a non-success status byte."""
|
|
73
|
+
|
|
74
|
+
code = "ctap_error"
|
|
75
|
+
exit_code = 10
|
|
76
|
+
|
|
77
|
+
def __init__(self, status: int, *, context: str | None = None) -> None:
|
|
78
|
+
self.status = status
|
|
79
|
+
name, message = describe_ctap(status)
|
|
80
|
+
prefix = f"{context}: " if context else ""
|
|
81
|
+
super().__init__(f"{prefix}{message} ({name}, 0x{status:02X})")
|
|
82
|
+
|
|
83
|
+
def to_dict(self) -> dict[str, object]:
|
|
84
|
+
name, _ = describe_ctap(self.status)
|
|
85
|
+
return {
|
|
86
|
+
"error": self.code,
|
|
87
|
+
"message": str(self),
|
|
88
|
+
"ctap_status": f"0x{self.status:02X}",
|
|
89
|
+
"ctap_name": name,
|
|
90
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""CTAP2 PIN/UV Auth Protocol (one and two) - the crypto under FIDO PIN + token ops.
|
|
2
|
+
|
|
3
|
+
Both protocols agree a shared secret over ECDH (P-256), then provide ``encrypt`` /
|
|
4
|
+
``decrypt`` / ``authenticate``:
|
|
5
|
+
|
|
6
|
+
- **Protocol one:** sharedSecret = SHA-256(Z); AES-256-CBC with a zero IV; HMAC-SHA-256
|
|
7
|
+
truncated to 16 bytes.
|
|
8
|
+
- **Protocol two:** sharedSecret = HKDF(Z) -> 32-byte HMAC key || 32-byte AES key;
|
|
9
|
+
AES-256-CBC with a random IV prepended to the ciphertext; full HMAC-SHA-256.
|
|
10
|
+
|
|
11
|
+
``Z`` is the 32-byte X coordinate of the ECDH point.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
from cryptography.hazmat.primitives import hashes, hmac
|
|
19
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
20
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
21
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
22
|
+
|
|
23
|
+
from cryptnox_id_cli.applets.fido import constants as c
|
|
24
|
+
|
|
25
|
+
_P256 = ec.SECP256R1()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _sha256(data: bytes) -> bytes:
|
|
29
|
+
h = hashes.Hash(hashes.SHA256())
|
|
30
|
+
h.update(data)
|
|
31
|
+
return h.finalize()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _hmac256(key: bytes, msg: bytes) -> bytes:
|
|
35
|
+
h = hmac.HMAC(key, hashes.SHA256())
|
|
36
|
+
h.update(msg)
|
|
37
|
+
return h.finalize()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _aes_cbc(key: bytes, iv: bytes, data: bytes, *, encrypt: bool) -> bytes:
|
|
41
|
+
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
|
|
42
|
+
op = cipher.encryptor() if encrypt else cipher.decryptor()
|
|
43
|
+
return op.update(data) + op.finalize()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cose_to_public_key(cose: dict) -> ec.EllipticCurvePublicKey:
|
|
47
|
+
x = int.from_bytes(cose[c.COSE_X], "big")
|
|
48
|
+
y = int.from_bytes(cose[c.COSE_Y], "big")
|
|
49
|
+
return ec.EllipticCurvePublicNumbers(x, y, _P256).public_key()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def public_key_to_cose(pub: ec.EllipticCurvePublicKey) -> dict:
|
|
53
|
+
nums = pub.public_numbers()
|
|
54
|
+
return {
|
|
55
|
+
c.COSE_KTY: c.COSE_KTY_EC2,
|
|
56
|
+
c.COSE_ALG: c.COSE_ALG_ECDH_ES_HKDF256,
|
|
57
|
+
c.COSE_CRV: c.COSE_CRV_P256,
|
|
58
|
+
c.COSE_X: nums.x.to_bytes(32, "big"),
|
|
59
|
+
c.COSE_Y: nums.y.to_bytes(32, "big"),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class PinUvAuthProtocol:
|
|
64
|
+
version: int
|
|
65
|
+
|
|
66
|
+
def __init__(self, shared_secret: bytes) -> None:
|
|
67
|
+
self.shared_secret = shared_secret
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def kdf(z: bytes) -> bytes: # pragma: no cover - overridden
|
|
71
|
+
raise NotImplementedError
|
|
72
|
+
|
|
73
|
+
def encrypt(self, plaintext: bytes) -> bytes: # pragma: no cover - overridden
|
|
74
|
+
raise NotImplementedError
|
|
75
|
+
|
|
76
|
+
def decrypt(self, ciphertext: bytes) -> bytes: # pragma: no cover - overridden
|
|
77
|
+
raise NotImplementedError
|
|
78
|
+
|
|
79
|
+
def authenticate(self, message: bytes) -> bytes: # pragma: no cover - overridden
|
|
80
|
+
raise NotImplementedError
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ProtocolOne(PinUvAuthProtocol):
|
|
84
|
+
version = 1
|
|
85
|
+
|
|
86
|
+
@staticmethod
|
|
87
|
+
def kdf(z: bytes) -> bytes:
|
|
88
|
+
return _sha256(z)
|
|
89
|
+
|
|
90
|
+
def encrypt(self, plaintext: bytes) -> bytes:
|
|
91
|
+
return _aes_cbc(self.shared_secret, b"\x00" * 16, plaintext, encrypt=True)
|
|
92
|
+
|
|
93
|
+
def decrypt(self, ciphertext: bytes) -> bytes:
|
|
94
|
+
return _aes_cbc(self.shared_secret, b"\x00" * 16, ciphertext, encrypt=False)
|
|
95
|
+
|
|
96
|
+
def authenticate(self, message: bytes) -> bytes:
|
|
97
|
+
return _hmac256(self.shared_secret, message)[:16]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ProtocolTwo(PinUvAuthProtocol):
|
|
101
|
+
version = 2
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def kdf(z: bytes) -> bytes:
|
|
105
|
+
salt = b"\x00" * 32
|
|
106
|
+
hmac_key = HKDF(hashes.SHA256(), 32, salt, b"CTAP2 HMAC key").derive(z)
|
|
107
|
+
aes_key = HKDF(hashes.SHA256(), 32, salt, b"CTAP2 AES key").derive(z)
|
|
108
|
+
return hmac_key + aes_key
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def _hmac_key(self) -> bytes:
|
|
112
|
+
return self.shared_secret[:32]
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def _aes_key(self) -> bytes:
|
|
116
|
+
return self.shared_secret[32:]
|
|
117
|
+
|
|
118
|
+
def encrypt(self, plaintext: bytes) -> bytes:
|
|
119
|
+
iv = os.urandom(16)
|
|
120
|
+
return iv + _aes_cbc(self._aes_key, iv, plaintext, encrypt=True)
|
|
121
|
+
|
|
122
|
+
def decrypt(self, ciphertext: bytes) -> bytes:
|
|
123
|
+
return _aes_cbc(self._aes_key, ciphertext[:16], ciphertext[16:], encrypt=False)
|
|
124
|
+
|
|
125
|
+
def authenticate(self, message: bytes) -> bytes:
|
|
126
|
+
return _hmac256(self._hmac_key, message)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
_PROTOCOLS: dict[int, type[PinUvAuthProtocol]] = {1: ProtocolOne, 2: ProtocolTwo}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def encapsulate(peer_cose: dict, version: int) -> tuple[dict, PinUvAuthProtocol]:
|
|
133
|
+
"""ECDH against the authenticator's public key; returns the platform COSE key to
|
|
134
|
+
send and the initialized protocol (holding the shared secret)."""
|
|
135
|
+
cls = _PROTOCOLS.get(version)
|
|
136
|
+
if cls is None:
|
|
137
|
+
raise ValueError(f"unsupported PIN/UV auth protocol {version}")
|
|
138
|
+
ephemeral = ec.generate_private_key(_P256)
|
|
139
|
+
z = ephemeral.exchange(ec.ECDH(), cose_to_public_key(peer_cose))
|
|
140
|
+
return public_key_to_cose(ephemeral.public_key()), cls(cls.kdf(z))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# -- PIN value helpers ------------------------------------------------------- #
|
|
144
|
+
_PIN_PAD_LEN = 64
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def pad_pin(pin: str) -> bytes:
|
|
148
|
+
"""UTF-8 PIN padded with 0x00 to 64 bytes (CTAP minimum). 4..63 bytes."""
|
|
149
|
+
raw = pin.encode("utf-8")
|
|
150
|
+
if len(raw) < 4:
|
|
151
|
+
raise ValueError("FIDO PIN must be at least 4 characters.")
|
|
152
|
+
if len(raw) > _PIN_PAD_LEN - 1:
|
|
153
|
+
raise ValueError("FIDO PIN must be at most 63 bytes.")
|
|
154
|
+
return raw + b"\x00" * (_PIN_PAD_LEN - len(raw))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def pin_hash_left16(pin: str) -> bytes:
|
|
158
|
+
"""LEFT(SHA-256(PIN), 16) - the pinHash the authenticator checks."""
|
|
159
|
+
return _sha256(pin.encode("utf-8"))[:16]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def pin_uv_authenticate(version: int, key: bytes, message: bytes) -> bytes:
|
|
163
|
+
"""The pinUvAuthParam MAC over ``message`` keyed by a pinUvAuthToken: protocol 1
|
|
164
|
+
truncates the HMAC to 16 bytes, protocol 2 uses the full 32."""
|
|
165
|
+
mac = _hmac256(key, message)
|
|
166
|
+
return mac[:16] if version == 1 else mac
|