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.
- pythonbeid-0.2.0/PKG-INFO +104 -0
- pythonbeid-0.2.0/README.md +81 -0
- pythonbeid-0.2.0/pyproject.toml +29 -0
- pythonbeid-0.2.0/pythonbeid/__init__.py +12 -0
- pythonbeid-0.2.0/pythonbeid/card_reader.py +256 -0
- pythonbeid-0.2.0/pythonbeid/exceptions.py +30 -0
- pythonbeid-0.2.0/pythonbeid/parser.py +89 -0
- pythonbeid-0.2.0/pythonbeid.egg-info/PKG-INFO +104 -0
- {pythonbeid-0.1.0 → pythonbeid-0.2.0}/pythonbeid.egg-info/SOURCES.txt +5 -3
- pythonbeid-0.2.0/pythonbeid.egg-info/requires.txt +7 -0
- {pythonbeid-0.1.0 → pythonbeid-0.2.0}/pythonbeid.egg-info/top_level.txt +0 -1
- pythonbeid-0.2.0/tests/test_card_reader.py +238 -0
- pythonbeid-0.2.0/tests/test_parser.py +103 -0
- pythonbeid-0.1.0/PKG-INFO +0 -74
- pythonbeid-0.1.0/README.md +0 -59
- pythonbeid-0.1.0/pythonbeid/__init__.py +0 -0
- pythonbeid-0.1.0/pythonbeid/card_reader.py +0 -232
- pythonbeid-0.1.0/pythonbeid.egg-info/PKG-INFO +0 -74
- pythonbeid-0.1.0/pythonbeid.egg-info/requires.txt +0 -1
- pythonbeid-0.1.0/setup.py +0 -25
- pythonbeid-0.1.0/tests/__init__.py +0 -0
- pythonbeid-0.1.0/tests/test_card_reader.py +0 -11
- {pythonbeid-0.1.0 → pythonbeid-0.2.0}/LICENSE +0 -0
- {pythonbeid-0.1.0 → pythonbeid-0.2.0}/pythonbeid.egg-info/dependency_links.txt +0 -0
- {pythonbeid-0.1.0 → pythonbeid-0.2.0}/setup.cfg +0 -0
|
@@ -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")
|