pythonbeid 0.1.0__tar.gz → 0.2.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,104 @@
1
+ Metadata-Version: 2.4
2
+ Name: pythonbeid
3
+ Version: 0.2.0
4
+ Summary: Un module pour lire les informations des cartes d'identité belges
5
+ Author-email: Fabien Toune <fabien.toune@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Lapin-Blanc/pythonbeid
8
+ Project-URL: Bug Tracker, https://github.com/Lapin-Blanc/pythonbeid/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Topic :: Security
12
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: pyscard
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest; extra == "dev"
19
+ Requires-Dist: pytest-cov; extra == "dev"
20
+ Requires-Dist: build; extra == "dev"
21
+ Requires-Dist: twine; extra == "dev"
22
+ Dynamic: license-file
23
+
24
+
25
+ # PythonBEID
26
+
27
+ PythonBEID est un module Python pour lire les informations essentielles des cartes d'identité belge à l'aide d'un lecteur de cartes PC/SC et de la bibliothèque `pyscard`.
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ pip install pythonbeid
33
+ ```
34
+
35
+ ## Utilisation
36
+
37
+ ```python
38
+ from pythonbeid import CardReader, NoReaderError, NoCardError
39
+
40
+ try:
41
+ with CardReader() as cr: # context manager — ferme proprement la connexion
42
+ info = cr.read_informations(photo=False)
43
+ print(info["last_name"]) # Smith
44
+ print(info["birth_date"]) # datetime(1990, 1, 1)
45
+ except NoReaderError:
46
+ print("Aucun lecteur de carte détecté.")
47
+ except NoCardError:
48
+ print("Aucune carte dans le lecteur.")
49
+ ```
50
+
51
+ Si plusieurs lecteurs sont connectés, précisez l'index :
52
+
53
+ ```python
54
+ with CardReader(reader_index=1) as cr:
55
+ info = cr.read_informations()
56
+ ```
57
+
58
+ ### Champs retournés par `read_informations()`
59
+
60
+ | Clé | Type | Description |
61
+ |---|---|---|
62
+ | `card_number` | str | Numéro de la carte |
63
+ | `validity_start` | datetime | Date début de validité |
64
+ | `validity_end` | datetime | Date fin de validité |
65
+ | `issuing_municipality` | str | Commune de délivrance |
66
+ | `national_number` | str | Numéro national |
67
+ | `last_name` | str | Nom de famille |
68
+ | `first_names` | str | Prénom(s) |
69
+ | `suffix` | str | Suffixe de nom |
70
+ | `nationality` | str | Nationalité |
71
+ | `birth_place` | str | Lieu de naissance |
72
+ | `birth_date` | datetime | Date de naissance |
73
+ | `sex` | str | Sexe |
74
+ | `address` | str | Adresse |
75
+ | `postal_code` | str | Code postal |
76
+ | `city` | str | Localité |
77
+ | `photo` | str | Photo en base64 (si `photo=True`) |
78
+
79
+ ### Activer les logs
80
+
81
+ ```python
82
+ import logging
83
+ logging.basicConfig(level=logging.DEBUG)
84
+ ```
85
+
86
+ ## Dépendances
87
+
88
+ - `pyscard`
89
+
90
+ ## Tests
91
+
92
+ ```bash
93
+ pip install -e ".[dev]"
94
+ pytest # tous les tests (matériel skippé si absent)
95
+ pytest tests/test_parser.py # tests sans matériel uniquement
96
+ ```
97
+
98
+ ## Contribuer
99
+
100
+ Les contributions et améliorations sont les bienvenues !
101
+
102
+ ## Licence
103
+
104
+ Ce projet est sous licence MIT. Voir le fichier `LICENSE` pour plus de détails.
@@ -0,0 +1,81 @@
1
+
2
+ # PythonBEID
3
+
4
+ PythonBEID est un module Python pour lire les informations essentielles des cartes d'identité belge à l'aide d'un lecteur de cartes PC/SC et de la bibliothèque `pyscard`.
5
+
6
+ ## Installation
7
+
8
+ ```bash
9
+ pip install pythonbeid
10
+ ```
11
+
12
+ ## Utilisation
13
+
14
+ ```python
15
+ from pythonbeid import CardReader, NoReaderError, NoCardError
16
+
17
+ try:
18
+ with CardReader() as cr: # context manager — ferme proprement la connexion
19
+ info = cr.read_informations(photo=False)
20
+ print(info["last_name"]) # Smith
21
+ print(info["birth_date"]) # datetime(1990, 1, 1)
22
+ except NoReaderError:
23
+ print("Aucun lecteur de carte détecté.")
24
+ except NoCardError:
25
+ print("Aucune carte dans le lecteur.")
26
+ ```
27
+
28
+ Si plusieurs lecteurs sont connectés, précisez l'index :
29
+
30
+ ```python
31
+ with CardReader(reader_index=1) as cr:
32
+ info = cr.read_informations()
33
+ ```
34
+
35
+ ### Champs retournés par `read_informations()`
36
+
37
+ | Clé | Type | Description |
38
+ |---|---|---|
39
+ | `card_number` | str | Numéro de la carte |
40
+ | `validity_start` | datetime | Date début de validité |
41
+ | `validity_end` | datetime | Date fin de validité |
42
+ | `issuing_municipality` | str | Commune de délivrance |
43
+ | `national_number` | str | Numéro national |
44
+ | `last_name` | str | Nom de famille |
45
+ | `first_names` | str | Prénom(s) |
46
+ | `suffix` | str | Suffixe de nom |
47
+ | `nationality` | str | Nationalité |
48
+ | `birth_place` | str | Lieu de naissance |
49
+ | `birth_date` | datetime | Date de naissance |
50
+ | `sex` | str | Sexe |
51
+ | `address` | str | Adresse |
52
+ | `postal_code` | str | Code postal |
53
+ | `city` | str | Localité |
54
+ | `photo` | str | Photo en base64 (si `photo=True`) |
55
+
56
+ ### Activer les logs
57
+
58
+ ```python
59
+ import logging
60
+ logging.basicConfig(level=logging.DEBUG)
61
+ ```
62
+
63
+ ## Dépendances
64
+
65
+ - `pyscard`
66
+
67
+ ## Tests
68
+
69
+ ```bash
70
+ pip install -e ".[dev]"
71
+ pytest # tous les tests (matériel skippé si absent)
72
+ pytest tests/test_parser.py # tests sans matériel uniquement
73
+ ```
74
+
75
+ ## Contribuer
76
+
77
+ Les contributions et améliorations sont les bienvenues !
78
+
79
+ ## Licence
80
+
81
+ Ce projet est sous licence MIT. Voir le fichier `LICENSE` pour plus de détails.
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pythonbeid"
7
+ version = "0.2.0"
8
+ description = "Un module pour lire les informations des cartes d'identité belges"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ authors = [{ name = "Fabien Toune", email = "fabien.toune@gmail.com" }]
12
+ requires-python = ">=3.9"
13
+ dependencies = ["pyscard"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Operating System :: OS Independent",
17
+ "Topic :: Security",
18
+ "Topic :: Software Development :: Libraries :: Python Modules",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/Lapin-Blanc/pythonbeid"
23
+ "Bug Tracker" = "https://github.com/Lapin-Blanc/pythonbeid/issues"
24
+
25
+ [project.optional-dependencies]
26
+ dev = ["pytest", "pytest-cov", "build", "twine"]
27
+
28
+ [tool.pytest.ini_options]
29
+ testpaths = ["tests"]
@@ -0,0 +1,12 @@
1
+ """pythonbeid — read information from Belgian eID cards."""
2
+
3
+ from .card_reader import CardReader
4
+ from .exceptions import APDUError, CardCommunicationError, NoCardError, NoReaderError
5
+
6
+ __all__ = [
7
+ "CardReader",
8
+ "NoReaderError",
9
+ "NoCardError",
10
+ "CardCommunicationError",
11
+ "APDUError",
12
+ ]
@@ -0,0 +1,256 @@
1
+ """Belgian eID card reader.
2
+
3
+ Reads personal data, address and optionally the photo from a Belgian
4
+ electronic identity card via a PC/SC-compatible smart card reader.
5
+
6
+ Dependencies: pyscard (python-smartcard)
7
+
8
+ Usage::
9
+
10
+ from pythonbeid import CardReader
11
+
12
+ with CardReader() as cr:
13
+ info = cr.read_informations(photo=False)
14
+ print(info["last_name"])
15
+ """
16
+ import base64
17
+ import logging
18
+ from datetime import datetime
19
+ from typing import Any
20
+
21
+ from smartcard.Exceptions import CardConnectionException, NoCardException
22
+ from smartcard.System import readers
23
+
24
+ from .exceptions import APDUError, CardCommunicationError, NoCardError, NoReaderError
25
+ from .parser import parse_french_date, parse_tlv
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # ── File identifiers (path on the card) ──────────────────────────────────────
30
+ _FILE_ID = [0x3F, 0x00, 0xDF, 0x01, 0x40, 0x31] # personal identity data
31
+ _FILE_ADDRESS = [0x3F, 0x00, 0xDF, 0x01, 0x40, 0x33] # address data
32
+ _FILE_PHOTO = [0x3F, 0x00, 0xDF, 0x01, 0x40, 0x35] # photo data
33
+
34
+ # ISO 7816-4: Le=0x00 in a short APDU means "expect up to 256 bytes".
35
+ _LE_MAX = 0x00
36
+
37
+ # ── Status words that are handled in-band ──────────────────────────────────
38
+ _SW_OK = 0x90 # Normal completion
39
+ _SW_MORE_DATA = 0x61 # More data available (GET RESPONSE with SW2 bytes)
40
+ _SW_WRONG_LE = 0x6C # Wrong Le: use SW2 as exact length
41
+
42
+
43
+ class CardReader:
44
+ """Read information from a Belgian eID card.
45
+
46
+ Parameters
47
+ ----------
48
+ reader_index:
49
+ Index of the PC/SC reader to use when multiple readers are connected.
50
+ Defaults to 0 (first available reader).
51
+
52
+ Examples
53
+ --------
54
+ Using as a context manager (recommended)::
55
+
56
+ with CardReader() as cr:
57
+ data = cr.read_informations()
58
+
59
+ Or manually::
60
+
61
+ cr = CardReader()
62
+ try:
63
+ data = cr.read_informations()
64
+ finally:
65
+ cr.close()
66
+ """
67
+
68
+ def __init__(self, reader_index: int = 0) -> None:
69
+ available = readers()
70
+ if not available:
71
+ raise NoReaderError("No smart card reader detected")
72
+ if reader_index >= len(available):
73
+ raise NoReaderError(
74
+ f"Reader index {reader_index} out of range "
75
+ f"({len(available)} reader(s) available)"
76
+ )
77
+ reader = available[reader_index]
78
+ logger.debug("Using reader: %s", reader)
79
+ try:
80
+ self._cnx = reader.createConnection()
81
+ self._cnx.connect()
82
+ except NoCardException:
83
+ raise NoCardError(f"No card in reader '{reader}'")
84
+ except CardConnectionException as exc:
85
+ raise CardCommunicationError(f"Connection error on '{reader}': {exc}") from exc
86
+ logger.debug("Connected to card via '%s'", reader)
87
+
88
+ # ── Context manager ───────────────────────────────────────────────────
89
+
90
+ def __enter__(self) -> "CardReader":
91
+ return self
92
+
93
+ def __exit__(self, *_: Any) -> None:
94
+ self.close()
95
+
96
+ def close(self) -> None:
97
+ """Disconnect from the card and release the reader."""
98
+ try:
99
+ self._cnx.disconnect()
100
+ logger.debug("Disconnected from card")
101
+ except Exception:
102
+ pass # Ignore errors on cleanup
103
+
104
+ # ── Low-level APDU transport ──────────────────────────────────────────
105
+
106
+ def _send_apdu(self, apdu: list[int]) -> tuple[list[int], int, int]:
107
+ """Transmit one APDU and return ``(data, SW1, SW2)``.
108
+
109
+ Raises
110
+ ------
111
+ APDUError
112
+ If the card returns an unexpected status word (i.e. not 0x90,
113
+ 0x61, or 0x6C).
114
+ """
115
+ response, sw1, sw2 = self._cnx.transmit(apdu)
116
+ logger.debug("APDU %s → SW=%02X%02X data(%d)=%s",
117
+ [f"{b:02X}" for b in apdu], sw1, sw2, len(response),
118
+ [f"{b:02X}" for b in response[:8]])
119
+ if sw1 not in (_SW_OK, _SW_MORE_DATA, _SW_WRONG_LE):
120
+ raise APDUError(sw1, sw2,
121
+ f"Unexpected status SW={sw1:02X}{sw2:02X} "
122
+ f"for APDU {[hex(b) for b in apdu[:4]]}")
123
+ return response, sw1, sw2
124
+
125
+ # ── File access helpers ───────────────────────────────────────────────
126
+
127
+ def _select_file(self, file_id: list[int]) -> None:
128
+ """Send a SELECT command for *file_id* (no response expected)."""
129
+ cmd = [0x00, 0xA4, 0x08, 0x0C, len(file_id)] + file_id
130
+ _, sw1, sw2 = self._send_apdu(cmd)
131
+ if sw1 != _SW_OK:
132
+ raise APDUError(sw1, sw2, f"SELECT failed for file {[hex(b) for b in file_id]}")
133
+ logger.debug("Selected file %s", [f"{b:02X}" for b in file_id])
134
+
135
+ def _read_binary(self, p1: int, p2: int, le: int) -> tuple[list[int], int, int]:
136
+ """Send a READ BINARY command and handle Le mismatch (0x6C)."""
137
+ cmd = [0x00, 0xB0, p1, p2, le]
138
+ data, sw1, sw2 = self._send_apdu(cmd)
139
+ if sw1 == _SW_WRONG_LE:
140
+ # Card tells us the exact length: re-issue with SW2 as Le.
141
+ logger.debug("RE-READ BINARY with Le=%d (was %d)", sw2, le)
142
+ cmd[-1] = sw2
143
+ data, sw1, sw2 = self._send_apdu(cmd)
144
+ return data, sw1, sw2
145
+
146
+ def _read_data(self, file_id: list[int]) -> list[int]:
147
+ """Select *file_id* and read its content (up to 256 bytes)."""
148
+ self._select_file(file_id)
149
+ data, sw1, sw2 = self._read_binary(0x00, 0x00, _LE_MAX)
150
+ logger.debug("Read %d bytes from file %s, SW=%02X%02X",
151
+ len(data), [f"{b:02X}" for b in file_id], sw1, sw2)
152
+ return data
153
+
154
+ def _read_photo(self) -> bytearray:
155
+ """Read the complete photo file in 256-byte chunks.
156
+
157
+ The photo file is typically larger than 256 bytes so it must be
158
+ read with successive READ BINARY commands using an incrementing
159
+ offset (P1 = high byte, P2 = low byte of a 15-bit offset).
160
+ """
161
+ self._select_file(_FILE_PHOTO)
162
+ photo_bytes: list[int] = []
163
+ # Offset advances by 256 bytes on each iteration.
164
+ # P1 holds the high byte of the byte offset (offset >> 8),
165
+ # P2 holds the low byte (offset & 0xFF).
166
+ # Starting with P1=0, P2=0 and incrementing P1 by 1 each time
167
+ # moves the window by 256 bytes per step.
168
+ p1 = 0
169
+ while True:
170
+ data, sw1, sw2 = self._read_binary(p1, 0x00, _LE_MAX)
171
+ if sw1 == _SW_OK:
172
+ photo_bytes.extend(data)
173
+ if len(data) < 256:
174
+ # Received fewer bytes than requested → end of file.
175
+ break
176
+ p1 += 1
177
+ elif sw1 == _SW_WRONG_LE:
178
+ # _read_binary already retried with the correct Le,
179
+ # so if we still get here something went wrong.
180
+ logger.warning("Unexpected 0x6C after retry at p1=%d", p1)
181
+ photo_bytes.extend(data)
182
+ break
183
+ else:
184
+ # Any other status (e.g. 0x6282 end-of-file) ends the loop.
185
+ logger.debug("Photo read ended at p1=%d, SW=%02X%02X", p1, sw1, sw2)
186
+ break
187
+ logger.debug("Photo: %d bytes total", len(photo_bytes))
188
+ return bytearray(photo_bytes)
189
+
190
+ # ── Public API ────────────────────────────────────────────────────────
191
+
192
+ def read_informations(self, photo: bool = False) -> dict[str, Any]:
193
+ """Read and return all information from the card.
194
+
195
+ Parameters
196
+ ----------
197
+ photo:
198
+ When ``True`` the card photo is read and included in the
199
+ returned dictionary as a base-64 encoded string.
200
+
201
+ Returns
202
+ -------
203
+ dict
204
+ Keys: ``card_number``, ``validity_start``, ``validity_end``,
205
+ ``issuing_municipality``, ``national_number``, ``last_name``,
206
+ ``first_names``, ``suffix``, ``nationality``, ``birth_place``,
207
+ ``birth_date``, ``sex``, ``address``, ``postal_code``,
208
+ ``city``, and optionally ``photo``.
209
+ """
210
+ # ── Identity file ────────────────────────────────────────────────
211
+ id_data = self._read_data(_FILE_ID)
212
+ id_fields = parse_tlv(id_data, 13) # tags 1–13 (tag 2 / index 1 unused)
213
+
214
+ informations: dict[str, Any] = {
215
+ "card_number": id_fields[0],
216
+ # id_fields[1] is the chip serial number — not exposed in the API
217
+ "validity_start": datetime.strptime(id_fields[2], "%d.%m.%Y"),
218
+ "validity_end": datetime.strptime(id_fields[3], "%d.%m.%Y"),
219
+ "issuing_municipality": id_fields[4],
220
+ "national_number": id_fields[5],
221
+ "last_name": id_fields[6],
222
+ "first_names": id_fields[7],
223
+ "suffix": id_fields[8],
224
+ "nationality": id_fields[9],
225
+ "birth_place": id_fields[10],
226
+ "birth_date": parse_french_date(id_fields[11]),
227
+ "sex": id_fields[12],
228
+ }
229
+
230
+ # ── Address file ─────────────────────────────────────────────────
231
+ addr_data = self._read_data(_FILE_ADDRESS)
232
+ addr_fields = parse_tlv(addr_data, 3)
233
+
234
+ informations["address"] = addr_fields[0]
235
+ informations["postal_code"] = addr_fields[1]
236
+ informations["city"] = addr_fields[2]
237
+
238
+ # ── Photo (optional) ─────────────────────────────────────────────
239
+ if photo:
240
+ photo_bytes = self._read_photo()
241
+ informations["photo"] = base64.b64encode(photo_bytes).decode("ascii")
242
+
243
+ # Mirror every key as an instance attribute for convenience.
244
+ for key, value in informations.items():
245
+ setattr(self, key, value)
246
+
247
+ return informations
248
+
249
+
250
+ if __name__ == "__main__":
251
+ import logging
252
+ from pprint import pprint
253
+
254
+ logging.basicConfig(level=logging.WARNING)
255
+ with CardReader() as cr:
256
+ pprint(cr.read_informations(photo=False))
@@ -0,0 +1,30 @@
1
+ """Custom exceptions for pythonbeid."""
2
+
3
+
4
+ class NoReaderError(RuntimeError):
5
+ """Raised when no smart card reader is detected."""
6
+
7
+
8
+ class NoCardError(RuntimeError):
9
+ """Raised when a reader is present but no card is inserted."""
10
+
11
+
12
+ class CardCommunicationError(RuntimeError):
13
+ """Raised on a general card communication failure."""
14
+
15
+
16
+ class APDUError(CardCommunicationError):
17
+ """Raised when an APDU command returns an unexpected status word.
18
+
19
+ Attributes
20
+ ----------
21
+ sw1 : int
22
+ First status byte (SW1).
23
+ sw2 : int
24
+ Second status byte (SW2).
25
+ """
26
+
27
+ def __init__(self, sw1: int, sw2: int, message: str = "") -> None:
28
+ self.sw1 = sw1
29
+ self.sw2 = sw2
30
+ super().__init__(message or f"Unexpected APDU status: SW={sw1:02X}{sw2:02X}")
@@ -0,0 +1,89 @@
1
+ """Pure parsing functions for Belgian eID card data.
2
+
3
+ These functions have no I/O dependencies and can be tested without hardware.
4
+ """
5
+ from datetime import datetime
6
+
7
+ # French month abbreviations as found on Belgian eID cards.
8
+ _MONTH_MAP: dict[str, str] = {
9
+ "JANV": "01", "JAN": "01",
10
+ "FEVR": "02", "FEV": "02",
11
+ "MARS": "03", "MAR": "03",
12
+ "AVRI": "04", "AVR": "04",
13
+ "MAI": "05",
14
+ "JUIN": "06",
15
+ "JUIL": "07",
16
+ "AOUT": "08", "AOU": "08",
17
+ "SEPT": "09", "SEP": "09",
18
+ "OCTO": "10", "OCT": "10",
19
+ "NOVE": "11", "NOV": "11",
20
+ "DECE": "12", "DEC": "12",
21
+ }
22
+
23
+
24
+ def parse_tlv(data: list[int], num_fields: int) -> list[str]:
25
+ """Parse a TLV-encoded buffer from a Belgian eID card.
26
+
27
+ Each record has the format: [tag: 1 byte] [length: 1 byte] [value: length bytes].
28
+ Parsing stops once *num_fields* records have been extracted or the buffer
29
+ is exhausted (whichever comes first).
30
+
31
+ Parameters
32
+ ----------
33
+ data:
34
+ Raw bytes received from the card (list of ints, 0–255).
35
+ num_fields:
36
+ Maximum number of fields to extract.
37
+
38
+ Returns
39
+ -------
40
+ list[str]
41
+ Decoded string values; an empty string is used for any field that
42
+ cannot be decoded as UTF-8.
43
+ """
44
+ idx = 0
45
+ fields: list[str] = []
46
+ while len(fields) < num_fields:
47
+ # Need at least 2 bytes for tag + length.
48
+ if idx + 1 >= len(data):
49
+ break
50
+ # tag byte (idx) — not used for value extraction
51
+ idx += 1
52
+ length = data[idx]
53
+ idx += 1
54
+ # Guard against a truncated buffer.
55
+ if idx + length > len(data):
56
+ break
57
+ raw = bytes(data[idx: idx + length])
58
+ idx += length
59
+ try:
60
+ fields.append(raw.decode("utf-8"))
61
+ except UnicodeDecodeError:
62
+ fields.append("")
63
+ return fields
64
+
65
+
66
+ def parse_french_date(date_str: str) -> datetime:
67
+ """Parse a French date string as printed on Belgian eID cards.
68
+
69
+ Expected format: ``DD MON YYYY`` where *MON* is a French month
70
+ abbreviation (e.g. ``"15 JANV 2000"`` or ``"03 MAR 1985"``).
71
+
72
+ Unknown month abbreviations fall back to January ("01").
73
+
74
+ Parameters
75
+ ----------
76
+ date_str:
77
+ Date string to parse.
78
+
79
+ Returns
80
+ -------
81
+ datetime
82
+ Parsed date (time component is midnight, no timezone).
83
+ """
84
+ parts = date_str.split()
85
+ if len(parts) != 3:
86
+ raise ValueError(f"Unexpected date format: {date_str!r}")
87
+ day, month_abbr, year = parts
88
+ month = _MONTH_MAP.get(month_abbr.upper(), "01")
89
+ return datetime.strptime(f"{day}/{month}/{year}", "%d/%m/%Y")