eidreader-sdk 1.0.0__tar.gz
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.
- eidreader_sdk-1.0.0/PKG-INFO +27 -0
- eidreader_sdk-1.0.0/eidreader_sdk/__init__.py +47 -0
- eidreader_sdk-1.0.0/eidreader_sdk/crypto.py +110 -0
- eidreader_sdk-1.0.0/eidreader_sdk/network.py +84 -0
- eidreader_sdk-1.0.0/eidreader_sdk/protocol.py +52 -0
- eidreader_sdk-1.0.0/eidreader_sdk/qrcode_gen.py +55 -0
- eidreader_sdk-1.0.0/eidreader_sdk/session.py +644 -0
- eidreader_sdk-1.0.0/eidreader_sdk/types.py +181 -0
- eidreader_sdk-1.0.0/eidreader_sdk.egg-info/PKG-INFO +27 -0
- eidreader_sdk-1.0.0/eidreader_sdk.egg-info/SOURCES.txt +13 -0
- eidreader_sdk-1.0.0/eidreader_sdk.egg-info/dependency_links.txt +1 -0
- eidreader_sdk-1.0.0/eidreader_sdk.egg-info/requires.txt +11 -0
- eidreader_sdk-1.0.0/eidreader_sdk.egg-info/top_level.txt +1 -0
- eidreader_sdk-1.0.0/pyproject.toml +47 -0
- eidreader_sdk-1.0.0/setup.cfg +4 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: eidreader-sdk
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Host-side SDK for the eID Reader P2P protocol. Receives identity document data from a mobile app over an AES-256-GCM encrypted LAN WebSocket connection secured with ECDH P-256 key exchange and SAS MITM protection. Download the app from: https://play.google.com/store/apps/details?id=com.TFAStudios.eidreadermobile
|
|
5
|
+
Author: Tipa Fabian
|
|
6
|
+
License: GPL-3.0-only
|
|
7
|
+
Project-URL: Homepage, https://play.google.com/store/apps/details?id=com.TFAStudios.eidreadermobile
|
|
8
|
+
Keywords: eid,nfc,identity,document,qrcode,websocket,ecdh,encryption,p2p
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Topic :: Security :: Cryptography
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.8
|
|
19
|
+
Requires-Dist: websockets>=10.0
|
|
20
|
+
Requires-Dist: cryptography>=41.0
|
|
21
|
+
Requires-Dist: qrcode>=7.4
|
|
22
|
+
Provides-Extra: pil
|
|
23
|
+
Requires-Dist: Pillow>=10.0; extra == "pil"
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
27
|
+
Requires-Dist: Pillow>=10.0; extra == "dev"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""eID Reader SDK — serverless P2P identity data transfer.
|
|
2
|
+
|
|
3
|
+
Receive identity data scanned by the eID Reader mobile app directly in your
|
|
4
|
+
application — no cloud server, no data storage, no intermediaries.
|
|
5
|
+
|
|
6
|
+
Quick start::
|
|
7
|
+
|
|
8
|
+
from eidreader_sdk import EIDReaderSession
|
|
9
|
+
|
|
10
|
+
session = EIDReaderSession()
|
|
11
|
+
session.on_paired(lambda: print(f"SAS: {session.sas_code}"))
|
|
12
|
+
session.on_data(lambda data: print(data.personal.full_name))
|
|
13
|
+
session.start()
|
|
14
|
+
|
|
15
|
+
# Render session.qr_code_png in your UI
|
|
16
|
+
# When the user confirms SAS codes match:
|
|
17
|
+
session.confirm_sas()
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from .session import EIDReaderSession
|
|
21
|
+
from .types import (
|
|
22
|
+
DocumentType,
|
|
23
|
+
EIDData,
|
|
24
|
+
EIDDocument,
|
|
25
|
+
EIDError,
|
|
26
|
+
EIDImage,
|
|
27
|
+
EIDMeta,
|
|
28
|
+
EIDMrz,
|
|
29
|
+
EIDPersonal,
|
|
30
|
+
Gender,
|
|
31
|
+
SessionState,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__version__ = "1.0.0"
|
|
35
|
+
__all__ = [
|
|
36
|
+
"EIDReaderSession",
|
|
37
|
+
"EIDData",
|
|
38
|
+
"EIDMeta",
|
|
39
|
+
"EIDPersonal",
|
|
40
|
+
"EIDDocument",
|
|
41
|
+
"EIDMrz",
|
|
42
|
+
"EIDImage",
|
|
43
|
+
"EIDError",
|
|
44
|
+
"DocumentType",
|
|
45
|
+
"Gender",
|
|
46
|
+
"SessionState",
|
|
47
|
+
]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Cryptographic operations: ECDH, HKDF, AES-256-GCM, SAS."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from typing import Tuple
|
|
10
|
+
|
|
11
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
12
|
+
from cryptography.hazmat.primitives.asymmetric.ec import (
|
|
13
|
+
ECDH,
|
|
14
|
+
SECP256R1,
|
|
15
|
+
EllipticCurvePrivateKey,
|
|
16
|
+
EllipticCurvePublicKey,
|
|
17
|
+
generate_private_key,
|
|
18
|
+
)
|
|
19
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
20
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
21
|
+
from cryptography.hazmat.primitives.serialization import load_der_public_key
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def generate_keypair() -> Tuple[EllipticCurvePrivateKey, EllipticCurvePublicKey]:
|
|
25
|
+
"""Generate an ephemeral ECDH P-256 keypair."""
|
|
26
|
+
privkey = generate_private_key(SECP256R1())
|
|
27
|
+
return privkey, privkey.public_key()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def serialize_pubkey(pubkey: EllipticCurvePublicKey) -> str:
|
|
31
|
+
"""Serialize a public key to base64url-encoded DER (no padding)."""
|
|
32
|
+
der = pubkey.public_bytes(
|
|
33
|
+
serialization.Encoding.DER,
|
|
34
|
+
serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
35
|
+
)
|
|
36
|
+
return base64.urlsafe_b64encode(der).rstrip(b"=").decode()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def deserialize_pubkey(b64: str) -> EllipticCurvePublicKey:
|
|
40
|
+
"""Deserialize a base64url-encoded DER public key."""
|
|
41
|
+
# Restore padding
|
|
42
|
+
padding = "=" * (4 - len(b64) % 4) if len(b64) % 4 else ""
|
|
43
|
+
der = base64.urlsafe_b64decode(b64 + padding)
|
|
44
|
+
return load_der_public_key(der)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def derive_shared_secret(
|
|
48
|
+
privkey: EllipticCurvePrivateKey,
|
|
49
|
+
phone_pubkey: EllipticCurvePublicKey,
|
|
50
|
+
) -> bytes:
|
|
51
|
+
"""Perform ECDH to derive the shared secret."""
|
|
52
|
+
return privkey.exchange(ECDH(), phone_pubkey)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def derive_aes_key(shared_secret: bytes, session_id: str) -> bytes:
|
|
56
|
+
"""Derive a 256-bit AES key from the shared secret using HKDF-SHA256.
|
|
57
|
+
|
|
58
|
+
Uses session_id as the HKDF salt and "eidreader-v1" as the info string,
|
|
59
|
+
ensuring each session produces a unique key even from the same shared secret.
|
|
60
|
+
"""
|
|
61
|
+
hkdf = HKDF(
|
|
62
|
+
algorithm=hashes.SHA256(),
|
|
63
|
+
length=32,
|
|
64
|
+
salt=session_id.encode("utf-8"),
|
|
65
|
+
info=b"eidreader-v1",
|
|
66
|
+
)
|
|
67
|
+
return hkdf.derive(shared_secret)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def compute_sas(shared_secret: bytes, session_id: str) -> str:
|
|
71
|
+
"""Compute the 6-digit Short Authentication String (SAS).
|
|
72
|
+
|
|
73
|
+
Both sides independently compute this from the shared secret.
|
|
74
|
+
A MITM attacker cannot forge it without knowing both private keys.
|
|
75
|
+
|
|
76
|
+
Returns a string like "482 931" (two 3-digit groups).
|
|
77
|
+
"""
|
|
78
|
+
mac = hmac.new(shared_secret, session_id.encode("utf-8"), "sha256").digest()
|
|
79
|
+
sas_int = int.from_bytes(mac[0:3], "big") % 1_000_000
|
|
80
|
+
raw = f"{sas_int:06d}"
|
|
81
|
+
return f"{raw[:3]} {raw[3:]}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def decrypt_payload(aes_key: bytes, payload: dict) -> dict:
|
|
85
|
+
"""Decrypt an AES-256-GCM encrypted DATA message payload.
|
|
86
|
+
|
|
87
|
+
The GCM authentication tag is appended to the ciphertext by the phone
|
|
88
|
+
(tag_included: true), so AESGCM.decrypt() handles tag verification automatically.
|
|
89
|
+
"""
|
|
90
|
+
nonce = base64.urlsafe_b64decode(payload["nonce"] + "==")
|
|
91
|
+
ciphertext = base64.urlsafe_b64decode(payload["ciphertext"] + "==")
|
|
92
|
+
aesgcm = AESGCM(aes_key)
|
|
93
|
+
plaintext = aesgcm.decrypt(nonce, ciphertext, None)
|
|
94
|
+
return json.loads(plaintext)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def encrypt_payload(aes_key: bytes, plaintext_dict: dict) -> dict:
|
|
98
|
+
"""Encrypt a payload with AES-256-GCM (for testing / phone-side simulation).
|
|
99
|
+
|
|
100
|
+
Returns the payload dict suitable for embedding in a DATA message.
|
|
101
|
+
"""
|
|
102
|
+
nonce = os.urandom(12)
|
|
103
|
+
aesgcm = AESGCM(aes_key)
|
|
104
|
+
plaintext = json.dumps(plaintext_dict).encode("utf-8")
|
|
105
|
+
ciphertext = aesgcm.encrypt(nonce, plaintext, None) # GCM tag appended
|
|
106
|
+
return {
|
|
107
|
+
"nonce": base64.urlsafe_b64encode(nonce).rstrip(b"=").decode(),
|
|
108
|
+
"ciphertext": base64.urlsafe_b64encode(ciphertext).rstrip(b"=").decode(),
|
|
109
|
+
"tag_included": True,
|
|
110
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Network utilities: LAN IP detection and port selection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
import socket
|
|
7
|
+
|
|
8
|
+
# Common virtual/container adapter name fragments to filter out
|
|
9
|
+
_VIRTUAL_NAMES = frozenset(
|
|
10
|
+
{
|
|
11
|
+
"vethernet",
|
|
12
|
+
"docker",
|
|
13
|
+
"vmnet",
|
|
14
|
+
"utun",
|
|
15
|
+
"bridge",
|
|
16
|
+
"loopback",
|
|
17
|
+
"virtual",
|
|
18
|
+
"hyperv",
|
|
19
|
+
"vbox",
|
|
20
|
+
"npcap",
|
|
21
|
+
"tailscale",
|
|
22
|
+
"zerotier",
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_lan_ip() -> str:
|
|
28
|
+
"""Detect the primary LAN IPv4 address.
|
|
29
|
+
|
|
30
|
+
Uses the UDP connect trick: opening a UDP socket to an external address
|
|
31
|
+
(without sending anything) forces the OS to select the correct outbound
|
|
32
|
+
interface. Falls back to hostname resolution if that fails.
|
|
33
|
+
|
|
34
|
+
Per the spec, loopback (127.x) and link-local (169.254.x) addresses are
|
|
35
|
+
excluded. On complex machines (multiple NICs, Docker, etc.) the developer
|
|
36
|
+
should pass ``host=`` to EIDReaderSession instead.
|
|
37
|
+
"""
|
|
38
|
+
# Primary method: UDP connect trick — no packet actually sent
|
|
39
|
+
try:
|
|
40
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
41
|
+
s.connect(("8.8.8.8", 80))
|
|
42
|
+
ip = s.getsockname()[0]
|
|
43
|
+
s.close()
|
|
44
|
+
if ip and not ip.startswith("127.") and not ip.startswith("169.254."):
|
|
45
|
+
return ip
|
|
46
|
+
except Exception:
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
# Fallback: hostname resolution
|
|
50
|
+
try:
|
|
51
|
+
hostname = socket.gethostname()
|
|
52
|
+
ip = socket.gethostbyname(hostname)
|
|
53
|
+
if ip and ip != "127.0.0.1" and not ip.startswith("169.254."):
|
|
54
|
+
return ip
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
return "127.0.0.1"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def find_available_port(
|
|
62
|
+
host: str,
|
|
63
|
+
min_port: int = 49152,
|
|
64
|
+
max_port: int = 65535,
|
|
65
|
+
max_attempts: int = 10,
|
|
66
|
+
) -> int:
|
|
67
|
+
"""Find a random available TCP port in the ephemeral range.
|
|
68
|
+
|
|
69
|
+
Attempts up to ``max_attempts`` random ports before raising RuntimeError.
|
|
70
|
+
"""
|
|
71
|
+
for _ in range(max_attempts):
|
|
72
|
+
port = random.randint(min_port, max_port)
|
|
73
|
+
try:
|
|
74
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
75
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
76
|
+
s.bind((host, port))
|
|
77
|
+
s.close()
|
|
78
|
+
return port
|
|
79
|
+
except OSError:
|
|
80
|
+
continue
|
|
81
|
+
raise RuntimeError(
|
|
82
|
+
f"Unable to find an available port after {max_attempts} attempts. "
|
|
83
|
+
"Check that your firewall allows incoming connections."
|
|
84
|
+
)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Protocol constants, message types, and QR URI builder."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MsgType(str, Enum):
|
|
10
|
+
HELLO = "HELLO"
|
|
11
|
+
HELLO_ACK = "HELLO_ACK"
|
|
12
|
+
CONFIRM = "CONFIRM"
|
|
13
|
+
CONFIRM_ACK = "CONFIRM_ACK"
|
|
14
|
+
DATA = "DATA"
|
|
15
|
+
DATA_ACK = "DATA_ACK"
|
|
16
|
+
CLOSE = "CLOSE"
|
|
17
|
+
ERROR = "ERROR"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
PROTOCOL_VERSION = 1
|
|
21
|
+
PORT_RANGE_MIN = 49152
|
|
22
|
+
PORT_RANGE_MAX = 65535
|
|
23
|
+
DEFAULT_TTL = 180
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_qr_uri(
|
|
27
|
+
ip: str,
|
|
28
|
+
port: int,
|
|
29
|
+
pubkey_b64: str,
|
|
30
|
+
session_id: str,
|
|
31
|
+
expires: int,
|
|
32
|
+
) -> str:
|
|
33
|
+
"""Build the eidreader:// URI that gets encoded into the QR code."""
|
|
34
|
+
return (
|
|
35
|
+
f"eidreader://pair"
|
|
36
|
+
f"?ip={ip}"
|
|
37
|
+
f"&port={port}"
|
|
38
|
+
f"&pubkey={pubkey_b64}"
|
|
39
|
+
f"&session={session_id}"
|
|
40
|
+
f"&expires={expires}"
|
|
41
|
+
f"&v={PROTOCOL_VERSION}"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_message(msg_type: str, session_id: str, seq: int, payload: Any) -> dict:
|
|
46
|
+
"""Build a WebSocket message envelope."""
|
|
47
|
+
return {
|
|
48
|
+
"type": msg_type,
|
|
49
|
+
"session": session_id,
|
|
50
|
+
"seq": seq,
|
|
51
|
+
"payload": payload,
|
|
52
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""QR code PNG generation from a URI string."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def generate_qr_png(uri: str) -> bytes:
|
|
9
|
+
"""Generate a QR code PNG from the given URI string.
|
|
10
|
+
|
|
11
|
+
Returns raw PNG bytes. Requires the ``qrcode`` package.
|
|
12
|
+
Install with: ``pip install 'qrcode[pil]'`` for best results,
|
|
13
|
+
or ``pip install qrcode`` for the pure-Python PNG backend.
|
|
14
|
+
"""
|
|
15
|
+
try:
|
|
16
|
+
import qrcode # type: ignore[import]
|
|
17
|
+
except ImportError as exc:
|
|
18
|
+
raise ImportError(
|
|
19
|
+
"The 'qrcode' package is required for QR code generation. "
|
|
20
|
+
"Install it with: pip install 'qrcode[pil]'"
|
|
21
|
+
) from exc
|
|
22
|
+
|
|
23
|
+
qr = qrcode.QRCode(
|
|
24
|
+
version=None, # auto-size to fit content
|
|
25
|
+
error_correction=qrcode.constants.ERROR_CORRECT_M,
|
|
26
|
+
box_size=10,
|
|
27
|
+
border=4,
|
|
28
|
+
)
|
|
29
|
+
qr.add_data(uri)
|
|
30
|
+
qr.make(fit=True)
|
|
31
|
+
|
|
32
|
+
# Prefer PIL/Pillow for higher-quality PNG output
|
|
33
|
+
try:
|
|
34
|
+
from PIL import Image # type: ignore[import] # noqa: F401
|
|
35
|
+
|
|
36
|
+
img = qr.make_image(fill_color="black", back_color="white")
|
|
37
|
+
buf = io.BytesIO()
|
|
38
|
+
img.save(buf, format="PNG")
|
|
39
|
+
return buf.getvalue()
|
|
40
|
+
except ImportError:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
# Fall back to the pure-Python PNG writer bundled with qrcode
|
|
44
|
+
try:
|
|
45
|
+
from qrcode.image.pure import PyPNGImage # type: ignore[import]
|
|
46
|
+
|
|
47
|
+
img = qr.make_image(image_factory=PyPNGImage)
|
|
48
|
+
buf = io.BytesIO()
|
|
49
|
+
img.save(buf)
|
|
50
|
+
return buf.getvalue()
|
|
51
|
+
except ImportError as exc:
|
|
52
|
+
raise ImportError(
|
|
53
|
+
"Could not generate QR code PNG. "
|
|
54
|
+
"Install Pillow: pip install Pillow"
|
|
55
|
+
) from exc
|
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
"""EIDReaderSession — the main public class of the eID Reader SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from typing import Callable, Optional
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
import websockets
|
|
15
|
+
import websockets.exceptions
|
|
16
|
+
from websockets.server import WebSocketServerProtocol, serve as ws_serve
|
|
17
|
+
except ImportError as exc: # pragma: no cover
|
|
18
|
+
raise ImportError(
|
|
19
|
+
"The 'websockets' package is required. Install it with: pip install websockets"
|
|
20
|
+
) from exc
|
|
21
|
+
|
|
22
|
+
from .crypto import (
|
|
23
|
+
compute_sas,
|
|
24
|
+
decrypt_payload,
|
|
25
|
+
derive_aes_key,
|
|
26
|
+
derive_shared_secret,
|
|
27
|
+
deserialize_pubkey,
|
|
28
|
+
generate_keypair,
|
|
29
|
+
serialize_pubkey,
|
|
30
|
+
)
|
|
31
|
+
from .network import find_available_port, get_lan_ip
|
|
32
|
+
from .protocol import (
|
|
33
|
+
DEFAULT_TTL,
|
|
34
|
+
PORT_RANGE_MAX,
|
|
35
|
+
PORT_RANGE_MIN,
|
|
36
|
+
MsgType,
|
|
37
|
+
build_message,
|
|
38
|
+
build_qr_uri,
|
|
39
|
+
)
|
|
40
|
+
from .qrcode_gen import generate_qr_png
|
|
41
|
+
from .types import EIDData, EIDError, SessionState
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class EIDReaderSession:
|
|
47
|
+
"""A single pairing session between the host application and the eID Reader app.
|
|
48
|
+
|
|
49
|
+
Usage (synchronous, callback-based)::
|
|
50
|
+
|
|
51
|
+
session = EIDReaderSession()
|
|
52
|
+
session.on_paired(lambda: print(f"SAS: {session.sas_code}"))
|
|
53
|
+
session.on_data(lambda data: print(data.personal.full_name))
|
|
54
|
+
session.on_expired(lambda: print("Expired — generate a new QR."))
|
|
55
|
+
session.on_error(lambda err: print(f"Error: {err}"))
|
|
56
|
+
|
|
57
|
+
session.start()
|
|
58
|
+
display_qr(session.qr_code_png)
|
|
59
|
+
|
|
60
|
+
# Later, when user confirms SAS codes match:
|
|
61
|
+
session.confirm_sas()
|
|
62
|
+
|
|
63
|
+
Usage (async)::
|
|
64
|
+
|
|
65
|
+
async def main():
|
|
66
|
+
session = EIDReaderSession()
|
|
67
|
+
session.on_data(lambda data: print(data.personal.full_name))
|
|
68
|
+
await session.start_async()
|
|
69
|
+
display_qr(session.qr_code_base64)
|
|
70
|
+
data = await session.wait_for_data()
|
|
71
|
+
|
|
72
|
+
See EIDReaderSession constructor for configuration options.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
host: Optional[str] = None,
|
|
78
|
+
port: Optional[int] = None,
|
|
79
|
+
ttl: int = DEFAULT_TTL,
|
|
80
|
+
require_sas: bool = True,
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Create a new session.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
host: Override the auto-detected LAN IP. Useful on multi-NIC machines
|
|
86
|
+
or in Docker/VM environments.
|
|
87
|
+
port: Override the random port. Must be in range 49152–65535.
|
|
88
|
+
ttl: Session lifetime in seconds (default 180). After expiry the QR
|
|
89
|
+
becomes invalid and a new session must be created.
|
|
90
|
+
require_sas: If True (default), the host waits for both the phone's
|
|
91
|
+
CONFIRM and developer's confirm_sas() before sending CONFIRM_ACK.
|
|
92
|
+
Set to False only in automated test scenarios.
|
|
93
|
+
"""
|
|
94
|
+
self._host_override = host
|
|
95
|
+
self._port_override = port
|
|
96
|
+
self._ttl = ttl
|
|
97
|
+
self._require_sas = require_sas
|
|
98
|
+
|
|
99
|
+
# Session identity
|
|
100
|
+
self._session_id: Optional[str] = None
|
|
101
|
+
self._host_ip: Optional[str] = None
|
|
102
|
+
self._port_num: Optional[int] = None
|
|
103
|
+
self._expires: Optional[int] = None
|
|
104
|
+
|
|
105
|
+
# State machine
|
|
106
|
+
self._state = SessionState.IDLE
|
|
107
|
+
|
|
108
|
+
# Cryptographic material — held only in memory, cleared after session
|
|
109
|
+
self._privkey = None
|
|
110
|
+
self._shared_secret: Optional[bytes] = None
|
|
111
|
+
self._aes_key: Optional[bytes] = None
|
|
112
|
+
|
|
113
|
+
# UI data
|
|
114
|
+
self._qr_png: Optional[bytes] = None
|
|
115
|
+
self._sas_code: Optional[str] = None
|
|
116
|
+
|
|
117
|
+
# Callbacks
|
|
118
|
+
self._cb_paired: Optional[Callable] = None
|
|
119
|
+
self._cb_data: Optional[Callable] = None
|
|
120
|
+
self._cb_expired: Optional[Callable] = None
|
|
121
|
+
self._cb_error: Optional[Callable] = None
|
|
122
|
+
self._cb_ready: Optional[Callable] = None
|
|
123
|
+
|
|
124
|
+
# Async primitives — created in the correct event loop (see _init_async_primitives)
|
|
125
|
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
126
|
+
self._sas_confirmed_event: Optional[asyncio.Event] = None
|
|
127
|
+
self._phone_confirmed_event: Optional[asyncio.Event] = None
|
|
128
|
+
self._data_event: Optional[asyncio.Event] = None
|
|
129
|
+
self._server_task: Optional[asyncio.Task] = None
|
|
130
|
+
|
|
131
|
+
# WebSocket references
|
|
132
|
+
self._server = None
|
|
133
|
+
self._ws: Optional[WebSocketServerProtocol] = None
|
|
134
|
+
|
|
135
|
+
# Received data
|
|
136
|
+
self._eid_data: Optional[EIDData] = None
|
|
137
|
+
|
|
138
|
+
# Sequence counter for outbound messages
|
|
139
|
+
self._seq_out: int = 0
|
|
140
|
+
# Last received inbound sequence number
|
|
141
|
+
self._seq_in: int = 0
|
|
142
|
+
|
|
143
|
+
# Threading primitives for sync start()
|
|
144
|
+
self._ready_event = threading.Event()
|
|
145
|
+
self._thread: Optional[threading.Thread] = None
|
|
146
|
+
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
# Properties
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def id(self) -> Optional[str]:
|
|
153
|
+
"""The session UUID (available after start())."""
|
|
154
|
+
return self._session_id
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def state(self) -> SessionState:
|
|
158
|
+
"""Current session state."""
|
|
159
|
+
return self._state
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def qr_code_png(self) -> Optional[bytes]:
|
|
163
|
+
"""Raw PNG bytes of the QR code. Available after start()."""
|
|
164
|
+
return self._qr_png
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def qr_code_base64(self) -> Optional[str]:
|
|
168
|
+
"""Base64-encoded PNG of the QR code. Available after start()."""
|
|
169
|
+
if self._qr_png:
|
|
170
|
+
import base64
|
|
171
|
+
return base64.b64encode(self._qr_png).decode()
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def sas_code(self) -> Optional[str]:
|
|
176
|
+
"""6-digit SAS code (e.g. "482 931"). Available after the phone connects."""
|
|
177
|
+
return self._sas_code
|
|
178
|
+
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
# Callback registration (fluent interface)
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
def on_paired(self, callback: Callable[[], None]) -> "EIDReaderSession":
|
|
184
|
+
"""Register a callback fired when the phone connects and SAS is ready."""
|
|
185
|
+
self._cb_paired = callback
|
|
186
|
+
return self
|
|
187
|
+
|
|
188
|
+
def on_data(self, callback: Callable[[EIDData], None]) -> "EIDReaderSession":
|
|
189
|
+
"""Register a callback fired when eID data is received and decrypted."""
|
|
190
|
+
self._cb_data = callback
|
|
191
|
+
return self
|
|
192
|
+
|
|
193
|
+
def on_expired(self, callback: Callable[[], None]) -> "EIDReaderSession":
|
|
194
|
+
"""Register a callback fired when the session TTL expires."""
|
|
195
|
+
self._cb_expired = callback
|
|
196
|
+
return self
|
|
197
|
+
|
|
198
|
+
def on_error(self, callback: Callable[[EIDError], None]) -> "EIDReaderSession":
|
|
199
|
+
"""Register a callback fired on any protocol or crypto error."""
|
|
200
|
+
self._cb_error = callback
|
|
201
|
+
return self
|
|
202
|
+
|
|
203
|
+
def on_ready(self, callback: Callable[[], None]) -> "EIDReaderSession":
|
|
204
|
+
"""Register a callback fired when the server is listening and QR is ready."""
|
|
205
|
+
self._cb_ready = callback
|
|
206
|
+
return self
|
|
207
|
+
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
# Public API
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
def start(self) -> "EIDReaderSession":
|
|
213
|
+
"""Start the session in a background daemon thread (non-blocking).
|
|
214
|
+
|
|
215
|
+
Blocks until the WebSocket server is listening and the QR code is ready
|
|
216
|
+
(or raises RuntimeError on timeout). The calling thread is then free to
|
|
217
|
+
render the QR code and wait for callbacks.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
self, for method chaining.
|
|
221
|
+
"""
|
|
222
|
+
self._loop = asyncio.new_event_loop()
|
|
223
|
+
self._thread = threading.Thread(target=self._thread_main, daemon=True)
|
|
224
|
+
self._thread.start()
|
|
225
|
+
if not self._ready_event.wait(timeout=15):
|
|
226
|
+
raise RuntimeError(
|
|
227
|
+
"EIDReaderSession failed to start within 15 seconds. "
|
|
228
|
+
"Check that the host IP is reachable and the port is not blocked."
|
|
229
|
+
)
|
|
230
|
+
return self
|
|
231
|
+
|
|
232
|
+
async def start_async(self) -> "EIDReaderSession":
|
|
233
|
+
"""Start the session within the current asyncio event loop (non-blocking).
|
|
234
|
+
|
|
235
|
+
Waits until the WebSocket server is listening and the QR code is ready,
|
|
236
|
+
then returns. The server continues running as a background task.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
self, for method chaining.
|
|
240
|
+
"""
|
|
241
|
+
self._loop = asyncio.get_running_loop()
|
|
242
|
+
self._init_async_primitives()
|
|
243
|
+
|
|
244
|
+
ready_future: asyncio.Future = self._loop.create_future()
|
|
245
|
+
self._server_task = asyncio.create_task(self._async_main(ready_future))
|
|
246
|
+
await ready_future
|
|
247
|
+
return self
|
|
248
|
+
|
|
249
|
+
async def wait_for_data(self) -> Optional[EIDData]:
|
|
250
|
+
"""Wait (async) until eID data is received.
|
|
251
|
+
|
|
252
|
+
Must be called after start_async(). Returns the EIDData object,
|
|
253
|
+
or None if the session expired/errored before data arrived.
|
|
254
|
+
"""
|
|
255
|
+
if self._data_event:
|
|
256
|
+
await self._data_event.wait()
|
|
257
|
+
return self._eid_data
|
|
258
|
+
|
|
259
|
+
def confirm_sas(self) -> None:
|
|
260
|
+
"""Confirm that the SAS codes match on the host side.
|
|
261
|
+
|
|
262
|
+
Call this after your user verbally or visually confirms that the 6-digit
|
|
263
|
+
code displayed in your app matches the code on the phone. This sends
|
|
264
|
+
CONFIRM_ACK to the phone and transitions the session to active data
|
|
265
|
+
transfer.
|
|
266
|
+
|
|
267
|
+
Thread-safe — can be called from any thread.
|
|
268
|
+
"""
|
|
269
|
+
if self._loop and self._sas_confirmed_event:
|
|
270
|
+
self._loop.call_soon_threadsafe(self._sas_confirmed_event.set)
|
|
271
|
+
|
|
272
|
+
def close(self) -> None:
|
|
273
|
+
"""Gracefully close the session.
|
|
274
|
+
|
|
275
|
+
Sends a CLOSE message to the phone, stops the WebSocket server, and
|
|
276
|
+
clears all cryptographic material from memory. Thread-safe.
|
|
277
|
+
"""
|
|
278
|
+
if self._loop and not self._loop.is_closed():
|
|
279
|
+
asyncio.run_coroutine_threadsafe(self._async_close(), self._loop)
|
|
280
|
+
|
|
281
|
+
# ---------------------------------------------------------------------------
|
|
282
|
+
# Internal — threading entry point
|
|
283
|
+
# ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
def _thread_main(self) -> None:
|
|
286
|
+
asyncio.set_event_loop(self._loop)
|
|
287
|
+
self._init_async_primitives()
|
|
288
|
+
try:
|
|
289
|
+
self._loop.run_until_complete(self._thread_async_main())
|
|
290
|
+
except Exception as exc:
|
|
291
|
+
logger.debug("Session loop exited with exception: %s", exc)
|
|
292
|
+
finally:
|
|
293
|
+
self._loop.close()
|
|
294
|
+
|
|
295
|
+
async def _thread_async_main(self) -> None:
|
|
296
|
+
await self._setup()
|
|
297
|
+
# Signal to start() that the server is ready
|
|
298
|
+
self._ready_event.set()
|
|
299
|
+
if self._cb_ready:
|
|
300
|
+
self._cb_ready()
|
|
301
|
+
await self._run_server_with_ttl()
|
|
302
|
+
|
|
303
|
+
# ---------------------------------------------------------------------------
|
|
304
|
+
# Internal — async entry point (used by start_async)
|
|
305
|
+
# ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
async def _async_main(self, ready_future: asyncio.Future) -> None:
|
|
308
|
+
await self._setup()
|
|
309
|
+
if not ready_future.done():
|
|
310
|
+
ready_future.set_result(True)
|
|
311
|
+
if self._cb_ready:
|
|
312
|
+
self._cb_ready()
|
|
313
|
+
await self._run_server_with_ttl()
|
|
314
|
+
|
|
315
|
+
# ---------------------------------------------------------------------------
|
|
316
|
+
# Internal — session setup (keypair, QR, network)
|
|
317
|
+
# ---------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
def _init_async_primitives(self) -> None:
|
|
320
|
+
self._sas_confirmed_event = asyncio.Event()
|
|
321
|
+
self._phone_confirmed_event = asyncio.Event()
|
|
322
|
+
self._data_event = asyncio.Event()
|
|
323
|
+
|
|
324
|
+
async def _setup(self) -> None:
|
|
325
|
+
# 1. Generate session UUID
|
|
326
|
+
self._session_id = str(uuid.uuid4())
|
|
327
|
+
self._seq_out = 0
|
|
328
|
+
self._seq_in = 0
|
|
329
|
+
|
|
330
|
+
# 2. Generate ephemeral ECDH P-256 keypair
|
|
331
|
+
self._privkey, pubkey = generate_keypair()
|
|
332
|
+
pubkey_b64 = serialize_pubkey(pubkey)
|
|
333
|
+
|
|
334
|
+
# 3. Detect/validate LAN IP
|
|
335
|
+
self._host_ip = self._host_override or get_lan_ip()
|
|
336
|
+
|
|
337
|
+
# 4. Pick an available port
|
|
338
|
+
self._port_num = self._port_override or find_available_port(
|
|
339
|
+
self._host_ip, PORT_RANGE_MIN, PORT_RANGE_MAX
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# 5. Compute session expiry
|
|
343
|
+
self._expires = int(time.time()) + self._ttl
|
|
344
|
+
|
|
345
|
+
# 6. Build QR URI and generate PNG
|
|
346
|
+
uri = build_qr_uri(
|
|
347
|
+
self._host_ip,
|
|
348
|
+
self._port_num,
|
|
349
|
+
pubkey_b64,
|
|
350
|
+
self._session_id,
|
|
351
|
+
self._expires,
|
|
352
|
+
)
|
|
353
|
+
self._qr_png = generate_qr_png(uri)
|
|
354
|
+
|
|
355
|
+
self._state = SessionState.LISTENING
|
|
356
|
+
logger.debug(
|
|
357
|
+
"Session %s listening on %s:%s (TTL %ss)",
|
|
358
|
+
self._session_id,
|
|
359
|
+
self._host_ip,
|
|
360
|
+
self._port_num,
|
|
361
|
+
self._ttl,
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
# ---------------------------------------------------------------------------
|
|
365
|
+
# Internal — WebSocket server
|
|
366
|
+
# ---------------------------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
async def _run_server_with_ttl(self) -> None:
|
|
369
|
+
try:
|
|
370
|
+
async with ws_serve(
|
|
371
|
+
self._handle_connection,
|
|
372
|
+
self._host_ip,
|
|
373
|
+
self._port_num,
|
|
374
|
+
ping_interval=None,
|
|
375
|
+
ping_timeout=None,
|
|
376
|
+
) as server:
|
|
377
|
+
self._server = server
|
|
378
|
+
try:
|
|
379
|
+
await asyncio.wait_for(self._wait_until_done(), timeout=self._ttl)
|
|
380
|
+
except asyncio.TimeoutError:
|
|
381
|
+
await self._handle_expiry()
|
|
382
|
+
except OSError as exc:
|
|
383
|
+
err = EIDError(EIDError.E_BIND_FAILED, str(exc))
|
|
384
|
+
self._state = SessionState.ERROR
|
|
385
|
+
logger.error("Failed to bind WebSocket server: %s", exc)
|
|
386
|
+
if self._cb_error:
|
|
387
|
+
self._cb_error(err)
|
|
388
|
+
finally:
|
|
389
|
+
self._clear_keys()
|
|
390
|
+
|
|
391
|
+
async def _wait_until_done(self) -> None:
|
|
392
|
+
terminal = {SessionState.COMPLETE, SessionState.EXPIRED, SessionState.ERROR}
|
|
393
|
+
while self._state not in terminal:
|
|
394
|
+
await asyncio.sleep(0.1)
|
|
395
|
+
|
|
396
|
+
async def _handle_expiry(self) -> None:
|
|
397
|
+
if self._state in {SessionState.COMPLETE, SessionState.ERROR}:
|
|
398
|
+
return # already finished normally
|
|
399
|
+
self._state = SessionState.EXPIRED
|
|
400
|
+
if self._ws:
|
|
401
|
+
try:
|
|
402
|
+
msg = build_message(
|
|
403
|
+
MsgType.ERROR,
|
|
404
|
+
self._session_id,
|
|
405
|
+
self._next_seq_out(),
|
|
406
|
+
{"code": EIDError.E_EXPIRED, "message": "Session has expired"},
|
|
407
|
+
)
|
|
408
|
+
await self._ws.send(json.dumps(msg))
|
|
409
|
+
await self._ws.close()
|
|
410
|
+
except Exception:
|
|
411
|
+
pass
|
|
412
|
+
self._clear_keys()
|
|
413
|
+
if self._cb_expired:
|
|
414
|
+
self._cb_expired()
|
|
415
|
+
if self._data_event:
|
|
416
|
+
self._data_event.set() # unblock wait_for_data()
|
|
417
|
+
|
|
418
|
+
# ---------------------------------------------------------------------------
|
|
419
|
+
# Internal — WebSocket connection handler
|
|
420
|
+
# ---------------------------------------------------------------------------
|
|
421
|
+
|
|
422
|
+
async def _handle_connection(self, ws: WebSocketServerProtocol) -> None:
|
|
423
|
+
# Only accept one connection per session
|
|
424
|
+
if self._ws is not None:
|
|
425
|
+
await ws.close(1008, "Session already in use")
|
|
426
|
+
return
|
|
427
|
+
|
|
428
|
+
self._ws = ws
|
|
429
|
+
logger.debug("Connection from %s", ws.remote_address)
|
|
430
|
+
|
|
431
|
+
try:
|
|
432
|
+
async for raw in ws:
|
|
433
|
+
if isinstance(raw, bytes):
|
|
434
|
+
raw = raw.decode("utf-8")
|
|
435
|
+
await self._dispatch(ws, raw)
|
|
436
|
+
if self._state in {
|
|
437
|
+
SessionState.COMPLETE,
|
|
438
|
+
SessionState.EXPIRED,
|
|
439
|
+
SessionState.ERROR,
|
|
440
|
+
}:
|
|
441
|
+
break
|
|
442
|
+
except websockets.exceptions.ConnectionClosed:
|
|
443
|
+
logger.debug("Connection closed by remote")
|
|
444
|
+
except Exception as exc:
|
|
445
|
+
logger.exception("Unexpected error in connection handler: %s", exc)
|
|
446
|
+
err = EIDError("E_INTERNAL", str(exc))
|
|
447
|
+
self._state = SessionState.ERROR
|
|
448
|
+
if self._cb_error:
|
|
449
|
+
self._cb_error(err)
|
|
450
|
+
finally:
|
|
451
|
+
self._ws = None
|
|
452
|
+
|
|
453
|
+
async def _dispatch(self, ws: WebSocketServerProtocol, raw: str) -> None:
|
|
454
|
+
try:
|
|
455
|
+
msg = json.loads(raw)
|
|
456
|
+
except json.JSONDecodeError:
|
|
457
|
+
await self._send_error(ws, EIDError.E_UNKNOWN_TYPE, "Invalid JSON")
|
|
458
|
+
return
|
|
459
|
+
|
|
460
|
+
msg_type = msg.get("type")
|
|
461
|
+
session = msg.get("session")
|
|
462
|
+
seq = msg.get("seq")
|
|
463
|
+
payload = msg.get("payload") or {}
|
|
464
|
+
|
|
465
|
+
# Validate session ID
|
|
466
|
+
if session != self._session_id:
|
|
467
|
+
await self._send_error(ws, EIDError.E_SESSION_MISMATCH, "Session ID mismatch")
|
|
468
|
+
await ws.close()
|
|
469
|
+
return
|
|
470
|
+
|
|
471
|
+
# Validate sequence number
|
|
472
|
+
if seq is not None:
|
|
473
|
+
expected = self._seq_in + 1
|
|
474
|
+
if seq != expected:
|
|
475
|
+
await self._send_error(
|
|
476
|
+
ws,
|
|
477
|
+
EIDError.E_SEQUENCE_ERROR,
|
|
478
|
+
f"Expected seq {expected}, got {seq}",
|
|
479
|
+
)
|
|
480
|
+
await ws.close()
|
|
481
|
+
return
|
|
482
|
+
self._seq_in = seq
|
|
483
|
+
|
|
484
|
+
handlers = {
|
|
485
|
+
MsgType.HELLO: self._on_hello,
|
|
486
|
+
MsgType.CONFIRM: self._on_confirm,
|
|
487
|
+
MsgType.DATA: self._on_data_msg,
|
|
488
|
+
MsgType.CLOSE: self._on_close,
|
|
489
|
+
MsgType.ERROR: self._on_error_msg,
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
handler = handlers.get(msg_type)
|
|
493
|
+
if handler:
|
|
494
|
+
await handler(ws, payload)
|
|
495
|
+
else:
|
|
496
|
+
await self._send_error(ws, EIDError.E_UNKNOWN_TYPE, f"Unknown type: {msg_type}")
|
|
497
|
+
|
|
498
|
+
# ---------------------------------------------------------------------------
|
|
499
|
+
# Internal — message handlers
|
|
500
|
+
# ---------------------------------------------------------------------------
|
|
501
|
+
|
|
502
|
+
async def _on_hello(self, ws: WebSocketServerProtocol, payload: dict) -> None:
|
|
503
|
+
self._state = SessionState.HANDSHAKING
|
|
504
|
+
|
|
505
|
+
phone_pubkey_b64 = payload.get("pubkey")
|
|
506
|
+
if not phone_pubkey_b64:
|
|
507
|
+
await self._send_error(ws, EIDError.E_CRYPTO_FAILED, "Missing pubkey in HELLO")
|
|
508
|
+
return
|
|
509
|
+
|
|
510
|
+
try:
|
|
511
|
+
phone_pubkey = deserialize_pubkey(phone_pubkey_b64)
|
|
512
|
+
self._shared_secret = derive_shared_secret(self._privkey, phone_pubkey)
|
|
513
|
+
self._aes_key = derive_aes_key(self._shared_secret, self._session_id)
|
|
514
|
+
self._sas_code = compute_sas(self._shared_secret, self._session_id)
|
|
515
|
+
except Exception as exc:
|
|
516
|
+
await self._send_error(ws, EIDError.E_CRYPTO_FAILED, str(exc))
|
|
517
|
+
return
|
|
518
|
+
|
|
519
|
+
# Send HELLO_ACK — both sides now compute and display SAS
|
|
520
|
+
ack = build_message(MsgType.HELLO_ACK, self._session_id, self._next_seq_out(), {})
|
|
521
|
+
await ws.send(json.dumps(ack))
|
|
522
|
+
|
|
523
|
+
self._state = SessionState.PAIRED
|
|
524
|
+
logger.debug("SAS code: %s", self._sas_code)
|
|
525
|
+
|
|
526
|
+
if self._cb_paired:
|
|
527
|
+
self._cb_paired()
|
|
528
|
+
|
|
529
|
+
async def _on_confirm(self, ws: WebSocketServerProtocol, payload: dict) -> None:
|
|
530
|
+
# Phone user has confirmed the SAS code
|
|
531
|
+
self._phone_confirmed_event.set()
|
|
532
|
+
|
|
533
|
+
if self._require_sas:
|
|
534
|
+
# Wait for the developer to also call confirm_sas()
|
|
535
|
+
await self._sas_confirmed_event.wait()
|
|
536
|
+
|
|
537
|
+
# Check if the session timed out while we were waiting
|
|
538
|
+
if self._state == SessionState.EXPIRED:
|
|
539
|
+
return
|
|
540
|
+
|
|
541
|
+
ack = build_message(MsgType.CONFIRM_ACK, self._session_id, self._next_seq_out(), {})
|
|
542
|
+
await ws.send(json.dumps(ack))
|
|
543
|
+
logger.debug("SAS confirmed on both sides — session active")
|
|
544
|
+
|
|
545
|
+
async def _on_data_msg(self, ws: WebSocketServerProtocol, payload: dict) -> None:
|
|
546
|
+
self._state = SessionState.TRANSFERRING
|
|
547
|
+
|
|
548
|
+
try:
|
|
549
|
+
plaintext = decrypt_payload(self._aes_key, payload)
|
|
550
|
+
eid_data = EIDData.from_dict(plaintext)
|
|
551
|
+
except Exception as exc:
|
|
552
|
+
await self._send_error(ws, EIDError.E_CRYPTO_FAILED, f"Decryption failed: {exc}")
|
|
553
|
+
return
|
|
554
|
+
|
|
555
|
+
# Acknowledge receipt
|
|
556
|
+
ack = build_message(MsgType.DATA_ACK, self._session_id, self._next_seq_out(), {})
|
|
557
|
+
await ws.send(json.dumps(ack))
|
|
558
|
+
|
|
559
|
+
self._eid_data = eid_data
|
|
560
|
+
self._state = SessionState.COMPLETE
|
|
561
|
+
self._clear_keys()
|
|
562
|
+
|
|
563
|
+
if self._data_event:
|
|
564
|
+
self._data_event.set()
|
|
565
|
+
|
|
566
|
+
if self._cb_data:
|
|
567
|
+
self._cb_data(eid_data)
|
|
568
|
+
|
|
569
|
+
logger.debug("eID data received and decrypted successfully")
|
|
570
|
+
|
|
571
|
+
async def _on_close(self, ws: WebSocketServerProtocol, payload: dict) -> None:
|
|
572
|
+
self._state = SessionState.COMPLETE
|
|
573
|
+
self._clear_keys()
|
|
574
|
+
if self._data_event:
|
|
575
|
+
self._data_event.set()
|
|
576
|
+
await ws.close()
|
|
577
|
+
|
|
578
|
+
async def _on_error_msg(self, ws: WebSocketServerProtocol, payload: dict) -> None:
|
|
579
|
+
code = payload.get("code", "E_UNKNOWN")
|
|
580
|
+
message = payload.get("message", "Phone reported an error")
|
|
581
|
+
err = EIDError(code, message)
|
|
582
|
+
self._state = SessionState.ERROR
|
|
583
|
+
self._clear_keys()
|
|
584
|
+
if self._data_event:
|
|
585
|
+
self._data_event.set()
|
|
586
|
+
if self._cb_error:
|
|
587
|
+
self._cb_error(err)
|
|
588
|
+
await ws.close()
|
|
589
|
+
|
|
590
|
+
# ---------------------------------------------------------------------------
|
|
591
|
+
# Internal — helpers
|
|
592
|
+
# ---------------------------------------------------------------------------
|
|
593
|
+
|
|
594
|
+
async def _send_error(self, ws: WebSocketServerProtocol, code: str, message: str) -> None:
|
|
595
|
+
msg = build_message(
|
|
596
|
+
MsgType.ERROR,
|
|
597
|
+
self._session_id or "",
|
|
598
|
+
self._next_seq_out(),
|
|
599
|
+
{"code": code, "message": message},
|
|
600
|
+
)
|
|
601
|
+
try:
|
|
602
|
+
await ws.send(json.dumps(msg))
|
|
603
|
+
except Exception:
|
|
604
|
+
pass
|
|
605
|
+
self._state = SessionState.ERROR
|
|
606
|
+
if self._data_event:
|
|
607
|
+
self._data_event.set()
|
|
608
|
+
err = EIDError(code, message)
|
|
609
|
+
if self._cb_error:
|
|
610
|
+
self._cb_error(err)
|
|
611
|
+
|
|
612
|
+
async def _async_close(self) -> None:
|
|
613
|
+
if self._ws:
|
|
614
|
+
try:
|
|
615
|
+
msg = build_message(
|
|
616
|
+
MsgType.CLOSE,
|
|
617
|
+
self._session_id or "",
|
|
618
|
+
self._next_seq_out(),
|
|
619
|
+
{},
|
|
620
|
+
)
|
|
621
|
+
await self._ws.send(json.dumps(msg))
|
|
622
|
+
await self._ws.close()
|
|
623
|
+
except Exception:
|
|
624
|
+
pass
|
|
625
|
+
if self._server:
|
|
626
|
+
self._server.close()
|
|
627
|
+
try:
|
|
628
|
+
await asyncio.wait_for(self._server.wait_closed(), timeout=5.0)
|
|
629
|
+
except asyncio.TimeoutError:
|
|
630
|
+
pass
|
|
631
|
+
self._clear_keys()
|
|
632
|
+
self._state = SessionState.COMPLETE
|
|
633
|
+
if self._data_event:
|
|
634
|
+
self._data_event.set()
|
|
635
|
+
|
|
636
|
+
def _clear_keys(self) -> None:
|
|
637
|
+
"""Remove all cryptographic material from memory."""
|
|
638
|
+
self._privkey = None
|
|
639
|
+
self._shared_secret = None
|
|
640
|
+
self._aes_key = None
|
|
641
|
+
|
|
642
|
+
def _next_seq_out(self) -> int:
|
|
643
|
+
self._seq_out += 1
|
|
644
|
+
return self._seq_out
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Data models for eID Reader SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import date, datetime
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DocumentType(str, Enum):
|
|
13
|
+
PASSPORT = "PASSPORT"
|
|
14
|
+
ID_CARD = "ID_CARD"
|
|
15
|
+
RESIDENCE_PERMIT = "RESIDENCE_PERMIT"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Gender(str, Enum):
|
|
19
|
+
MALE = "M"
|
|
20
|
+
FEMALE = "F"
|
|
21
|
+
OTHER = "X"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SessionState(str, Enum):
|
|
25
|
+
IDLE = "IDLE"
|
|
26
|
+
LISTENING = "LISTENING"
|
|
27
|
+
HANDSHAKING = "HANDSHAKING"
|
|
28
|
+
PAIRED = "PAIRED"
|
|
29
|
+
TRANSFERRING = "TRANSFERRING"
|
|
30
|
+
COMPLETE = "COMPLETE"
|
|
31
|
+
EXPIRED = "EXPIRED"
|
|
32
|
+
ERROR = "ERROR"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class EIDMeta:
|
|
37
|
+
document_type: Optional[DocumentType] = None
|
|
38
|
+
issuing_country: Optional[str] = None
|
|
39
|
+
scan_timestamp: Optional[datetime] = None
|
|
40
|
+
sdk_version: Optional[str] = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class EIDPersonal:
|
|
45
|
+
surname: Optional[str] = None
|
|
46
|
+
given_names: Optional[str] = None
|
|
47
|
+
date_of_birth: Optional[date] = None
|
|
48
|
+
gender: Optional[Gender] = None
|
|
49
|
+
nationality: Optional[str] = None
|
|
50
|
+
personal_number: Optional[str] = None
|
|
51
|
+
place_of_birth: Optional[str] = None
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def full_name(self) -> Optional[str]:
|
|
55
|
+
parts = [p for p in [self.given_names, self.surname] if p]
|
|
56
|
+
return " ".join(parts) if parts else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class EIDDocument:
|
|
61
|
+
number: Optional[str] = None
|
|
62
|
+
expiry_date: Optional[date] = None
|
|
63
|
+
issue_date: Optional[date] = None
|
|
64
|
+
issuing_authority: Optional[str] = None
|
|
65
|
+
issuing_country: Optional[str] = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class EIDMrz:
|
|
70
|
+
line1: Optional[str] = None
|
|
71
|
+
line2: Optional[str] = None
|
|
72
|
+
is_valid: Optional[bool] = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class EIDImage:
|
|
77
|
+
face_photo: Optional[bytes] = None
|
|
78
|
+
signature: Optional[bytes] = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class EIDData:
|
|
83
|
+
meta: EIDMeta = field(default_factory=EIDMeta)
|
|
84
|
+
personal: EIDPersonal = field(default_factory=EIDPersonal)
|
|
85
|
+
document: EIDDocument = field(default_factory=EIDDocument)
|
|
86
|
+
mrz: EIDMrz = field(default_factory=EIDMrz)
|
|
87
|
+
image: EIDImage = field(default_factory=EIDImage)
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def from_dict(cls, data: dict) -> "EIDData":
|
|
91
|
+
obj = cls()
|
|
92
|
+
|
|
93
|
+
meta_d = data.get("meta") or {}
|
|
94
|
+
doc_type = meta_d.get("document_type")
|
|
95
|
+
obj.meta.document_type = DocumentType(doc_type) if doc_type else None
|
|
96
|
+
obj.meta.issuing_country = meta_d.get("issuing_country")
|
|
97
|
+
ts = meta_d.get("scan_timestamp")
|
|
98
|
+
if ts:
|
|
99
|
+
try:
|
|
100
|
+
obj.meta.scan_timestamp = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
101
|
+
except (ValueError, AttributeError):
|
|
102
|
+
pass
|
|
103
|
+
obj.meta.sdk_version = meta_d.get("sdk_version")
|
|
104
|
+
|
|
105
|
+
pers_d = data.get("personal") or {}
|
|
106
|
+
obj.personal.surname = pers_d.get("surname")
|
|
107
|
+
obj.personal.given_names = pers_d.get("given_names")
|
|
108
|
+
dob = pers_d.get("date_of_birth")
|
|
109
|
+
if dob:
|
|
110
|
+
try:
|
|
111
|
+
obj.personal.date_of_birth = date.fromisoformat(dob)
|
|
112
|
+
except (ValueError, AttributeError):
|
|
113
|
+
pass
|
|
114
|
+
gender = pers_d.get("gender")
|
|
115
|
+
# Accept both enum values and common string representations
|
|
116
|
+
gender_map = {
|
|
117
|
+
"M": Gender.MALE,
|
|
118
|
+
"F": Gender.FEMALE,
|
|
119
|
+
"X": Gender.OTHER,
|
|
120
|
+
"MALE": Gender.MALE,
|
|
121
|
+
"FEMALE": Gender.FEMALE,
|
|
122
|
+
"OTHER": Gender.OTHER,
|
|
123
|
+
}
|
|
124
|
+
if gender:
|
|
125
|
+
try:
|
|
126
|
+
obj.personal.gender = Gender(gender)
|
|
127
|
+
except ValueError:
|
|
128
|
+
obj.personal.gender = gender_map.get(str(gender).upper(), None)
|
|
129
|
+
else:
|
|
130
|
+
obj.personal.gender = None
|
|
131
|
+
obj.personal.nationality = pers_d.get("nationality")
|
|
132
|
+
obj.personal.personal_number = pers_d.get("personal_number")
|
|
133
|
+
obj.personal.place_of_birth = pers_d.get("place_of_birth")
|
|
134
|
+
|
|
135
|
+
doc_d = data.get("document") or {}
|
|
136
|
+
obj.document.number = doc_d.get("number")
|
|
137
|
+
for attr, key in [("expiry_date", "expiry_date"), ("issue_date", "issue_date")]:
|
|
138
|
+
val = doc_d.get(key)
|
|
139
|
+
if val:
|
|
140
|
+
try:
|
|
141
|
+
setattr(obj.document, attr, date.fromisoformat(val))
|
|
142
|
+
except (ValueError, AttributeError):
|
|
143
|
+
pass
|
|
144
|
+
obj.document.issuing_authority = doc_d.get("issuing_authority")
|
|
145
|
+
obj.document.issuing_country = doc_d.get("issuing_country")
|
|
146
|
+
|
|
147
|
+
mrz_d = data.get("mrz") or {}
|
|
148
|
+
obj.mrz.line1 = mrz_d.get("line1")
|
|
149
|
+
obj.mrz.line2 = mrz_d.get("line2")
|
|
150
|
+
obj.mrz.is_valid = mrz_d.get("valid")
|
|
151
|
+
|
|
152
|
+
img_d = data.get("image") or {}
|
|
153
|
+
for attr, key in [("face_photo", "face_photo"), ("signature", "signature")]:
|
|
154
|
+
val = img_d.get(key)
|
|
155
|
+
if val:
|
|
156
|
+
try:
|
|
157
|
+
setattr(obj.image, attr, base64.b64decode(val))
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
return obj
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class EIDError(Exception):
|
|
165
|
+
# Standard error codes
|
|
166
|
+
E_SESSION_MISMATCH = "E_SESSION_MISMATCH"
|
|
167
|
+
E_SEQUENCE_ERROR = "E_SEQUENCE_ERROR"
|
|
168
|
+
E_EXPIRED = "E_EXPIRED"
|
|
169
|
+
E_CRYPTO_FAILED = "E_CRYPTO_FAILED"
|
|
170
|
+
E_SAS_REJECTED = "E_SAS_REJECTED"
|
|
171
|
+
E_PROTOCOL_VERSION = "E_PROTOCOL_VERSION"
|
|
172
|
+
E_UNKNOWN_TYPE = "E_UNKNOWN_TYPE"
|
|
173
|
+
E_BIND_FAILED = "E_BIND_FAILED"
|
|
174
|
+
|
|
175
|
+
def __init__(self, code: str, message: str) -> None:
|
|
176
|
+
self.code = code
|
|
177
|
+
self.message = message
|
|
178
|
+
super().__init__(f"{code}: {message}")
|
|
179
|
+
|
|
180
|
+
def __str__(self) -> str:
|
|
181
|
+
return f"{self.code}: {self.message}"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: eidreader-sdk
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Host-side SDK for the eID Reader P2P protocol. Receives identity document data from a mobile app over an AES-256-GCM encrypted LAN WebSocket connection secured with ECDH P-256 key exchange and SAS MITM protection. Download the app from: https://play.google.com/store/apps/details?id=com.TFAStudios.eidreadermobile
|
|
5
|
+
Author: Tipa Fabian
|
|
6
|
+
License: GPL-3.0-only
|
|
7
|
+
Project-URL: Homepage, https://play.google.com/store/apps/details?id=com.TFAStudios.eidreadermobile
|
|
8
|
+
Keywords: eid,nfc,identity,document,qrcode,websocket,ecdh,encryption,p2p
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Topic :: Security :: Cryptography
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.8
|
|
19
|
+
Requires-Dist: websockets>=10.0
|
|
20
|
+
Requires-Dist: cryptography>=41.0
|
|
21
|
+
Requires-Dist: qrcode>=7.4
|
|
22
|
+
Provides-Extra: pil
|
|
23
|
+
Requires-Dist: Pillow>=10.0; extra == "pil"
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
27
|
+
Requires-Dist: Pillow>=10.0; extra == "dev"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
eidreader_sdk/__init__.py
|
|
3
|
+
eidreader_sdk/crypto.py
|
|
4
|
+
eidreader_sdk/network.py
|
|
5
|
+
eidreader_sdk/protocol.py
|
|
6
|
+
eidreader_sdk/qrcode_gen.py
|
|
7
|
+
eidreader_sdk/session.py
|
|
8
|
+
eidreader_sdk/types.py
|
|
9
|
+
eidreader_sdk.egg-info/PKG-INFO
|
|
10
|
+
eidreader_sdk.egg-info/SOURCES.txt
|
|
11
|
+
eidreader_sdk.egg-info/dependency_links.txt
|
|
12
|
+
eidreader_sdk.egg-info/requires.txt
|
|
13
|
+
eidreader_sdk.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
eidreader_sdk
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "eidreader-sdk"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Host-side SDK for the eID Reader P2P protocol. Receives identity document data from a mobile app over an AES-256-GCM encrypted LAN WebSocket connection secured with ECDH P-256 key exchange and SAS MITM protection. Download the app from: https://play.google.com/store/apps/details?id=com.TFAStudios.eidreadermobile"
|
|
9
|
+
requires-python = ">=3.8"
|
|
10
|
+
license = { text = "GPL-3.0-only" }
|
|
11
|
+
authors = [{ name = "Tipa Fabian" }]
|
|
12
|
+
keywords = ["eid", "nfc", "identity", "document", "qrcode", "websocket", "ecdh", "encryption", "p2p"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.8",
|
|
16
|
+
"Programming Language :: Python :: 3.9",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Topic :: Security :: Cryptography",
|
|
22
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
dependencies = [
|
|
26
|
+
"websockets>=10.0",
|
|
27
|
+
"cryptography>=41.0",
|
|
28
|
+
"qrcode>=7.4",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
pil = ["Pillow>=10.0"]
|
|
33
|
+
dev = [
|
|
34
|
+
"pytest>=8.0",
|
|
35
|
+
"pytest-asyncio>=0.23",
|
|
36
|
+
"Pillow>=10.0",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.packages.find]
|
|
40
|
+
where = ["."]
|
|
41
|
+
include = ["eidreader_sdk*"]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
asyncio_mode = "auto"
|
|
45
|
+
|
|
46
|
+
[project.urls]
|
|
47
|
+
Homepage = "https://play.google.com/store/apps/details?id=com.TFAStudios.eidreadermobile"
|