mitid-connect 0.0.1__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.
@@ -0,0 +1,29 @@
1
+ """Log in through MitID from Python: an unofficial implementation of MitID's browser client.
2
+
3
+ It authenticates with the MitID app (QR code or one-time code) or a code token, and gives back
4
+ the authorization code the service being logged in to exchanges for its own tokens.
5
+ """
6
+
7
+ from importlib.metadata import PackageNotFoundError, version
8
+
9
+ from .browser_client import BrowserClient
10
+ from .helpers import (
11
+ choose_between_multiple_identitites,
12
+ generate_nem_login_parameters,
13
+ get_authentication_code,
14
+ )
15
+ from .srp import CustomSRP
16
+
17
+ try:
18
+ __version__ = version("mitid_connect")
19
+ except PackageNotFoundError: # pragma: no cover - only in a checkout that is not installed
20
+ __version__ = "0.0.0"
21
+
22
+ __all__ = [
23
+ "BrowserClient",
24
+ "CustomSRP",
25
+ "__version__",
26
+ "choose_between_multiple_identitites",
27
+ "generate_nem_login_parameters",
28
+ "get_authentication_code",
29
+ ]
@@ -0,0 +1,813 @@
1
+ import requests, time, hashlib, base64, hmac, qrcode, threading, json, logging
2
+ from .srp import CustomSRP, hex_to_bytes, bytes_to_hex, pad
3
+
4
+ _LOGGER = logging.getLogger(__name__)
5
+
6
+
7
+ DEFAULT_TIMEOUT = 30
8
+
9
+
10
+ class BrowserClient:
11
+ def __init__(
12
+ self,
13
+ client_hash: str,
14
+ authentication_session_id: str,
15
+ requests_session=None,
16
+ timeout: int = DEFAULT_TIMEOUT,
17
+ ):
18
+ self.qr_display_thread_lock = threading.Lock()
19
+ self.session = requests_session if requests_session is not None else requests.Session()
20
+ self.timeout = timeout
21
+
22
+ self.client_hash = client_hash
23
+ self.authentication_session_id = authentication_session_id
24
+
25
+ r = self.session.get(
26
+ f"https://www.mitid.dk/mitid-core-client-backend/v1/authentication-sessions/{authentication_session_id}",
27
+ timeout=self.timeout,
28
+ )
29
+ if r.status_code != 200:
30
+ _LOGGER.error(
31
+ f"Failed to get authentication session ({authentication_session_id}), status code {r.status_code}"
32
+ )
33
+ raise Exception(r.content)
34
+
35
+ r = r.json()
36
+ # This is all needed for flowValueProofs later on
37
+ self.broker_security_context = r["brokerSecurityContext"]
38
+ self.service_provider_name = r["serviceProviderName"]
39
+ self.reference_text_header = r["referenceTextHeader"]
40
+ self.reference_text_body = r["referenceTextBody"]
41
+ self.status_message = (
42
+ f"Beginning login session for {self.service_provider_name}"
43
+ )
44
+ _LOGGER.info(f"Beginning login session for {self.service_provider_name}")
45
+ _LOGGER.debug(f"{self.reference_text_header}")
46
+ _LOGGER.debug(f"{self.reference_text_body}")
47
+
48
+ def __display_qr_ascii(self, stop_event):
49
+
50
+ frame = True
51
+ while not stop_event.is_set():
52
+ # os.system("cls" if os.name == "nt" else "clear")
53
+ # print("Scan this QR Code in the app:")
54
+ qr1, qr2 = self.__get_qr_codes()
55
+ # print(render_qr(qr1) if frame else render_qr(qr2))
56
+ frame = not frame
57
+ stop_event.wait(1)
58
+
59
+ def __set_qr_codes(self, qr1, qr2):
60
+ with self.qr_display_thread_lock:
61
+ self.qr1 = qr1
62
+ self.qr2 = qr2
63
+
64
+ def __get_qr_codes(self):
65
+ with self.qr_display_thread_lock:
66
+ return self.qr1, self.qr2
67
+
68
+ def get_current_qr_codes(self):
69
+ """Get current QR codes for external display (e.g., Home Assistant GUI)."""
70
+ try:
71
+ return self.__get_qr_codes()
72
+ except AttributeError:
73
+ return None
74
+
75
+ def start_app_authentication(self):
76
+ """Start APP authentication and return poll information."""
77
+ self.__select_authenticator("APP")
78
+
79
+ r = self.session.post(
80
+ f"https://www.mitid.dk/mitid-code-app-auth/v1/authenticator-sessions/web/{self.current_authenticator_session_id}/init-auth",
81
+ json={},
82
+ timeout=self.timeout,
83
+ )
84
+ if r.status_code != 200:
85
+ raise Exception(f"Failed to request app login, status code {r.status_code}")
86
+
87
+ r = r.json()
88
+ if (
89
+ "errorCode" in r
90
+ and r["errorCode"]
91
+ == "auth.codeapp.authentication.parallel_sessions_detected"
92
+ ):
93
+ raise Exception("Parallel app sessions detected")
94
+
95
+ self.poll_url = r["pollUrl"]
96
+ self.ticket = r["ticket"]
97
+ self.auth_status = "waiting"
98
+ self.status_message = "Waiting for MitID app"
99
+ self.otp_code = None
100
+ return {"poll_url": self.poll_url, "ticket": self.ticket}
101
+
102
+ def poll_app_authentication_status(self):
103
+ """Poll once for APP authentication status. Returns status dict."""
104
+ if not hasattr(self, "poll_url"):
105
+ raise Exception(
106
+ "Authentication not started - call start_app_authentication() first"
107
+ )
108
+
109
+ r = self.session.post(self.poll_url, json={"ticket": self.ticket}, timeout=self.timeout)
110
+
111
+ if r.status_code != 200:
112
+ return {"status": "error", "message": "Poll request failed"}
113
+
114
+ data = r.json()
115
+ status = data.get("status")
116
+
117
+ if status == "timeout":
118
+ return {"status": "waiting", "message": "Waiting for response..."}
119
+
120
+ elif status == "channel_validation_otp":
121
+ self.otp_code = data["channelBindingValue"]
122
+ self.status_message = f"Use OTP code: {self.otp_code}"
123
+ return {
124
+ "status": "otp_ready",
125
+ "message": self.status_message,
126
+ "otp_code": self.otp_code,
127
+ }
128
+
129
+ elif status == "channel_validation_tqr":
130
+ # Generate QR codes
131
+ channel_binding = data["channelBindingValue"]
132
+ half = int(len(channel_binding) / 2)
133
+
134
+ qr_data_1 = {
135
+ "v": 1,
136
+ "p": 1,
137
+ "t": 2,
138
+ "h": channel_binding[:half],
139
+ "uc": data["updateCount"],
140
+ }
141
+ qr1 = qrcode.QRCode(border=1)
142
+ qr1.add_data(json.dumps(qr_data_1, separators=(",", ":")))
143
+ qr1.make()
144
+
145
+ qr_data_2 = {
146
+ "v": 1,
147
+ "p": 2,
148
+ "t": 2,
149
+ "h": channel_binding[half:],
150
+ "uc": data["updateCount"],
151
+ }
152
+ qr2 = qrcode.QRCode(border=1)
153
+ qr2.add_data(json.dumps(qr_data_2, separators=(",", ":")))
154
+ qr2.make()
155
+
156
+ self.__set_qr_codes(qr1, qr2)
157
+ self.status_message = "Scan QR code with MitID app"
158
+ return {"status": "qr_ready", "message": self.status_message}
159
+
160
+ elif status == "channel_verified":
161
+ self.status_message = "QR verified, waiting for approval"
162
+ return {"status": "verified", "message": self.status_message}
163
+
164
+ elif status == "OK" and data.get("confirmation") == True:
165
+ # Authentication completed
166
+ self.auth_response = data["payload"]["response"]
167
+ self.auth_response_signature = data["payload"]["responseSignature"]
168
+ return {"status": "completed", "message": "Authentication successful"}
169
+
170
+ else:
171
+ return {"status": "error", "message": "Authentication was not accepted"}
172
+
173
+ def complete_app_authentication(self):
174
+ """Complete APP authentication after polling succeeds. Returns finalization session ID."""
175
+ if not hasattr(self, "auth_response"):
176
+ raise Exception(
177
+ "Authentication not completed - poll until status is 'completed'"
178
+ )
179
+
180
+ # SRP protocol stages
181
+ timer_1 = time.time()
182
+ SRP = CustomSRP()
183
+ A = SRP.SRPStage1()
184
+
185
+ r = self.session.post(
186
+ f"https://www.mitid.dk/mitid-code-app-auth/v1/authenticator-sessions/web/{self.current_authenticator_session_id}/init",
187
+ json={"randomA": {"value": A}},
188
+ timeout=self.timeout,
189
+ )
190
+ if r.status_code != 200:
191
+ raise Exception(f"Failed to init app protocol, status code {r.status_code}")
192
+
193
+ srpSalt = r.json()["srpSalt"]["value"]
194
+ randomB = r.json()["randomB"]["value"]
195
+
196
+ m = hashlib.sha256()
197
+ m.update(
198
+ base64.b64decode(self.auth_response)
199
+ + self.current_authenticator_session_flow_key.encode("utf8")
200
+ )
201
+ password = m.hexdigest()
202
+
203
+ m1 = SRP.SRPStage3(
204
+ srpSalt, randomB, password, self.current_authenticator_session_id
205
+ )
206
+
207
+ unhashed_flow_value_proof = self.__create_flow_value_proof()
208
+ m = hashlib.sha256()
209
+ unhashed_flow_value_proof_key = "flowValues" + bytes_to_hex(SRP.K_bits)
210
+ m.update(unhashed_flow_value_proof_key.encode("utf8"))
211
+ hashed_flow_value_proof_key = m.hexdigest()
212
+ hmac_flow_value_proof = hmac.new(
213
+ hex_to_bytes(hashed_flow_value_proof_key),
214
+ unhashed_flow_value_proof,
215
+ hashlib.sha256,
216
+ ).digest()
217
+ flow_value_proof = base64.b64encode(hmac_flow_value_proof).decode("ascii")
218
+
219
+ # Complete authentication
220
+ r = self.session.post(
221
+ f"https://www.mitid.dk/mitid-code-app-auth/v1/authenticator-sessions/web/{self.current_authenticator_session_id}/complete",
222
+ json={"M1": {"value": m1}, "flowValueProof": {"value": flow_value_proof}},
223
+ timeout=self.timeout,
224
+ )
225
+ if r.status_code != 200:
226
+ raise Exception(
227
+ f"Failed to complete app authentication, status code {r.status_code}"
228
+ )
229
+
230
+ self.status_message = "App login was accepted, finalizing authentication"
231
+ _LOGGER.info(
232
+ "App login was accepted, you can now finalize authentication and receive your authorization code"
233
+ )
234
+ self.finalization_authentication_session_id = r.json()[
235
+ "authenticationSessionId"
236
+ ]
237
+ return self.finalization_authentication_session_id
238
+
239
+ def __convert_human_authenticator_name_to_combination_id(self, authenticator_name):
240
+ match authenticator_name:
241
+ case "APP":
242
+ return "S3"
243
+ case "TOKEN":
244
+ return "S1"
245
+ case _:
246
+ raise Exception(f"No such authenticator name ({authenticator_name})")
247
+
248
+ def __convert_combination_id_to_human_authenticator_name(self, combination_id):
249
+ match combination_id:
250
+ case "S4": # App + MitID chip
251
+ return "APP"
252
+ case "S3":
253
+ return "APP"
254
+ case "L2":
255
+ return "APP"
256
+ case "S1":
257
+ return "TOKEN"
258
+ case _:
259
+ raise Exception(f"No such combination ID ({combination_id})")
260
+
261
+ def identify_as_user_and_get_available_authenticators(self, user_id):
262
+ self.user_id = user_id
263
+ r = self.session.put(
264
+ f"https://www.mitid.dk/mitid-core-client-backend/v1/authentication-sessions/{self.authentication_session_id}",
265
+ json={"identityClaim": user_id},
266
+ timeout=self.timeout,
267
+ )
268
+
269
+ if r.status_code != 200:
270
+ _LOGGER.error(
271
+ f"Received status code ({r.status_code}) while attempting to identify as user ({user_id})"
272
+ )
273
+ if (
274
+ r.status_code == 400
275
+ and r.json()["errorCode"] == "control.identity_not_found"
276
+ ):
277
+ _LOGGER.error(f"User '{user_id}' does not exist.")
278
+ raise Exception(r.content)
279
+
280
+ if (
281
+ r.status_code == 400
282
+ and r.json()["errorCode"] == "control.authentication_session_not_found"
283
+ ):
284
+ _LOGGER.error("Authentication session not found")
285
+ raise Exception(r.content)
286
+
287
+ raise Exception(r.content)
288
+
289
+ r = self.session.post(
290
+ f"https://www.mitid.dk/mitid-core-client-backend/v2/authentication-sessions/{self.authentication_session_id}/next",
291
+ json={"combinationId": ""},
292
+ timeout=self.timeout,
293
+ )
294
+
295
+ if r.status_code != 200:
296
+ _LOGGER.error(
297
+ f"Received status code ({r.status_code}) while attempting to get authenticators for user ({user_id})"
298
+ )
299
+ raise Exception(r.content)
300
+
301
+ r = r.json()
302
+ if (
303
+ r["errors"]
304
+ and len(r["errors"]) > 0
305
+ and r["errors"][0]["errorCode"] == "control.authenticator_cannot_be_started"
306
+ ):
307
+ error_text = r["errors"][0]["userMessage"]["text"]["text"]
308
+ _LOGGER.error(
309
+ f"Could not get authenticators, got the following error text: {error_text}"
310
+ )
311
+ raise Exception(error_text)
312
+
313
+ self.current_authenticator_type = r["nextAuthenticator"]["authenticatorType"]
314
+ self.current_authenticator_session_flow_key = r["nextAuthenticator"][
315
+ "authenticatorSessionFlowKey"
316
+ ]
317
+ self.current_authenticator_eafe_hash = r["nextAuthenticator"]["eafeHash"]
318
+ self.current_authenticator_session_id = r["nextAuthenticator"][
319
+ "authenticatorSessionId"
320
+ ]
321
+
322
+ available_combinations = r["combinations"]
323
+ available_authenticators = {}
324
+ for available_combination in available_combinations:
325
+ available_authenticators[
326
+ self.__convert_combination_id_to_human_authenticator_name(
327
+ available_combination["id"]
328
+ )
329
+ ] = available_combination["combinationItems"][0]["name"]
330
+
331
+ return available_authenticators
332
+
333
+ def __create_flow_value_proof(self):
334
+ hashed_broker_security_context = hashlib.sha256(
335
+ self.broker_security_context.encode("utf8")
336
+ ).hexdigest()
337
+ base64_reference_text_header = base64.b64encode(
338
+ (self.reference_text_header.encode("utf8"))
339
+ ).decode("ascii")
340
+ base64_reference_text_body = base64.b64encode(
341
+ (self.reference_text_body.encode("utf8"))
342
+ ).decode("ascii")
343
+ base64_service_provider_name = base64.b64encode(
344
+ (self.service_provider_name.encode("utf8"))
345
+ ).decode("ascii")
346
+ return f"{self.current_authenticator_session_id},{self.current_authenticator_session_flow_key},{self.client_hash},{self.current_authenticator_eafe_hash},{hashed_broker_security_context},{base64_reference_text_header},{base64_reference_text_body},{base64_service_provider_name}".encode(
347
+ "utf-8"
348
+ )
349
+
350
+ def __select_authenticator(self, authenticator_type: str):
351
+ if authenticator_type == self.current_authenticator_type:
352
+ return
353
+
354
+ combination_id = self.__convert_human_authenticator_name_to_combination_id(
355
+ authenticator_type
356
+ )
357
+
358
+ r = self.session.post(
359
+ f"https://www.mitid.dk/mitid-core-client-backend/v2/authentication-sessions/{self.authentication_session_id}/next",
360
+ json={"combinationId": combination_id},
361
+ timeout=self.timeout,
362
+ )
363
+
364
+ if r.status_code != 200:
365
+ _LOGGER.error(
366
+ f"Received status code ({r.status_code}) while attempting to get authenticators for user ({self.user_id})"
367
+ )
368
+ raise Exception(r.content)
369
+
370
+ r = r.json()
371
+ if (
372
+ r["errors"]
373
+ and len(r["errors"]) > 0
374
+ and r["errors"][0]["errorCode"] == "control.authenticator_cannot_be_started"
375
+ ):
376
+ error_text = r["errors"][0]["userMessage"]["text"]["text"]
377
+ _LOGGER.error(
378
+ f"Could not get authenticators, got the following error text: {error_text}"
379
+ )
380
+ raise Exception(error_text)
381
+
382
+ self.current_authenticator_type = r["nextAuthenticator"]["authenticatorType"]
383
+ self.current_authenticator_session_flow_key = r["nextAuthenticator"][
384
+ "authenticatorSessionFlowKey"
385
+ ]
386
+ self.current_authenticator_eafe_hash = r["nextAuthenticator"]["eafeHash"]
387
+ self.current_authenticator_session_id = r["nextAuthenticator"][
388
+ "authenticatorSessionId"
389
+ ]
390
+
391
+ if self.current_authenticator_type != authenticator_type:
392
+ raise Exception(
393
+ f"Was not able to choose the desired authenticator ({authenticator_type}), instead we received ({self.current_authenticator_type})"
394
+ )
395
+
396
+ def authenticate_with_token(self, token_digits: str):
397
+ self.__select_authenticator("TOKEN")
398
+
399
+ timer_1 = time.time()
400
+ SRP = CustomSRP()
401
+ A = SRP.SRPStage1()
402
+ timer_1 = time.time() - timer_1
403
+
404
+ r = self.session.post(
405
+ f"https://www.mitid.dk/mitid-code-token-auth/v1/authenticator-sessions/{self.current_authenticator_session_id}/codetoken-init",
406
+ json={"randomA": {"value": A}},
407
+ timeout=self.timeout,
408
+ )
409
+ if r.status_code != 200:
410
+ _LOGGER.error(
411
+ f"Failed to init TOTP code protocol, status code {r.status_code}"
412
+ )
413
+ raise Exception(r.content)
414
+
415
+ timer_2 = time.time()
416
+ r = r.json()
417
+ # pbkdfSalt is not actually used even though we receive it, what the hell are they doing here?
418
+ # This seems like schlock
419
+ # pbkdfSalt = r["pbkdf2Salt"]["value"]
420
+ srpSalt = r["srpSalt"]["value"]
421
+ randomB = r["randomB"]["value"]
422
+
423
+ m1 = SRP.SRPStage3(
424
+ srpSalt,
425
+ randomB,
426
+ bytes_to_hex(self.current_authenticator_session_flow_key.encode("utf-8")),
427
+ self.current_authenticator_session_id,
428
+ )
429
+
430
+ unhashed_flow_value_proof = self.__create_flow_value_proof()
431
+ m = hashlib.sha256()
432
+ unhashed_flow_value_proof_key = "OTP" + token_digits + bytes_to_hex(SRP.K_bits)
433
+ m.update(unhashed_flow_value_proof_key.encode("utf8"))
434
+ flow_value_proof_key = m.digest()
435
+
436
+ flow_value_proof = hmac.new(
437
+ flow_value_proof_key, unhashed_flow_value_proof, hashlib.sha256
438
+ ).hexdigest()
439
+
440
+ timer_2 = time.time() - timer_2
441
+ front_end_processing_time = int((timer_1 + timer_2) * 1000)
442
+
443
+ r = self.session.post(
444
+ f"https://www.mitid.dk/mitid-code-token-auth/v1/authenticator-sessions/{self.current_authenticator_session_id}/codetoken-prove",
445
+ json={
446
+ "m1": {"value": m1},
447
+ "flowValueProof": {"value": flow_value_proof},
448
+ "frontEndProcessingTime": front_end_processing_time,
449
+ },
450
+ timeout=self.timeout,
451
+ )
452
+ if r.status_code != 204:
453
+ _LOGGER.error(f"Failed to submit TOTP code, status code {r.status_code}")
454
+ raise Exception(r.content)
455
+
456
+ r = self.session.post(
457
+ f"https://www.mitid.dk/mitid-core-client-backend/v2/authentication-sessions/{self.authentication_session_id}/next",
458
+ json={"combinationId": ""},
459
+ timeout=self.timeout,
460
+ )
461
+ if r.status_code != 200:
462
+ _LOGGER.error(f"Failed to prove TOTP code, status code {r.status_code}")
463
+ raise Exception(r.content)
464
+
465
+ if (
466
+ r.json()["errors"]
467
+ and len(r.json()["errors"]) > 0
468
+ and r.json()["errors"][0]["errorCode"] == "TOTP_INVALID"
469
+ ):
470
+ error_text = r.json()["errors"][0]["message"]
471
+ _LOGGER.error(
472
+ f"Could not log in with the provided TOTP code, got the following message: {error_text}"
473
+ )
474
+ raise Exception(r.content)
475
+
476
+ r = r.json()
477
+ if (
478
+ "nextAuthenticator" not in r
479
+ or "authenticatorType" not in r["nextAuthenticator"]
480
+ or r["nextAuthenticator"]["authenticatorType"] != "PASSWORD"
481
+ ):
482
+ _LOGGER.error(
483
+ f"Ran into an unexpected situation, was expecting to be asked for password after TOTP but got the following response"
484
+ )
485
+ raise Exception(r.content)
486
+
487
+ self.current_authenticator_type = r["nextAuthenticator"]["authenticatorType"]
488
+ self.current_authenticator_session_flow_key = r["nextAuthenticator"][
489
+ "authenticatorSessionFlowKey"
490
+ ]
491
+ self.current_authenticator_eafe_hash = r["nextAuthenticator"]["eafeHash"]
492
+ self.current_authenticator_session_id = r["nextAuthenticator"][
493
+ "authenticatorSessionId"
494
+ ]
495
+ _LOGGER.info("Token code accepted, you now need to validate your password")
496
+
497
+ def authenticate_with_password(self, password: str):
498
+ if self.current_authenticator_type != "PASSWORD":
499
+ raise Exception(
500
+ f"You cannot authenticate with password before completing authentication with token code, the current authenticator type was ({self.current_authenticator_type})"
501
+ )
502
+
503
+ timer_1 = time.time()
504
+ SRP = CustomSRP()
505
+ A = SRP.SRPStage1()
506
+ timer_1 = time.time() - timer_1
507
+
508
+ r = self.session.post(
509
+ f"https://www.mitid.dk/mitid-password-auth/v1/authenticator-sessions/{self.current_authenticator_session_id}/init",
510
+ json={"randomA": {"value": A}},
511
+ timeout=self.timeout,
512
+ )
513
+ if r.status_code != 200:
514
+ _LOGGER.error(
515
+ f"Failed to init password protocol, status code {r.status_code}"
516
+ )
517
+ raise Exception(r.content)
518
+
519
+ timer_2 = time.time()
520
+ r = r.json()
521
+ pbkdfSalt = r["pbkdf2Salt"]["value"]
522
+ srpSalt = r["srpSalt"]["value"]
523
+ randomB = r["randomB"]["value"]
524
+
525
+ password = hashlib.pbkdf2_hmac(
526
+ "sha256", password.encode("utf-8"), hex_to_bytes(pbkdfSalt), 20000, 32
527
+ ).hex()
528
+
529
+ m1 = SRP.SRPStage3(
530
+ srpSalt, randomB, password, self.current_authenticator_session_id
531
+ )
532
+
533
+ unhashed_flow_value_proof = self.__create_flow_value_proof()
534
+ m = hashlib.sha256()
535
+ unhashed_flow_value_proof_key = "flowValues" + bytes_to_hex(SRP.K_bits)
536
+ m.update(unhashed_flow_value_proof_key.encode("utf8"))
537
+ flow_value_proof_key = m.digest()
538
+
539
+ flow_value_proof = hmac.new(
540
+ flow_value_proof_key, unhashed_flow_value_proof, hashlib.sha256
541
+ ).hexdigest()
542
+
543
+ timer_2 = time.time() - timer_2
544
+ front_end_processing_time = int((timer_1 + timer_2) * 1000)
545
+
546
+ r = self.session.post(
547
+ f"https://www.mitid.dk/mitid-password-auth/v1/authenticator-sessions/{self.current_authenticator_session_id}/password-prove",
548
+ json={
549
+ "m1": {"value": m1},
550
+ "flowValueProof": {"value": flow_value_proof},
551
+ "frontEndProcessingTime": front_end_processing_time,
552
+ },
553
+ timeout=self.timeout,
554
+ )
555
+ if r.status_code != 204:
556
+ _LOGGER.error(f"Failed to submit password, status code {r.status_code}")
557
+ raise Exception(r.content)
558
+
559
+ r = self.session.post(
560
+ f"https://www.mitid.dk/mitid-core-client-backend/v2/authentication-sessions/{self.authentication_session_id}/next",
561
+ json={"combinationId": ""},
562
+ timeout=self.timeout,
563
+ )
564
+ if r.status_code != 200:
565
+ _LOGGER.error(f"Failed to prove password, status code {r.status_code}")
566
+ raise Exception(r.content)
567
+
568
+ r = r.json()
569
+ if r["errors"] and len(r["errors"]) > 0:
570
+ if r["errors"][0]["errorCode"] == "PASSWORD_INVALID":
571
+ error_text = r["errors"][0]["message"]
572
+ _LOGGER.error(
573
+ f"Could not log in with the provided password, got the following message: {error_text}"
574
+ )
575
+ raise Exception(error_text)
576
+ elif r["errors"][0]["errorCode"] == "core.psd2.error":
577
+ error_text = r["errors"][0]["message"]
578
+ _LOGGER.error(
579
+ f"Could not log in due to an error, probably due to a wrong password provided. Got the following message: {error_text}"
580
+ )
581
+ raise Exception(error_text)
582
+ else:
583
+ error_text = r["errors"][0]["message"]
584
+ _LOGGER.error(
585
+ f"Could not log in due to an unknown error, got the following message: {error_text}"
586
+ )
587
+ raise Exception(error_text)
588
+
589
+ self.finalization_authentication_session_id = r["nextSessionId"]
590
+ self.status_message = "Password accepted, finalizing authentication"
591
+ _LOGGER.info(
592
+ "Password was accepted, you can now finalize authentication and receive your authorization code"
593
+ )
594
+
595
+ def authenticate_with_app(self):
596
+ self.__select_authenticator("APP")
597
+
598
+ r = self.session.post(
599
+ f"https://www.mitid.dk/mitid-code-app-auth/v1/authenticator-sessions/web/{self.current_authenticator_session_id}/init-auth",
600
+ json={},
601
+ timeout=self.timeout,
602
+ )
603
+ if r.status_code != 200:
604
+ _LOGGER.error(f"Failed to request app login, status code {r.status_code}")
605
+ raise Exception(r.content)
606
+
607
+ r = r.json()
608
+ if (
609
+ "errorCode" in r
610
+ and r["errorCode"]
611
+ == "auth.codeapp.authentication.parallel_sessions_detected"
612
+ ):
613
+ _LOGGER.error(
614
+ "Parallel app sessions detected, only a single app login session can be happening at any one time"
615
+ )
616
+ raise Exception(
617
+ "Parallel app sessions detected. Please wait a few minutes before trying again."
618
+ )
619
+
620
+ poll_url = r["pollUrl"]
621
+ ticket = r["ticket"]
622
+ self.status_message = "Login request has been made, open your MitID app now"
623
+ _LOGGER.info("Login request has been made, open your MitID app now")
624
+ qr_stop_event = None
625
+ qr_display_thread = None
626
+ while True:
627
+ r = self.session.post(poll_url, json={"ticket": ticket}, timeout=self.timeout)
628
+
629
+ if r.status_code == 200 and r.json()["status"] == "timeout":
630
+ continue
631
+
632
+ if r.status_code == 200 and r.json()["status"] == "channel_validation_otp":
633
+ self.status_message = f"Please use the following OTP code in the app: {r.json()['channelBindingValue']}"
634
+ _LOGGER.info(
635
+ f"Please use the following OTP code in the app: {r.json()['channelBindingValue']}"
636
+ )
637
+ continue
638
+
639
+ if r.status_code == 200 and r.json()["status"] == "channel_validation_tqr":
640
+ qr_data = {
641
+ "v": 1,
642
+ "p": 1,
643
+ "t": 2,
644
+ "h": r.json()["channelBindingValue"][
645
+ : int(len(r.json()["channelBindingValue"]) / 2)
646
+ ],
647
+ "uc": r.json()["updateCount"],
648
+ }
649
+ qr1 = qrcode.QRCode(border=1)
650
+ qr1.add_data(json.dumps(qr_data, separators=(",", ":")))
651
+ qr1.make()
652
+
653
+ qr_data["p"] = 2
654
+ qr_data["h"] = r.json()["channelBindingValue"][
655
+ int(len(r.json()["channelBindingValue"]) / 2) :
656
+ ]
657
+
658
+ qr2 = qrcode.QRCode(border=1)
659
+ qr2.add_data(json.dumps(qr_data, separators=(",", ":")))
660
+ qr2.make()
661
+
662
+ self.__set_qr_codes(qr1, qr2)
663
+
664
+ if qr_stop_event is None:
665
+ qr_stop_event = threading.Event()
666
+ qr_display_thread = threading.Thread(
667
+ target=self.__display_qr_ascii, args=[qr_stop_event]
668
+ )
669
+ qr_display_thread.start()
670
+
671
+ continue
672
+
673
+ if r.status_code == 200 and r.json()["status"] == "channel_verified":
674
+ if qr_display_thread and qr_display_thread.is_alive():
675
+ qr_stop_event.set()
676
+ qr_display_thread.join()
677
+ self.status_message = "The OTP/QR code has been verified, now waiting user to approve login"
678
+ _LOGGER.info(
679
+ "The OTP/QR code has been verified, now waiting user to approve login"
680
+ )
681
+ continue
682
+
683
+ if not (
684
+ r.status_code == 200
685
+ and r.json()["status"] == "OK"
686
+ and r.json()["confirmation"] == True
687
+ ):
688
+ if qr_display_thread and qr_display_thread.is_alive():
689
+ qr_stop_event.set()
690
+ qr_display_thread.join()
691
+ _LOGGER.error("Login request was not accepted")
692
+ raise Exception(r.content)
693
+
694
+ break
695
+
696
+ r = r.json()
697
+ response = r["payload"]["response"]
698
+ response_signature = r["payload"]["responseSignature"]
699
+
700
+ timer_1 = time.time()
701
+ SRP = CustomSRP()
702
+ A = SRP.SRPStage1()
703
+ timer_1 = time.time() - timer_1
704
+
705
+ r = self.session.post(
706
+ f"https://www.mitid.dk/mitid-code-app-auth/v1/authenticator-sessions/web/{self.current_authenticator_session_id}/init",
707
+ json={"randomA": {"value": A}},
708
+ timeout=self.timeout,
709
+ )
710
+ if r.status_code != 200:
711
+ _LOGGER.error(f"Failed to init app protocol, status code {r.status_code}")
712
+ raise Exception(r.content)
713
+
714
+ timer_2 = time.time()
715
+ srpSalt = r.json()["srpSalt"]["value"]
716
+ randomB = r.json()["randomB"]["value"]
717
+
718
+ m = hashlib.sha256()
719
+ m.update(
720
+ base64.b64decode(response)
721
+ + self.current_authenticator_session_flow_key.encode("utf8")
722
+ )
723
+ password = m.hexdigest()
724
+
725
+ m1 = SRP.SRPStage3(
726
+ srpSalt, randomB, password, self.current_authenticator_session_id
727
+ )
728
+
729
+ unhashed_flow_value_proof = self.__create_flow_value_proof()
730
+ m = hashlib.sha256()
731
+ unhashed_flow_value_proof_key = "flowValues" + bytes_to_hex(SRP.K_bits)
732
+ m.update(unhashed_flow_value_proof_key.encode("utf8"))
733
+ flow_value_proof_key = m.digest()
734
+
735
+ flow_value_proof = hmac.new(
736
+ flow_value_proof_key, unhashed_flow_value_proof, hashlib.sha256
737
+ ).hexdigest()
738
+
739
+ timer_2 = time.time() - timer_2
740
+
741
+ r = self.session.post(
742
+ f"https://www.mitid.dk/mitid-code-app-auth/v1/authenticator-sessions/web/{self.current_authenticator_session_id}/prove",
743
+ json={"m1": {"value": m1}, "flowValueProof": {"value": flow_value_proof}},
744
+ timeout=self.timeout,
745
+ )
746
+ if r.status_code != 200:
747
+ _LOGGER.error(
748
+ f"Failed to submit app response proof, status code {r.status_code}"
749
+ )
750
+ raise Exception(r.content)
751
+
752
+ timer_3 = time.time()
753
+ m2 = r.json()["m2"]["value"]
754
+ if not SRP.SRPStage5(m2):
755
+ raise Exception("m2 could not be validated during proving of app response")
756
+ auth_enc = base64.b64encode(
757
+ SRP.AuthEnc(base64.b64decode(pad(response_signature)))
758
+ ).decode("ascii")
759
+ timer_3 = time.time() - timer_3
760
+
761
+ front_end_processing_time = int((timer_1 + timer_2 + timer_3) * 1000)
762
+
763
+ r = self.session.post(
764
+ f"https://www.mitid.dk/mitid-code-app-auth/v1/authenticator-sessions/web/{self.current_authenticator_session_id}/verify",
765
+ json={
766
+ "encAuth": auth_enc,
767
+ "frontEndProcessingTime": front_end_processing_time,
768
+ },
769
+ timeout=self.timeout,
770
+ )
771
+ if r.status_code != 204:
772
+ _LOGGER.error(
773
+ f"Failed to verify app response signature, status code {r.status_code}"
774
+ )
775
+ raise Exception(r.content)
776
+
777
+ r = self.session.post(
778
+ f"https://www.mitid.dk/mitid-core-client-backend/v2/authentication-sessions/{self.authentication_session_id}/next",
779
+ json={"combinationId": ""},
780
+ timeout=self.timeout,
781
+ )
782
+ if r.status_code != 200:
783
+ _LOGGER.error(f"Failed to prove app login, status code {r.status_code}")
784
+ raise Exception(r.content)
785
+
786
+ r = r.json()
787
+ if r["errors"] and len(r["errors"]) > 0:
788
+ _LOGGER.error(f"Could not prove the app login")
789
+ raise Exception("Could not prove the app login. Please try again.")
790
+
791
+ self.finalization_authentication_session_id = r["nextSessionId"]
792
+ self.status_message = "App login was accepted, finalizing authentication"
793
+ _LOGGER.info(
794
+ "App login was accepted, you can now finalize authentication and receive your authorization code"
795
+ )
796
+
797
+ def finalize_authentication_and_get_authorization_code(self):
798
+ if not self.finalization_authentication_session_id:
799
+ raise Exception(
800
+ "No finalization session ID set, make sure you have completed an authentication flow."
801
+ )
802
+
803
+ r = self.session.put(
804
+ f"https://www.mitid.dk/mitid-core-client-backend/v1/authentication-sessions/{self.finalization_authentication_session_id}/finalization",
805
+ timeout=self.timeout,
806
+ )
807
+ if r.status_code != 200:
808
+ _LOGGER.error(
809
+ f"Failed to retrieve authorization code, status code {r.status_code}"
810
+ )
811
+ raise Exception(r.content)
812
+
813
+ return r.json()["authorizationCode"]
@@ -0,0 +1,98 @@
1
+ """Ready-made steps of a NemLog-in login flow around the MitID browser client."""
2
+
3
+ import binascii, base64, hashlib, logging
4
+ from typing import Any
5
+
6
+ from Cryptodome import Random
7
+ from requests import Response, Session
8
+ from .browser_client import BrowserClient
9
+ from bs4 import BeautifulSoup
10
+
11
+ _LOGGER = logging.getLogger(__name__)
12
+
13
+ def get_authentication_code(
14
+ session: Session, aux: dict[str, Any], method: str, user_id: str, password: str | None
15
+ ) -> str:
16
+ """Log `user_id` in with the MitID app, from NemLog-in's parameters `aux`, and give the code.
17
+
18
+ Raises:
19
+ NotImplementedError: `method` is "TOKEN", which needs the token's digits.
20
+ Exception: the method is unknown, or not one the user has.
21
+ """
22
+ client_hash = binascii.hexlify(base64.b64decode(aux["coreClient"]["checksum"])).decode('ascii')
23
+ authentication_session_id = aux["parameters"]["authenticationSessionId"]
24
+
25
+ MitIDClient = BrowserClient(client_hash, authentication_session_id, session)
26
+ available_authenticators = MitIDClient.identify_as_user_and_get_available_authenticators(user_id)
27
+
28
+ _LOGGER.debug(f"Available authenticator: {available_authenticators}")
29
+
30
+ if method == "TOKEN" and "TOKEN" in available_authenticators:
31
+ if not password:
32
+ raise ValueError("Password is required for TOKEN authentication")
33
+ # Token digits must be provided via the caller, not via interactive input
34
+ raise NotImplementedError(
35
+ "TOKEN authentication requires token_digits parameter - interactive input not supported"
36
+ )
37
+ elif method == "APP" and "APP" in available_authenticators:
38
+ MitIDClient.authenticate_with_app()
39
+ elif method == "TOKEN" and "TOKEN" not in available_authenticators:
40
+ raise Exception(f"Token authentication method chosen but not available for MitID user")
41
+ elif method == "APP" and "APP" not in available_authenticators:
42
+ raise Exception(f"App authentication method chosen but not available for MitID user")
43
+ else:
44
+ raise Exception(f"Unknown authenticator method: {method}")
45
+
46
+ authorization_code = MitIDClient.finalize_authentication_and_get_authorization_code()
47
+ return authorization_code
48
+
49
+ def choose_between_multiple_identitites(
50
+ session: Session, request: Response, soup: BeautifulSoup, identity_index: int = 0
51
+ ) -> tuple[Response, BeautifulSoup]:
52
+ """Choose between multiple identities.
53
+
54
+ Args:
55
+ session: requests session
56
+ request: current request object
57
+ soup: BeautifulSoup parsed response
58
+ identity_index: zero-based index of the identity to select (default: 0, first identity)
59
+
60
+ Returns:
61
+ Tuple of (request, soup) after identity selection
62
+ """
63
+ data = {}
64
+ for soup_input in soup.form.select("input"):
65
+ try:
66
+ data[soup_input["name"]] = soup_input["value"]
67
+ except KeyError:
68
+ data[soup_input.get("name", "")] = ""
69
+ login_options = soup.select("div.list-link-box")
70
+ _LOGGER.info('Multiple identities available:')
71
+ for i, login_option in enumerate(login_options):
72
+ _LOGGER.info(f'{i+1}: {login_option.select_one("div.list-link-text").string}')
73
+
74
+ if identity_index < 0 or identity_index >= len(login_options):
75
+ raise ValueError(f"Identity index {identity_index} out of range (0-{len(login_options)-1})")
76
+
77
+ selected_option = login_options[identity_index].a["data-loginoptions"]
78
+ data["ChosenOptionJson"] = selected_option
79
+ _LOGGER.info(f'Selected identity {identity_index + 1}')
80
+
81
+ request = session.post(request.url, data=data)
82
+ soup = BeautifulSoup(request.text, "xml")
83
+ return request, soup
84
+
85
+ def __generate_random_string():
86
+ return binascii.hexlify(Random.new().read(28)).decode("utf-8")
87
+
88
+ def __generate_challenge(verifier):
89
+ return base64.urlsafe_b64encode(hashlib.sha256(verifier.encode("utf-8")).digest()).decode("utf-8").rstrip("=")
90
+
91
+ def generate_nem_login_parameters() -> tuple[str, str, str, str]:
92
+ """A fresh state, nonce, PKCE code verifier and its S256 code challenge for NemLog-in."""
93
+ nem_login_state = __generate_random_string()
94
+ nem_login_nonce = __generate_random_string()
95
+ nem_login_code_verifier = __generate_random_string()
96
+ nem_login_code_challenge = __generate_challenge(nem_login_code_verifier)
97
+
98
+ return nem_login_state, nem_login_nonce, nem_login_code_verifier, nem_login_code_challenge
mitid_connect/py.typed ADDED
File without changes
mitid_connect/srp.py ADDED
@@ -0,0 +1,216 @@
1
+ """The SRP-6a exchange and AES-GCM encryption MitID's browser client speaks."""
2
+
3
+ import base64
4
+ import secrets
5
+ import binascii
6
+ import hashlib
7
+ from collections.abc import Callable
8
+ from typing import Protocol, cast
9
+
10
+ from Cryptodome.Cipher import AES
11
+ from Cryptodome import Random
12
+
13
+ BLOCK_SIZE = 16
14
+
15
+
16
+ class _Gcm(Protocol):
17
+ def encrypt_and_digest(self, plaintext: bytes) -> tuple[bytes, bytes]: ...
18
+ def decrypt_and_verify(self, ciphertext: bytes, received_mac_tag: bytes) -> bytes: ...
19
+
20
+
21
+ # pycryptodomex annotates AES.new with a bare Dict among its overloads, which a strict check reads
22
+ # as unknown; it is taken by name, with the type it has in GCM mode.
23
+ _new_gcm = cast(Callable[[bytes, int, bytes], _Gcm], getattr(AES, "new"))
24
+
25
+
26
+ def pad(s: str) -> str:
27
+ """`s` padded to a whole number of blocks, PKCS#7 style."""
28
+ return s + (BLOCK_SIZE - len(s) % BLOCK_SIZE) * chr(BLOCK_SIZE - len(s) % BLOCK_SIZE)
29
+
30
+
31
+ def unpad(s: str) -> str:
32
+ """`s` with its PKCS#7 padding taken off."""
33
+ return s[:-ord(s[len(s) - 1:])]
34
+
35
+
36
+ def int_to_hex(x: int) -> str:
37
+ """`x` in lower-case hex, without a prefix."""
38
+ return format(x, 'x')
39
+
40
+
41
+ def int_to_bytes(x: int) -> bytes:
42
+ """`x` as big-endian bytes, as few as hold it."""
43
+ return x.to_bytes((x.bit_length() + 7) // 8, 'big')
44
+
45
+
46
+ def bytes_to_int(x: bytes) -> int:
47
+ """The big-endian number `x` holds."""
48
+ return int.from_bytes(x, 'big')
49
+
50
+
51
+ def bytes_to_hex(x: bytes) -> str:
52
+ """`x` in lower-case hex."""
53
+ return binascii.hexlify(x).decode('utf-8')
54
+
55
+
56
+ def hex_to_bytes(x: str) -> bytes:
57
+ """The bytes the hex `x` spells."""
58
+ return binascii.unhexlify(x)
59
+
60
+
61
+ def hex_to_int(x: str) -> int:
62
+ """The number the hex `x` spells."""
63
+ return int(x, 16)
64
+
65
+
66
+ def AesDecryptWithKey(encMessage: str | bytes, key: bytes) -> bytes:
67
+ """The plain text of the base64 AES-GCM message `encMessage`: IV, cipher text, then tag."""
68
+ encMessage = base64.b64decode(encMessage)
69
+
70
+ iv = encMessage[:BLOCK_SIZE]
71
+ cipherText = encMessage[BLOCK_SIZE:len(encMessage)-BLOCK_SIZE]
72
+ macTag = encMessage[len(encMessage)-BLOCK_SIZE:]
73
+
74
+ cipher = _new_gcm(key, AES.MODE_GCM, iv)
75
+ decrypted = cipher.decrypt_and_verify(cipherText, macTag)
76
+ return decrypted
77
+
78
+ class CustomSRP():
79
+ """One SRP-6a exchange with MitID, and the session key it agrees."""
80
+
81
+ n: int
82
+ g: int
83
+ a: int
84
+ big_a: int
85
+ big_b: int
86
+ hashed_password: int
87
+ K_bits: bytes
88
+ M1_hex: str
89
+
90
+ def SRPStage1(self) -> str:
91
+ """Pick a secret, and give the public value A, in hex, to send."""
92
+ self.n = 4983313092069490398852700692508795473567251422586244806694940877242664573189903192937797446992068818099986958054998012331720869136296780936009508700487789962429161515853541556719593346959929531150706457338429058926505817847524855862259333438239756474464759974189984231409170758360686392625635632084395639143229889862041528635906990913087245817959460948345336333086784608823084788906689865566621015175424691535711520273786261989851360868669067101108956159530739641990220546209432953829448997561743719584980402874346226230488627145977608389858706391858138200618631385210304429902847702141587470513336905449351327122086464725143970313054358650488241167131544692349123381333204515637608656643608393788598011108539679620836313915590459891513992208387515629240292926570894321165482608544030173975452781623791805196546326996790536207359143527182077625412731080411108775183565594553871817639221414953634530830290393130518228654795859
93
+ self.g = 2
94
+ a = secrets.randbits(256)
95
+ if a < 0:
96
+ a += self.n
97
+ self.a = a
98
+
99
+ self.big_a = pow(self.g, self.a, self.n)
100
+ return int_to_hex(self.big_a)
101
+
102
+ def computeLittleS(self) -> int:
103
+ """The multiplier k of SRP-6a, as MitID works it out."""
104
+ N_bytes = int_to_bytes(self.n)
105
+ g_bytes = int_to_bytes(self.g)
106
+
107
+ # Prepend g_bytes with |N_bytes|-|g_bytes| of 0
108
+ g_bytes = (b"\0" * (len(N_bytes) - len(g_bytes))) + g_bytes
109
+
110
+ m = hashlib.sha256()
111
+ m.update(str(self.n).encode('utf-8') + g_bytes)
112
+ digest = m.hexdigest()
113
+
114
+ return hex_to_int(digest)
115
+
116
+ def computeU(self) -> int:
117
+ """The scrambler u, from A and B."""
118
+ N_length = len(int_to_bytes(self.n))
119
+ A_bytes = int_to_bytes(self.big_a)
120
+ B_bytes = int_to_bytes(self.big_b)
121
+
122
+ # Prepend A_bytes with |N_bytes|-|A_bytes| of 0
123
+ A_bytes = (b"\0"*(N_length-len(A_bytes)))+A_bytes
124
+
125
+ # Prepend r_bytes with |N_bytes|-|r_bytes| of 0
126
+ B_bytes = (b"\0"*(N_length-len(B_bytes)))+B_bytes
127
+
128
+ m = hashlib.sha256()
129
+ m.update(A_bytes + B_bytes)
130
+ hashed = m.hexdigest()
131
+ u = hex_to_int(hashed) % self.n
132
+ return u
133
+
134
+ def computeSessionKey(self) -> int:
135
+ """The premaster secret S. MitID's own app does not refuse u == 0, so this does not."""
136
+ u = self.computeU()
137
+
138
+ s = self.computeLittleS()
139
+
140
+ a = u * self.hashed_password + self.a
141
+ c = pow((self.big_b - (pow(self.g, self.hashed_password, self.n) * s)), a, self.n)
142
+ if a < 0:
143
+ a += self.n
144
+
145
+ return c
146
+
147
+ def computeM1(self, r: str, srpSalt: str) -> str:
148
+ """The client's proof M1, in hex."""
149
+ m = hashlib.sha256()
150
+ m.update(str(self.n).encode("utf-8"))
151
+ N = hex_to_int(m.hexdigest())
152
+
153
+ m = hashlib.sha256()
154
+ m.update(str(self.g).encode("utf-8"))
155
+ g = hex_to_int(m.hexdigest())
156
+ a = N ^ g
157
+
158
+ m = hashlib.sha256()
159
+ m.update((str(a) + r + srpSalt + str(self.big_a) + str(self.big_b) + bytes_to_hex(self.K_bits)).encode("ascii"))
160
+ return m.hexdigest()
161
+
162
+ def SRPStage3(self, srpSalt: str, randomB: str, password: str, auth_session_id: str) -> str:
163
+ """Take the server's B, agree the session key, and give the proof M1 to send.
164
+
165
+ Raises:
166
+ Exception: B fails SRP's safety check.
167
+ """
168
+ self.big_b = hex_to_int(randomB)
169
+
170
+ if(self.big_b == 0 or self.big_b % self.n == 0):
171
+ raise Exception("randomB did not pass safety check")
172
+
173
+ m = hashlib.sha256()
174
+ m.update((srpSalt + password).encode("ascii"))
175
+ self.hashed_password = hex_to_int(m.hexdigest())
176
+
177
+ a = self.computeSessionKey()
178
+
179
+ m = hashlib.sha256()
180
+ m.update(str(a).encode("utf-8"))
181
+ self.K_bits = m.digest()
182
+
183
+ m = hashlib.sha256()
184
+ m.update(auth_session_id.encode("utf-8"))
185
+ I_hex = m.hexdigest()
186
+
187
+ self.M1_hex = self.computeM1(I_hex, srpSalt)
188
+
189
+ return self.M1_hex
190
+
191
+ # Should satisfy if the server is correct
192
+ # Interestingly enough, this cannot be checked for the pin-binding proof
193
+ def SRPStage5(self, M2_hex: str) -> bool:
194
+ """Whether the server's proof M2 matches the session key agreed."""
195
+ M1_bigInt = int(self.M1_hex, 16)
196
+
197
+ m = hashlib.sha256()
198
+ m.update((str(self.big_a) + str(M1_bigInt) + bytes_to_hex(self.K_bits)).encode('utf-8'))
199
+ M2_hex_verify = m.hexdigest()
200
+ return M2_hex_verify == M2_hex
201
+
202
+ def AuthEnc(self, plainText: bytes) -> bytes:
203
+ """`plainText` encrypted with the session key: IV, cipher text, then tag."""
204
+ iv = Random.new().read(BLOCK_SIZE)
205
+ cipher = _new_gcm(self.K_bits, AES.MODE_GCM, iv)
206
+ ciphertext, tag = cipher.encrypt_and_digest(plainText)
207
+ return (iv + ciphertext + tag)
208
+
209
+ def AuthDec(self, encMessage: str | bytes) -> bytes:
210
+ """The plain text of a message encrypted with the session key."""
211
+ return AesDecryptWithKey(encMessage, self.K_bits)
212
+
213
+ def AuthDecPin(self, encMessage: str | bytes) -> bytes:
214
+ """The plain text of a message encrypted with the PIN key derived from the session key."""
215
+ pin_key = hashlib.sha256(binascii.hexlify(self.K_bits) + b"PIN").digest()
216
+ return AesDecryptWithKey(encMessage, pin_key)
@@ -0,0 +1,55 @@
1
+ Metadata-Version: 2.4
2
+ Name: mitid_connect
3
+ Version: 0.0.1
4
+ Summary: Log in through MitID from Python: an unofficial implementation of MitID's browser client, with the MitID app or a code token.
5
+ Author: HairingX
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/HairingX/mitid_connect
8
+ Project-URL: Source, https://github.com/HairingX/mitid_connect
9
+ Project-URL: Issues, https://github.com/HairingX/mitid_connect/issues
10
+ Project-URL: Releases, https://github.com/HairingX/mitid_connect/releases
11
+ Keywords: mitid,nemlog-in,login,denmark
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Software Development :: Libraries
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.12
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: beautifulsoup4>=4.12.0
26
+ Requires-Dist: lxml>=5.0.0
27
+ Requires-Dist: pycryptodomex>=3.24.1
28
+ Requires-Dist: qrcode>=8.2
29
+ Requires-Dist: requests>=2.31.0
30
+ Dynamic: license-file
31
+
32
+ # mitid_connect
33
+
34
+ Log in through [MitID](https://www.mitid.dk) from Python: an unofficial implementation of MitID's
35
+ browser client. It authenticates with the MitID app - by QR code or one-time code - or with a
36
+ code token, and gives back the authorization code that the service being logged in to exchanges
37
+ for its own tokens.
38
+
39
+ ```python
40
+ from mitid_connect import BrowserClient
41
+
42
+ client = BrowserClient(client_hash, authentication_session_id)
43
+ authenticators = client.identify_as_user_and_get_available_authenticators("username")
44
+ client.authenticate_with_app() # approve in the MitID app
45
+ code = client.finalize_authentication_and_get_authorization_code()
46
+ ```
47
+
48
+ `client_hash` and `authentication_session_id` come from the service's NemLog-in page;
49
+ `get_authentication_code` does all of the above from that page's parameters.
50
+
51
+ It is used by [aula_connect](https://github.com/HairingX/aula_connect) to log in to Aula.
52
+
53
+ ## Origin and licence
54
+
55
+ It carries on MitID-BrowserClient by Hundter, under the MIT licence (`LICENSE`).
@@ -0,0 +1,10 @@
1
+ mitid_connect/__init__.py,sha256=6lPc4-HF1grYMWhwV_C96vweANum8EY2M7PnjWUsgrc,889
2
+ mitid_connect/browser_client.py,sha256=EAd4NqMaJtepm5qx1N5v0n4DKhdtrDwDIoXjX1BGOug,32429
3
+ mitid_connect/helpers.py,sha256=P6SI3Wt-mPnFQkNOXDq4-lbj9YHihZTFWcwJpFdUzb8,4385
4
+ mitid_connect/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ mitid_connect/srp.py,sha256=TeVUZcbJb3qrCV4KMmpUbWwTMoteCY6lOyACqkeRP6E,7914
6
+ mitid_connect-0.0.1.dist-info/licenses/LICENSE,sha256=v-WwuWyqFMKfwXLWDSB_LS99yZNu4Ibd1HnMiF8ddyY,1153
7
+ mitid_connect-0.0.1.dist-info/METADATA,sha256=MkzDd-xJZLeEHbpYnMQ05U2hYLalQXpFB4f1nOO4A4g,2316
8
+ mitid_connect-0.0.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
9
+ mitid_connect-0.0.1.dist-info/top_level.txt,sha256=6OqNVy-tgVlykookob7hBs2KZkcN7VcAvuU66XJ2aBg,14
10
+ mitid_connect-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) Hundter, the author of MitID-BrowserClient, which this library carries on
4
+ Copyright (c) 2026 HairingX
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ mitid_connect