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.
@@ -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,11 @@
1
+ websockets>=10.0
2
+ cryptography>=41.0
3
+ qrcode>=7.4
4
+
5
+ [dev]
6
+ pytest>=8.0
7
+ pytest-asyncio>=0.23
8
+ Pillow>=10.0
9
+
10
+ [pil]
11
+ Pillow>=10.0
@@ -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"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+