tiny-ca 0.1.0__py3-none-any.whl

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,530 @@
1
+ """
2
+ serial_generator.py
3
+
4
+ X.509 certificate serial number generation and parsing utilities.
5
+
6
+ Two implementations are provided:
7
+
8
+ - ``SerialGenerator`` — stateful generator that maintains an internal
9
+ mapping between serial numbers and their source
10
+ identifiers (int or str). Suitable when an
11
+ authoritative in-process registry is acceptable.
12
+
13
+ - ``SerialWithEncoding`` — stateless generator that encodes a short name
14
+ prefix and a random UUID fragment into a single
15
+ integer. No shared state; safe for concurrent use.
16
+
17
+ SOLID compliance
18
+ ----------------
19
+ SRP : Each class has a single reason to change.
20
+ ``SerialGenerator`` → manages serial ↔ id mapping.
21
+ ``SerialWithEncoding`` → encodes/decodes name + random bits.
22
+ ``_PrefixRegistry`` → owns the CertType ↔ hex-prefix mapping.
23
+ OCP : New ``CertType`` variants require only a new entry in ``_PrefixRegistry``;
24
+ no existing logic changes.
25
+ LSP : Both generators expose compatible ``generate`` / ``parse`` signatures,
26
+ so they can be swapped behind an ``ISerialGenerator`` Protocol.
27
+ ISP : ``ISerialGenerator`` declares only the two methods callers actually need.
28
+ DIP : ``CertificateFactory`` (and other consumers) depend on ``ISerialGenerator``,
29
+ not on concrete classes.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from typing import Protocol, runtime_checkable
35
+ import uuid
36
+
37
+ from tiny_ca.const import CertType
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # ISP / DIP: minimal interface that callers depend on
41
+ # ---------------------------------------------------------------------------
42
+
43
+
44
+ @runtime_checkable
45
+ class ISerialGenerator(Protocol):
46
+ """
47
+ Minimal contract for serial-number generators.
48
+
49
+ Any class that exposes ``generate`` and ``parse`` with compatible
50
+ signatures satisfies this Protocol and can be injected wherever
51
+ certificate serial numbers are needed.
52
+ """
53
+
54
+ @staticmethod
55
+ def generate(name: str, serial_type: CertType) -> int:
56
+ """
57
+ Generate a unique serial number for the given name and certificate type.
58
+
59
+ Parameters
60
+ ----------
61
+ name : str
62
+ Human-readable identifier (e.g. CN or service name).
63
+ serial_type : CertType
64
+ Category of the certificate (CA, USER, SERVICE, …).
65
+
66
+ Returns
67
+ -------
68
+ int
69
+ A non-negative integer suitable for use as an X.509 serial number.
70
+ """
71
+ ...
72
+
73
+ @staticmethod
74
+ def parse(serial: int) -> tuple[CertType, str]:
75
+ """
76
+ Decode a previously generated serial number.
77
+
78
+ Parameters
79
+ ----------
80
+ serial : int
81
+ Integer serial number produced by ``generate``.
82
+
83
+ Returns
84
+ -------
85
+ tuple[CertType, str]
86
+ ``(cert_type, name)`` recovered from the serial.
87
+ """
88
+ ...
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # OCP: centralised prefix registry — add new types here only
93
+ # ---------------------------------------------------------------------------
94
+
95
+
96
+ class _PrefixRegistry:
97
+ """
98
+ Bidirectional mapping between :class:`CertType` and 2-byte hex prefixes.
99
+
100
+ The prefixes are chosen to be human-readable in hex dumps:
101
+
102
+ ========== ======= ========
103
+ CertType Prefix ASCII
104
+ ========== ======= ========
105
+ CA 0x4341 "CA"
106
+ USER 0x5553 "US"
107
+ SERVICE 0x5356 "SV"
108
+ DEVICE 0x4445 "DE"
109
+ INTERNAL 0x494E "IN"
110
+ ========== ======= ========
111
+
112
+ Class is intentionally non-instantiable (all attributes are class-level).
113
+ """
114
+
115
+ #: ``CertType`` → 2-byte integer prefix (big-endian ASCII representation).
116
+ TYPE_TO_PREFIX: dict[CertType, int] = {
117
+ CertType.CA: 0x4341, # "CA"
118
+ CertType.USER: 0x5553, # "US"
119
+ CertType.SERVICE: 0x5356, # "SV"
120
+ CertType.DEVICE: 0x4445, # "DE"
121
+ CertType.INTERNAL: 0x494E, # "IN"
122
+ }
123
+
124
+ #: Reverse mapping: 2-byte prefix → ``CertType``.
125
+ PREFIX_TO_TYPE: dict[int, CertType] = {v: k for k, v in TYPE_TO_PREFIX.items()}
126
+
127
+ def __init_subclass__(cls, **kwargs: object) -> None: # pragma: no cover
128
+ raise TypeError("_PrefixRegistry is not meant to be subclassed.")
129
+
130
+ @classmethod
131
+ def prefix_for(cls, cert_type: CertType) -> int:
132
+ """
133
+ Return the numeric prefix for *cert_type*.
134
+
135
+ Parameters
136
+ ----------
137
+ cert_type : CertType
138
+ The certificate category to look up.
139
+
140
+ Returns
141
+ -------
142
+ int
143
+ 2-byte integer prefix.
144
+
145
+ Raises
146
+ ------
147
+ KeyError
148
+ If *cert_type* has no registered prefix.
149
+ """
150
+ try:
151
+ return cls.TYPE_TO_PREFIX[cert_type]
152
+ except KeyError:
153
+ raise KeyError(
154
+ f"No prefix registered for CertType.{cert_type.name}. "
155
+ "Add it to _PrefixRegistry.TYPE_TO_PREFIX."
156
+ ) from None
157
+
158
+ @classmethod
159
+ def type_for(cls, prefix: int) -> CertType | None:
160
+ """
161
+ Return the :class:`CertType` associated with *prefix*, or ``None``.
162
+
163
+ Parameters
164
+ ----------
165
+ prefix : int
166
+ 2-byte integer prefix extracted from a serial number.
167
+
168
+ Returns
169
+ -------
170
+ CertType | None
171
+ Matching certificate type, or ``None`` if the prefix is unknown.
172
+ """
173
+ return cls.PREFIX_TO_TYPE.get(prefix)
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # SRP: stateful serial generator with id ↔ serial mapping
178
+ # ---------------------------------------------------------------------------
179
+
180
+
181
+ class SerialGenerator:
182
+ """
183
+ Stateful serial-number generator with a bidirectional id ↔ serial registry.
184
+
185
+ Serial number layout (64-bit integer)
186
+ ::
187
+
188
+ [ 16-bit prefix ][ 48-bit data ]
189
+
190
+ - **prefix** — 2-byte ASCII code derived from :class:`CertType`
191
+ (see :class:`_PrefixRegistry`).
192
+ - **data** — for ``int`` inputs: the value itself (truncated to 48 bits);
193
+ for ``str`` inputs: an auto-incrementing counter per type.
194
+
195
+ The mapping between serial numbers and their original identifiers is kept
196
+ in instance-level dictionaries, making this class unsuitable for concurrent
197
+ multi-process use without external synchronisation.
198
+
199
+ Attributes
200
+ ----------
201
+ _id_map : dict[int, int | str]
202
+ Maps ``serial`` → original identifier (int or str).
203
+ _name_map : dict[str, int]
204
+ Maps ``"<cert_type_value>_<name>"`` → serial; populated for string ids.
205
+ _last_serial : dict[CertType, int]
206
+ Per-type auto-increment counter for string-based identifiers.
207
+ """
208
+
209
+ #: Bit-width reserved for the data portion of the serial.
210
+ _DATA_BITS: int = 48
211
+
212
+ #: Bitmask for the data portion: ``(1 << 48) - 1``.
213
+ _DATA_MASK: int = (1 << _DATA_BITS) - 1
214
+
215
+ def __init__(self) -> None:
216
+ self._id_map: dict[int, int | str] = {}
217
+ self._name_map: dict[str, int] = {}
218
+ self._last_serial: dict[CertType, int] = {}
219
+
220
+ # ------------------------------------------------------------------
221
+ # ISerialGenerator interface
222
+ # ------------------------------------------------------------------
223
+
224
+ def generate(self, id_value: int | str, serial_type: CertType) -> int:
225
+ """
226
+ Generate a serial number for *id_value* and register the mapping.
227
+
228
+ For **integer** inputs the value is embedded directly into the data
229
+ field (truncated to 48 bits). For **string** inputs a per-type
230
+ auto-increment counter is used and the original string is stored in
231
+ ``_id_map`` for later retrieval by :meth:`parse`.
232
+
233
+ Parameters
234
+ ----------
235
+ id_value : int | str
236
+ Source identifier. Integers are encoded directly; strings trigger
237
+ the auto-increment path.
238
+ serial_type : CertType
239
+ Certificate category; determines the 2-byte prefix.
240
+
241
+ Returns
242
+ -------
243
+ int
244
+ 64-bit serial number: ``(prefix << 48) | data``.
245
+
246
+ Raises
247
+ ------
248
+ KeyError
249
+ If *serial_type* has no registered prefix.
250
+ """
251
+ prefix = _PrefixRegistry.prefix_for(serial_type)
252
+
253
+ if isinstance(id_value, int):
254
+ data = id_value & self._DATA_MASK
255
+ serial = (prefix << self._DATA_BITS) | data
256
+ self._id_map[serial] = id_value
257
+ else:
258
+ counter = self._last_serial.get(serial_type, 1)
259
+ data = counter & self._DATA_MASK
260
+ serial = (prefix << self._DATA_BITS) | data
261
+
262
+ self._id_map[serial] = id_value
263
+ self._name_map[f"{serial_type.value}_{id_value}"] = serial
264
+ self._last_serial[serial_type] = counter + 1
265
+
266
+ return serial
267
+
268
+ def parse(self, serial: int) -> tuple[CertType | None, int | str | None]:
269
+ """
270
+ Decode a serial number back to its type and original identifier.
271
+
272
+ For ``CertType.USER`` the numeric data portion is returned directly.
273
+ For all other types the original identifier is looked up from the
274
+ internal registry; ``None`` is returned if not found.
275
+
276
+ Parameters
277
+ ----------
278
+ serial : int
279
+ 64-bit serial number previously produced by :meth:`generate`.
280
+
281
+ Returns
282
+ -------
283
+ tuple[CertType | None, int | str | None]
284
+ ``(cert_type, original_id)``. Both members may be ``None`` if
285
+ the serial was not generated by this instance.
286
+ """
287
+ prefix = serial >> self._DATA_BITS
288
+ data = serial & self._DATA_MASK
289
+ cert_type = _PrefixRegistry.type_for(prefix)
290
+
291
+ if cert_type == CertType.USER:
292
+ return cert_type, data
293
+
294
+ return cert_type, self._id_map.get(serial)
295
+
296
+ def get_serial_by_name(self, name: str, serial_type: CertType) -> int | None:
297
+ """
298
+ Look up the serial number previously assigned to a string identifier.
299
+
300
+ Parameters
301
+ ----------
302
+ name : str
303
+ Original string identifier passed to :meth:`generate`.
304
+ serial_type : CertType
305
+ Certificate type under which the name was registered.
306
+
307
+ Returns
308
+ -------
309
+ int | None
310
+ Registered serial number, or ``None`` if not found.
311
+ """
312
+ return self._name_map.get(f"{serial_type.value}_{name}")
313
+
314
+
315
+ # ---------------------------------------------------------------------------
316
+ # SRP: stateless serial generator with name encoding + UUID randomness
317
+ # ---------------------------------------------------------------------------
318
+
319
+
320
+ class SerialWithEncoding:
321
+ """
322
+ Stateless serial-number generator that encodes a short name prefix and
323
+ a UUID-derived random fragment into a single integer.
324
+
325
+ Serial number layout
326
+ ::
327
+
328
+ [ 16-bit prefix ][ 80-bit encoded name ][ 64-bit random ]
329
+
330
+ Total width: **160 bits** (well within Python's arbitrary-precision int;
331
+ X.509 allows up to 20 bytes / 160 bits per RFC 5280 §4.1.2.2).
332
+
333
+ - **prefix** — 2-byte ASCII code from :class:`_PrefixRegistry`.
334
+ - **encoded name** — up to 4 ASCII characters packed into 32 bits
335
+ (little-endian byte order, zero-padded).
336
+ - **random** — lower 64 bits of a fresh ``uuid.uuid4()`` ensuring
337
+ global uniqueness without shared state.
338
+
339
+ Because no mutable state is kept, this class is safe to use from multiple
340
+ threads or processes simultaneously.
341
+
342
+ Class Attributes
343
+ ----------------
344
+ RANDOM_BITS : int
345
+ Number of bits reserved for the random (UUID) portion. Default: ``64``.
346
+ NAME_BITS : int
347
+ Number of bits reserved for the encoded name portion. Default: ``32``
348
+ (4 bytes × 8 bits).
349
+ MAX_NAME_LENGTH : int
350
+ Maximum number of ASCII characters that can be encoded. Default: ``4``.
351
+ """
352
+
353
+ #: Bit-width of the random (UUID) segment.
354
+ RANDOM_BITS: int = 64
355
+
356
+ #: Bit-width of the encoded-name segment (10 ASCII chars × 8 bits).
357
+ NAME_BITS: int = 80
358
+
359
+ #: Maximum number of characters accepted by :meth:`_encode_name`.
360
+ MAX_NAME_LENGTH: int = 10
361
+
362
+ # ------------------------------------------------------------------
363
+ # ISerialGenerator interface
364
+ # ------------------------------------------------------------------
365
+
366
+ @classmethod
367
+ def generate(cls, name: str, serial_type: CertType) -> int:
368
+ """
369
+ Generate a globally unique serial number for *name* and *serial_type*.
370
+
371
+ Only the first :attr:`MAX_NAME_LENGTH` characters of *name* are encoded;
372
+ uniqueness is guaranteed by the UUID random segment, not by the name.
373
+
374
+ Parameters
375
+ ----------
376
+ name : str
377
+ Human-readable identifier. Only the first 4 ASCII characters are
378
+ embedded; the remainder is ignored (not hashed or truncated with loss).
379
+ serial_type : CertType
380
+ Certificate category; determines the 2-byte prefix.
381
+
382
+ Returns
383
+ -------
384
+ int
385
+ Non-negative integer serial suitable for X.509 certificates.
386
+
387
+ Raises
388
+ ------
389
+ KeyError
390
+ If *serial_type* has no registered prefix.
391
+
392
+ Examples
393
+ --------
394
+ >>> serial = SerialWithEncoding.generate("nginx", CertType.SERVICE)
395
+ >>> cert_type, name = SerialWithEncoding.parse(serial)
396
+ >>> assert cert_type == CertType.SERVICE
397
+ >>> assert name == "ngin" # only first 4 chars are stored
398
+ """
399
+ prefix: int = _PrefixRegistry.prefix_for(serial_type)
400
+ encoded_name: int = cls._encode_name(name[: cls.MAX_NAME_LENGTH])
401
+ random_part: int = uuid.uuid4().int & cls._random_mask()
402
+
403
+ serial: int = (
404
+ (prefix << (cls.NAME_BITS + cls.RANDOM_BITS))
405
+ | (encoded_name << cls.RANDOM_BITS)
406
+ | random_part
407
+ )
408
+ return serial
409
+
410
+ @classmethod
411
+ def parse(cls, serial: int) -> tuple[CertType | None, str]:
412
+ """
413
+ Decode a serial number produced by :meth:`generate`.
414
+
415
+ Parameters
416
+ ----------
417
+ serial : int
418
+ Integer serial number to decode.
419
+
420
+ Returns
421
+ -------
422
+ tuple[CertType | None, str]
423
+ ``(cert_type, name_prefix)`` where *name_prefix* is the up-to-4-char
424
+ string recovered from the encoded-name segment.
425
+ *cert_type* is ``None`` if the prefix is unrecognised.
426
+
427
+ Examples
428
+ --------
429
+ >>> serial = SerialWithEncoding.generate("ca-root", CertType.CA)
430
+ >>> cert_type, name = SerialWithEncoding.parse(serial)
431
+ >>> assert cert_type == CertType.CA
432
+ >>> assert name == "ca-r"
433
+ """
434
+ random_part: int = serial & cls._random_mask() # noqa: F841 — kept for clarity
435
+ encoded_name: int = (serial >> cls.RANDOM_BITS) & cls._name_mask()
436
+ prefix: int = serial >> (cls.RANDOM_BITS + cls.NAME_BITS)
437
+
438
+ cert_type: CertType | None = _PrefixRegistry.type_for(prefix)
439
+ name: str = cls._decode_name(encoded_name, cls.MAX_NAME_LENGTH)
440
+
441
+ return cert_type, name
442
+
443
+ # ------------------------------------------------------------------
444
+ # Private encoding helpers
445
+ # ------------------------------------------------------------------
446
+
447
+ @classmethod
448
+ def _encode_name(cls, text: str) -> int:
449
+ """
450
+ Pack up to :attr:`MAX_NAME_LENGTH` ASCII characters into a single integer.
451
+
452
+ Characters are stored in **little-endian** byte order:
453
+ ``result |= ord(char) << (8 * position)``.
454
+
455
+ Parameters
456
+ ----------
457
+ text : str
458
+ ASCII string of length ≤ :attr:`MAX_NAME_LENGTH`.
459
+
460
+ Returns
461
+ -------
462
+ int
463
+ Non-negative integer with characters packed into the lower bytes.
464
+
465
+ Raises
466
+ ------
467
+ ValueError
468
+ If *text* is longer than :attr:`MAX_NAME_LENGTH`.
469
+ """
470
+ if len(text) > cls.MAX_NAME_LENGTH:
471
+ raise ValueError(
472
+ f"Name too long: {len(text)} chars > {cls.MAX_NAME_LENGTH} max. "
473
+ f"Truncate before calling or increase MAX_NAME_LENGTH."
474
+ )
475
+ result: int = 0
476
+ for position, char in enumerate(text):
477
+ result |= ord(char) << (8 * position)
478
+ return result
479
+
480
+ @classmethod
481
+ def _decode_name(cls, value: int, length: int) -> str:
482
+ """
483
+ Unpack a little-endian-encoded integer back into an ASCII string.
484
+
485
+ Null bytes (``0x00``) terminate the string early, so zero-padding
486
+ introduced by :meth:`_encode_name` is stripped automatically.
487
+
488
+ Parameters
489
+ ----------
490
+ value : int
491
+ Integer produced by :meth:`_encode_name`.
492
+ length : int
493
+ Maximum number of characters to attempt to decode.
494
+
495
+ Returns
496
+ -------
497
+ str
498
+ Decoded ASCII string with trailing null characters removed.
499
+ """
500
+ chars: list[str] = []
501
+ for position in range(length):
502
+ byte: int = (value >> (8 * position)) & 0xFF
503
+ if byte == 0:
504
+ break
505
+ chars.append(chr(byte))
506
+ return "".join(chars)
507
+
508
+ @classmethod
509
+ def _random_mask(cls) -> int:
510
+ """
511
+ Bitmask for the random segment: ``(1 << RANDOM_BITS) - 1``.
512
+
513
+ Returns
514
+ -------
515
+ int
516
+ Integer with the lower :attr:`RANDOM_BITS` set to 1.
517
+ """
518
+ return (1 << cls.RANDOM_BITS) - 1
519
+
520
+ @classmethod
521
+ def _name_mask(cls) -> int:
522
+ """
523
+ Bitmask for the name segment: ``(1 << NAME_BITS) - 1``.
524
+
525
+ Returns
526
+ -------
527
+ int
528
+ Integer with the lower :attr:`NAME_BITS` set to 1.
529
+ """
530
+ return (1 << cls.NAME_BITS) - 1