pybiss 0.0.1__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.
pybiss-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: pybiss
3
+ Version: 0.0.1
4
+ Summary: Unofficial BISS implementation in Python. Fast, modern, self contained
5
+ Requires-Python: >=3.13
6
+ Description-Content-Type: text/markdown
pybiss-0.0.1/Readme.md ADDED
File without changes
@@ -0,0 +1,13 @@
1
+ [project]
2
+ name = "pybiss"
3
+ version = "0.0.1"
4
+ description = "Unofficial BISS implementation in Python. Fast, modern, self contained"
5
+ readme = "Readme.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = []
8
+
9
+
10
+ [project.scripts]
11
+ # pybiss-server = "src.app:app.run"
12
+ # pybiss-tui = "src.tui:run_tui"
13
+ # pybiss-gui = "src.desktop_ui:main"
pybiss-0.0.1/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,286 @@
1
+ import base64, sys
2
+
3
+ sys.path.append(__file__.rsplit('/', 1)[0])
4
+
5
+ from src.server import MiniServer
6
+ import src.detector as detector
7
+ import src.hardware as hardware
8
+ import src.service as service
9
+ import src.server_ui as ui
10
+
11
+
12
+ # -- Endpoints
13
+
14
+ app = MiniServer()
15
+
16
+
17
+ @app.get('/version')
18
+ def get_version(req):
19
+ return {
20
+ 'version': 'pybiss-1.0.0',
21
+ 'httpMethods': 'GET, POST',
22
+ 'contentTypes': 'data, digest',
23
+ 'signatureTypes': 'signature',
24
+ 'selectorAvailable': True,
25
+ 'hashAlgorithms': 'SHA1, SHA256, SHA384, SHA512'
26
+ }
27
+
28
+
29
+ @app.get('/status')
30
+ def get_status(req):
31
+ atrs = detector.get_connected_atrs()
32
+ lib_path = detector.auto_detect_library()
33
+ return {
34
+ 'status': 'ok' if atrs else 'no_cards_detected',
35
+ 'readers': atrs,
36
+ 'driver': lib_path
37
+ }
38
+
39
+
40
+ @app.post('/getsigner')
41
+ def get_signer(req):
42
+ data = req.json
43
+ selector = data.get('selector')
44
+ show_valid_certs = data.get('showValidCerts', True)
45
+
46
+ lib_path = detector.auto_detect_library()
47
+ if not lib_path:
48
+ raise Exception('No supported smart cards detected')
49
+
50
+ funcs = hardware.load_pkcs11(lib_path)
51
+ slots = hardware.get_slots(funcs, token_present=True)
52
+ if not slots:
53
+ raise Exception('No token present in smart card reader')
54
+
55
+ ui_provider = ui.get_ui_provider()
56
+ pin = ui_provider.prompt_pin()
57
+ if not pin:
58
+ return {'chain': [], 'status': 'error', 'reasonCode': 401, 'reasonText': 'PIN canceled'}
59
+
60
+ all_certs = []
61
+ for slot_id in slots:
62
+ try:
63
+ certs = hardware.get_certificates(funcs, slot_id, pin)
64
+ for c in certs:
65
+ c['slot_id'] = slot_id
66
+ c['pin'] = pin
67
+ all_certs.append(c)
68
+ except Exception:
69
+ continue
70
+
71
+ filtered_certs = service.filter_certificates(all_certs, selector=selector, show_valid_certs=show_valid_certs)
72
+ if not filtered_certs:
73
+ return {'chain': [], 'status': 'error', 'reasonCode': 404, 'reasonText': 'No matching certificates'}
74
+
75
+ choice_idx = ui_provider.choose_certificate(filtered_certs)
76
+ if choice_idx == -1:
77
+ return {'chain': [], 'status': 'error', 'reasonCode': 400, 'reasonText': 'Canceled'}
78
+
79
+ selected_cert = filtered_certs[choice_idx]
80
+ cert_b64 = base64.b64encode(selected_cert['der']).decode('utf-8')
81
+
82
+ return {'chain': [cert_b64], 'status': 'ok', 'reasonCode': 200, 'reasonText': 'Success'}
83
+
84
+
85
+ @app.post('/sign')
86
+ def sign(req):
87
+
88
+ data = req.json
89
+
90
+ # Extract fields from the raw JSON payload
91
+ contents = data.get('contents', [])
92
+ signed_contents = data.get('signedContents', [])
93
+ signed_contents_cert = data.get('signedContentsCert', [])
94
+ hash_algorithm = data.get('hashAlgorithm', 'SHA256')
95
+ confirm_text = data.get('confirmText', [])
96
+ signer_cert_b64 = data.get('signerCertificateB64')
97
+
98
+ # Verification of Server Signature
99
+ if len(contents) != len(signed_contents) or not contents:
100
+ return {'signatures': [], 'status': 'error', 'reasonCode': 400, 'reasonText': 'Contents and signature length mismatch/empty'}
101
+
102
+ try:
103
+ if not signed_contents_cert:
104
+ raise ValueError('Server certificate missing')
105
+ server_cert_der = base64.b64decode(signed_contents_cert[0])
106
+ except Exception:
107
+ return {'signatures': [], 'status': 'error', 'reasonCode': 400, 'reasonText': 'Invalid server certificate encoding'}
108
+
109
+ # Verify each content piece against the server signature
110
+ for i in range(len(contents)):
111
+ try:
112
+ payload = base64.b64decode(contents[i])
113
+ srv_sig = base64.b64decode(signed_contents[i])
114
+ except Exception:
115
+ return {'signatures': [], 'status': 'error', 'reasonCode': 400, 'reasonText': 'Invalid payload/signature base64 encoding'}
116
+
117
+ verified = service.verify_server_signature(
118
+ payload=payload,
119
+ signature=srv_sig,
120
+ server_cert_der=server_cert_der,
121
+ hash_alg=hash_algorithm
122
+ )
123
+ if not verified:
124
+ return {'signatures': [], 'status': 'error', 'reasonCode': 403, 'reasonText': 'Server signature verification failed'}
125
+
126
+ # Confirm signature request with the user
127
+ ui_provider = ui.get_ui_provider()
128
+ confirm_msg = confirm_text[0] if confirm_text else 'Authorize signature request from banking portal?'
129
+
130
+ confirmed = ui_provider.confirm_sign(confirm_msg)
131
+ if not confirmed:
132
+ return {'signatures': [], 'status': 'error', 'reasonCode': 400, 'reasonText': 'User rejected signature request'}
133
+
134
+ # Locate card PKCS11 library
135
+ lib_path = detector.auto_detect_library()
136
+ if not lib_path:
137
+ return {'signatures': [], 'status': 'error', 'reasonCode': 400, 'reasonText': 'No supported smart cards detected'}
138
+
139
+ try:
140
+ funcs = hardware.load_pkcs11(lib_path)
141
+ except Exception as e:
142
+ return {'signatures': [], 'status': 'error', 'reasonCode': 500, 'reasonText': f'Failed to load PKCS11 driver: {e}'}
143
+
144
+ # Search and identify which slot contains the requested signer certificate
145
+ try:
146
+ if not signer_cert_b64:
147
+ raise ValueError('Signer certificate missing')
148
+ signer_cert_der = base64.b64decode(signer_cert_b64)
149
+ except Exception:
150
+ return {'signatures': [], 'status': 'error', 'reasonCode': 400, 'reasonText': 'Invalid signer certificate encoding'}
151
+
152
+ slots = hardware.get_slots(funcs, token_present=True)
153
+ target_slot = None
154
+ target_cert_id = None
155
+ target_pin = None
156
+
157
+ # Prompt user for PIN to search card
158
+ pin = ui_provider.prompt_pin()
159
+ if not pin:
160
+ return {'signatures': [], 'status': 'error', 'reasonCode': 401, 'reasonText': 'PIN required'}
161
+
162
+ for slot_id in slots:
163
+ try:
164
+ certs = hardware.get_certificates(funcs, slot_id, pin)
165
+ for c in certs:
166
+ if c['der'] == signer_cert_der:
167
+ target_slot = slot_id
168
+ target_cert_id = c['id']
169
+ target_pin = pin
170
+ break
171
+ if target_slot is not None:
172
+ break
173
+ except Exception:
174
+ continue
175
+
176
+ if target_slot is None:
177
+ return {'signatures': [], 'status': 'error', 'reasonCode': 404, 'reasonText': 'Requested signing certificate not found on smart card'}
178
+
179
+ # Perform PKCS#11 signing
180
+ generated_signatures = []
181
+ for payload_b64 in contents:
182
+ try:
183
+ payload_bytes = base64.b64decode(payload_b64)
184
+ sig = hardware.sign_payload(
185
+ funcs=funcs,
186
+ slot_id=target_slot,
187
+ pin=target_pin,
188
+ payload=payload_bytes,
189
+ key_id=target_cert_id
190
+ )
191
+ generated_signatures.append(base64.b64encode(sig).decode('utf-8'))
192
+ except Exception as e:
193
+ return {'signatures': [], 'status': 'error', 'reasonCode': 500, 'reasonText': f'Signing failed: {e}'}
194
+
195
+ return {'signatures': generated_signatures, 'status': 'ok', 'reasonCode': 200, 'reasonText': 'Success'}
196
+
197
+
198
+ @app.get('/getSerialNumber')
199
+ def get_serial_number(req):
200
+
201
+ lib_path = detector.auto_detect_library()
202
+ if not lib_path:
203
+ return {'status': 'error', 'reasonCode': 400, 'reasonText': 'No supported smart cards detected'}
204
+
205
+ try:
206
+ funcs = hardware.load_pkcs11(lib_path)
207
+ slots = hardware.get_slots(funcs, token_present=True)
208
+ if not slots:
209
+ return {'status': 'error', 'reasonCode': 404, 'reasonText': 'No token present'}
210
+
211
+ serial = hardware.get_token_serial_number(funcs, slots[0])
212
+ # The Java app returns keysize and vendor too, but serial is the most critical
213
+ return {'serialNumber': serial, 'status': 'ok', 'reasonCode': 200, 'reasonText': 'Success'}
214
+ except Exception as e:
215
+ return {'status': 'error', 'reasonCode': 500, 'reasonText': str(e)}
216
+
217
+
218
+ @app.post('/setPIN')
219
+ def set_pin(req):
220
+
221
+ data = req.json
222
+ old_pin = data.get('oldPIN')
223
+ new_pin = data.get('newPIN')
224
+
225
+ if not old_pin or not new_pin:
226
+ return {'status': 'error', 'reasonCode': 400, 'reasonText': 'Missing PIN data'}
227
+
228
+ lib_path = detector.auto_detect_library()
229
+ try:
230
+ funcs = hardware.load_pkcs11(lib_path)
231
+ slots = hardware.get_slots(funcs, token_present=True)
232
+ hardware.change_card_pin(funcs, slots[0], old_pin, new_pin)
233
+ return {'status': 'ok', 'reasonCode': 200, 'reasonText': 'PIN successfully changed'}
234
+ except Exception as e:
235
+ return {'status': 'error', 'reasonCode': 500, 'reasonText': str(e)}
236
+
237
+
238
+ @app.post('/unblockPIN')
239
+ def unblock_pin(req):
240
+
241
+ data = req.json
242
+ puk = data.get('puk')
243
+ new_pin = data.get('newPIN')
244
+
245
+ if not puk or not new_pin:
246
+ return {'status': 'error', 'reasonCode': 400, 'reasonText': 'Missing PUK or new PIN'}
247
+
248
+ lib_path = detector.auto_detect_library()
249
+ try:
250
+ funcs = hardware.load_pkcs11(lib_path)
251
+ slots = hardware.get_slots(funcs, token_present=True)
252
+ hardware.unblock_card_pin(funcs, slots[0], puk, new_pin)
253
+ return {'status': 'ok', 'reasonCode': 200, 'reasonText': 'PIN successfully unblocked'}
254
+ except Exception as e:
255
+ return {'status': 'error', 'reasonCode': 403, 'reasonText': str(e)}
256
+
257
+
258
+ @app.post('/writeCertSC')
259
+ def write_cert_sc(req):
260
+
261
+ data = req.json
262
+ cert_b64 = data.get('certificate')
263
+
264
+ if not cert_b64:
265
+ return {'status': 'error', 'reasonCode': 400, 'reasonText': 'Missing certificate data'}
266
+
267
+ ui_provider = ui.get_ui_provider()
268
+ pin = ui_provider.prompt_pin()
269
+ if not pin:
270
+ return {'status': 'error', 'reasonCode': 401, 'reasonText': 'PIN canceled'}
271
+
272
+ lib_path = detector.auto_detect_library()
273
+ try:
274
+ cert_der = base64.b64decode(cert_b64)
275
+ funcs = hardware.load_pkcs11(lib_path)
276
+ slots = hardware.get_slots(funcs, token_present=True)
277
+
278
+ hardware.write_certificate(funcs, slots[0], pin, cert_der, label="B-Trust Certificate")
279
+ return {'status': 'ok', 'reasonCode': 200, 'reasonText': 'Certificate successfully written to card'}
280
+ except Exception as e:
281
+ return {'status': 'error', 'reasonCode': 500, 'reasonText': str(e)}
282
+
283
+
284
+ if __name__ == '__main__':
285
+
286
+ app.run(host='127.0.0.1', port=4843)
@@ -0,0 +1,193 @@
1
+ from datetime import datetime, timezone
2
+
3
+ class DerReader:
4
+
5
+ def __init__(self, data: bytes):
6
+ self.data = data
7
+ self.pos = 0
8
+
9
+ def is_empty(self) -> bool:
10
+ return self.pos >= len(self.data)
11
+
12
+
13
+ def read_seq(self):
14
+ ''' Expects a sequence and returns a new reader for its contents '''
15
+
16
+ tag, val = self.read_tlv()
17
+ if tag != 0x30:
18
+ raise ValueError(f'Expected SEQUENCE (0x30), got {hex(tag)}')
19
+ return DerReader(val)
20
+
21
+
22
+ def read_tlv(self) -> tuple[int, bytes]:
23
+ ''' Reads a Tag-Length-Value block and advances the pointer '''
24
+
25
+ if self.pos >= len(self.data):
26
+ raise ValueError("Unexpected EOF reading tag")
27
+
28
+ tag = self.data[self.pos]
29
+ self.pos += 1
30
+
31
+ if self.pos >= len(self.data):
32
+ raise ValueError("Unexpected EOF reading length")
33
+
34
+ length = self.data[self.pos]
35
+ self.pos += 1
36
+
37
+ # Handle multi-byte lengths (e.g. > 127 bytes)
38
+ if length & 0x80:
39
+ num_bytes = length & 0x7F
40
+ if self.pos + num_bytes > len(self.data):
41
+ raise ValueError("Unexpected EOF reading multi-byte length")
42
+
43
+ length = int.from_bytes(self.data[self.pos:self.pos + num_bytes], 'big')
44
+ self.pos += num_bytes
45
+
46
+ # Strict bounds check before slicing
47
+ if self.pos + length > len(self.data):
48
+ raise ValueError(f"Unexpected EOF reading value. Needs {length} bytes, but only {len(self.data) - self.pos} left.")
49
+
50
+ val = self.data[self.pos:self.pos + length]
51
+ self.pos += length
52
+
53
+ return tag, val
54
+
55
+
56
+ def _decode_string(tag: int, val: bytes) -> str:
57
+
58
+ if tag == 0x1E: # BMPString (Microsoft AD CS often uses this)
59
+ return val.decode('utf-16-be')
60
+ return val.decode('utf-8', errors='ignore')
61
+
62
+
63
+ def _parse_name(name_der: bytes) -> str:
64
+ ''' Extracts the X.509 distinguished name (DN) into a standard format '''
65
+
66
+ parts = []
67
+ outer = DerReader(name_der)
68
+ while not outer.is_empty():
69
+ _, set_val = outer.read_tlv()
70
+ set_r = DerReader(set_val)
71
+ while not set_r.is_empty():
72
+ seq_r = DerReader(set_r.read_tlv()[1])
73
+ oid = seq_r.read_tlv()[1]
74
+ str_tag, str_val = seq_r.read_tlv()
75
+
76
+ label = {
77
+ b'\x55\x04\x03': 'CN',
78
+ b'\x55\x04\x06': 'C',
79
+ b'\x55\x04\x0a': 'O',
80
+ b'\x55\x04\x0b': 'OU'
81
+ }.get(oid, 'Unknown')
82
+
83
+ parts.append(f'{label}={_decode_string(str_tag, str_val)}')
84
+
85
+ parts.reverse() # Reverse to match RFC4514 representation
86
+ return ','.join(parts)
87
+
88
+
89
+
90
+ def _parse_time(tag: int, val: bytes) -> int:
91
+
92
+ s = val.decode('ascii')
93
+ if tag == 0x17: # UTCTime (YYMMDDHHMMSSZ)
94
+ y = int(s[:2])
95
+ y += 2000 if y < 50 else 1900
96
+ s = f'{y}{s[2:]}'
97
+
98
+ # GeneralizedTime is already YYYYMMDDHHMMSSZ
99
+ dt = datetime.strptime(s[:14], '%Y%m%d%H%M%S')
100
+
101
+ return int(dt.replace(tzinfo=timezone.utc).timestamp())
102
+
103
+
104
+ def parse_certificate(der_bytes: bytes) -> dict:
105
+ ''' Parses an X.509 DER certificate to extract B-Trust metadata '''
106
+
107
+ cert = DerReader(der_bytes).read_seq()
108
+ tbs = cert.read_seq()
109
+
110
+ # Version (Optional [0])
111
+ tag, val = tbs.read_tlv()
112
+ if tag == 0xA0:
113
+ tag, val = tbs.read_tlv() # Move to Serial
114
+
115
+ # Serial Number
116
+ serial = str(int.from_bytes(val, 'big'))
117
+
118
+ # Signature Algorithm (Skip)
119
+ tbs.read_tlv()
120
+
121
+ # Issuer Name
122
+ _, issuer_val = tbs.read_tlv()
123
+ issuer = _parse_name(issuer_val)
124
+
125
+ # Validity Timestamps
126
+ validity = DerReader(tbs.read_tlv()[1])
127
+ not_before = _parse_time(*validity.read_tlv())
128
+ not_after = _parse_time(*validity.read_tlv())
129
+
130
+ # Subject Name
131
+ _, subject_val = tbs.read_tlv()
132
+ subject = _parse_name(subject_val)
133
+
134
+ # SubjectPublicKeyInfo (SPKI) -> RSA Modulus and Exponent
135
+ _, spki_val = tbs.read_tlv()
136
+ spki = DerReader(spki_val)
137
+ spki.read_tlv() # Skip AlgorithmIdentifier
138
+ bit_string = spki.read_tlv()[1]
139
+
140
+ pkcs1 = DerReader(bit_string[1:]).read_seq()
141
+ n = int.from_bytes(pkcs1.read_tlv()[1], 'big')
142
+ e = int.from_bytes(pkcs1.read_tlv()[1], 'big')
143
+
144
+ # Extract Key Usages and AKI from Extensions
145
+ usages, aki = [], ''
146
+ while not tbs.is_empty():
147
+ ext_tag, ext_val = tbs.read_tlv()
148
+ if ext_tag == 0xA3: # Extensions [3]
149
+ ext_seq = DerReader(ext_val).read_seq()
150
+ while not ext_seq.is_empty():
151
+ ext = DerReader(ext_seq.read_tlv()[1])
152
+ oid = ext.read_tlv()[1]
153
+
154
+ e_tag, e_val = ext.read_tlv()
155
+ if e_tag == 0x01: # Skip Boolean CRITICAL flag
156
+ e_tag, e_val = ext.read_tlv()
157
+
158
+ if oid == b'\x55\x1d\x0f': # Key Usage
159
+ bits = DerReader(e_val).read_tlv()[1]
160
+ if len(bits) > 1:
161
+ b = bits[1]
162
+ if b & 0x80: usages.append('digitalSignature')
163
+ if b & 0x40: usages.append('nonRepudiation')
164
+ if b & 0x20: usages.append('keyEncipherment')
165
+
166
+ elif oid == b'\x55\x1d\x23': # Authority Key Identifier (AKI)
167
+ inner = DerReader(e_val).read_seq()
168
+ if not inner.is_empty():
169
+ a_tag, a_val = inner.read_tlv()
170
+ if a_tag == 0x80: # [0] KeyIdentifier
171
+ aki = a_val.hex().upper()
172
+
173
+ return {
174
+ 'serial': serial, 'issuer': issuer, 'subject': subject,
175
+ 'not_before': not_before, 'not_after': not_after,
176
+ 'key_usages': usages, 'aki': aki, 'n': n, 'e': e
177
+ }
178
+
179
+
180
+ def get_x509_subject(der_bytes: bytes) -> str:
181
+ subject_str = parse_certificate(der_bytes)['subject']
182
+ for part in subject_str.split(','):
183
+ if part.startswith('CN='): return part[3:]
184
+ return 'Unknown Subject'
185
+
186
+
187
+ def get_x509_metadata(der_bytes: bytes) -> dict:
188
+ return parse_certificate(der_bytes)
189
+
190
+
191
+ def get_rsa_public_key(der_bytes: bytes) -> tuple[int, int]:
192
+ cert = parse_certificate(der_bytes)
193
+ return cert['n'], cert['e']
@@ -0,0 +1,68 @@
1
+ import os, sys, configparser
2
+ from pathlib import Path
3
+
4
+
5
+ class ConfigManager:
6
+ ''' Manages persistent user settings in a local .ini file '''
7
+
8
+ def __init__(self, app_name='PyBISS', config_file_name='settings.ini'):
9
+ self.config_dir = self._get_config_dir(app_name)
10
+ self.config_file = self.config_dir / config_file_name
11
+
12
+ self.parser = configparser.ConfigParser()
13
+ # Prevent configparser from converting keys to lowercase
14
+ self.parser.optionxform = str
15
+
16
+ # Define the baseline defaults
17
+ self.parser['Settings'] = {
18
+ 'signAPI': 'PKCS11',
19
+ 'pkcs11Path': '',
20
+ 'pfxPath': '',
21
+ 'language': 'en',
22
+ 'osStarted': 'True',
23
+ 'newSacRequired': 'True'
24
+ }
25
+
26
+ self.load()
27
+
28
+ def _get_config_dir(self, app_name: str) -> Path:
29
+ if os.name == 'nt':
30
+ base = os.getenv('APPDATA', str(Path.home() / 'AppData' / 'Roaming'))
31
+ return Path(base) / app_name
32
+ elif sys.platform == 'darwin':
33
+ return Path.home() / 'Library' / 'Application Support' / app_name
34
+ else:
35
+ base = os.getenv('XDG_CONFIG_HOME', str(Path.home() / '.config'))
36
+ return Path(base) / app_name
37
+
38
+ def load(self):
39
+ ''' Reads the .ini file. Missing keys will fall back to the defaults above '''
40
+
41
+ if self.config_file.exists():
42
+ self.parser.read(self.config_file, encoding='utf-8')
43
+ else:
44
+ self.save()
45
+
46
+
47
+ def save(self):
48
+ ''' Writes the current state to the .ini file '''
49
+
50
+ self.config_dir.mkdir(parents=True, exist_ok=True)
51
+ try:
52
+ with open(self.config_file, 'w', encoding='utf-8') as f:
53
+ self.parser.write(f)
54
+ except Exception as e:
55
+ print(f'[!] Error saving config: {e}')
56
+
57
+ def get(self, key: str, fallback=None) -> str:
58
+ return self.parser.get('Settings', key, fallback=fallback)
59
+
60
+ def get_bool(self, key: str, fallback=False) -> bool:
61
+ return self.parser.getboolean('Settings', key, fallback=fallback)
62
+
63
+ def set(self, key: str, value):
64
+ self.parser.set('Settings', key, str(value))
65
+ self.save()
66
+
67
+
68
+ config = ConfigManager()