sep2cert 0.1.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.
sep2cert-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alex Guinman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,63 @@
1
+ Metadata-Version: 2.4
2
+ Name: sep2cert
3
+ Version: 0.1.0
4
+ Summary: Useful functions for working with IEEE 2030.5 (SEP2)
5
+ Author-email: Alex Guinman <alex@guinman.id.au>
6
+ Requires-Python: >=3.12
7
+ Description-Content-Type: text/markdown
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.14
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Operating System :: OS Independent
14
+ License-File: LICENSE
15
+ Requires-Dist: asn1==2.8.0
16
+ Requires-Dist: cryptography>=44.0.1
17
+ Requires-Dist: python-dateutil>=2.8.2
18
+ Requires-Dist: typer>=0.15.1
19
+ Requires-Dist: pytest>=2.7.3 ; extra == "test"
20
+ Requires-Dist: pytest-cov>=6.0.0 ; extra == "test"
21
+ Project-URL: Source, https://github.com/aguinane/SEP2-Certs
22
+ Provides-Extra: test
23
+
24
+ # SEP2-Certs
25
+
26
+ Create and verify IEEE 2030.5 (SEP2) PKI certificates
27
+
28
+ ## Certificate Inspection
29
+
30
+ Get the LFDI for a certificate. It will also do some validation checks.
31
+
32
+ ```sh
33
+ python -m sep2cert cert-lfdi certs/dev-ABC-cert.pem
34
+ ```
35
+
36
+ ## Certificate Creation
37
+
38
+ Note the below CLI commands are only intended for testing purposes.
39
+ For production certificates, additional functions and policies may be required.
40
+
41
+ ### Signing Certificates
42
+
43
+ Create a SERCA, and a MICA.
44
+
45
+ ```sh
46
+ python -m sep2cert create-serca
47
+ python -m sep2cert create-mica certs/serca.pem certs/serca.key
48
+ ```
49
+
50
+ ### Device Certificates
51
+
52
+ To create a device certificate, first create a Key and Certificate Signing Request (CSR).
53
+
54
+ ```sh
55
+ python -m sep2cert create-key --key-file certs/dev-ABC.key
56
+ ```
57
+
58
+ Once you have the CSR, build the cert by signing with the MICA.
59
+
60
+ ```sh
61
+ python -m sep2cert create-cert certs/dev-ABC.csr certs/mica.pem certs/mica.key --pen 12345 --serno ABC
62
+ ```
63
+
@@ -0,0 +1,39 @@
1
+ # SEP2-Certs
2
+
3
+ Create and verify IEEE 2030.5 (SEP2) PKI certificates
4
+
5
+ ## Certificate Inspection
6
+
7
+ Get the LFDI for a certificate. It will also do some validation checks.
8
+
9
+ ```sh
10
+ python -m sep2cert cert-lfdi certs/dev-ABC-cert.pem
11
+ ```
12
+
13
+ ## Certificate Creation
14
+
15
+ Note the below CLI commands are only intended for testing purposes.
16
+ For production certificates, additional functions and policies may be required.
17
+
18
+ ### Signing Certificates
19
+
20
+ Create a SERCA, and a MICA.
21
+
22
+ ```sh
23
+ python -m sep2cert create-serca
24
+ python -m sep2cert create-mica certs/serca.pem certs/serca.key
25
+ ```
26
+
27
+ ### Device Certificates
28
+
29
+ To create a device certificate, first create a Key and Certificate Signing Request (CSR).
30
+
31
+ ```sh
32
+ python -m sep2cert create-key --key-file certs/dev-ABC.key
33
+ ```
34
+
35
+ Once you have the CSR, build the cert by signing with the MICA.
36
+
37
+ ```sh
38
+ python -m sep2cert create-cert certs/dev-ABC.csr certs/mica.pem certs/mica.key --pen 12345 --serno ABC
39
+ ```
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["flit_core >=3.2,<4"]
3
+ build-backend = "flit_core.buildapi"
4
+
5
+ [project]
6
+ name = "sep2cert"
7
+ authors = [{ name = "Alex Guinman", email = "alex@guinman.id.au" }]
8
+ readme = "README.md"
9
+ license = { file = "LICENSE" }
10
+ classifiers = [
11
+ "License :: OSI Approved :: MIT License",
12
+ "Programming Language :: Python :: 3",
13
+ "Programming Language :: Python :: 3.14",
14
+ "Programming Language :: Python :: 3.13",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Operating System :: OS Independent",
17
+ ]
18
+ requires-python = ">=3.12"
19
+ dynamic = ["version", "description"]
20
+ dependencies = [
21
+ "asn1==2.8.0",
22
+ "cryptography>=44.0.1",
23
+ "python-dateutil>=2.8.2",
24
+ "typer>=0.15.1",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ test = ["pytest>=2.7.3", "pytest-cov>=6.0.0"]
29
+
30
+ [project.scripts]
31
+ sep2cert = "sep2cert.cli:app"
32
+
33
+ [project.urls]
34
+ Source = "https://github.com/aguinane/SEP2-Certs"
35
+
36
+ [tool.pytest.ini_options]
37
+ addopts = "-ra --failed-first --showlocals --durations=3 --cov=sep2cert"
38
+
39
+ [tool.coverage.run]
40
+ omit = ["*/version.py", '*/__main__.py']
41
+
42
+ [tool.coverage.report]
43
+ show_missing = true
44
+ skip_empty = true
45
+ fail_under = 75
46
+
47
+ [tool.ruff.lint]
48
+ select = ["A", "B", "E", "F", "I", "N", "PERF", "RUF", "SIM", "UP"]
@@ -0,0 +1,9 @@
1
+ """Useful functions for working with IEEE 2030.5 (SEP2)"""
2
+
3
+ from .cert_id import get_certificate_lfdi
4
+ from .version import __version__
5
+
6
+ __all__ = [
7
+ "__version__",
8
+ "get_certificate_lfdi",
9
+ ]
@@ -0,0 +1,3 @@
1
+ from .cli import app
2
+
3
+ app()
@@ -0,0 +1,459 @@
1
+ import logging
2
+ import uuid
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+
6
+ import asn1
7
+ from cryptography import x509
8
+ from cryptography.hazmat.backends import default_backend
9
+ from cryptography.hazmat.primitives import hashes, serialization
10
+ from cryptography.hazmat.primitives.asymmetric import ec
11
+ from cryptography.hazmat.primitives.hashes import SHA256
12
+ from cryptography.x509.oid import ExtensionOID, NameOID, ObjectIdentifier
13
+ from dateutil import tz
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+ DEFAULT_OUTPUT = Path("certs")
18
+
19
+ INDEF_EXPIRY = datetime(9999, 12, 31, 23, 59, 59, 0) # As per standard
20
+
21
+ ANY_POLICY_OID = ObjectIdentifier("2.5.29.32.0") # OID for "X509v3 Any Policy"
22
+
23
+ # IEEE 2030.5 device type assignments (Section 6.11.7.2)
24
+ SEP2_DEV_GENERIC = ObjectIdentifier("1.3.6.1.4.1.40732.1.1")
25
+ SEP2_DEV_MOBILE = ObjectIdentifier("1.3.6.1.4.1.40732.1.2")
26
+ SEP2_DEV_POSTMANUF = ObjectIdentifier("1.3.6.1.4.1.40732.1.3")
27
+
28
+ # IEEE 2030.5 policy assignments (Section 6.11.7.3)
29
+ SEP2_TEST_CERT = ObjectIdentifier("1.3.6.1.4.1.40732.2.1")
30
+ SEP2_SELFSIGNED = ObjectIdentifier("1.3.6.1.4.1.40732.2.2")
31
+ SEP2_SERVPROV = ObjectIdentifier("1.3.6.1.4.1.40732.2.3")
32
+ SEP2_BULK_CERT = ObjectIdentifier("1.3.6.1.4.1.40732.2.4")
33
+
34
+ # HardwareModuleName (Section 6.11.7.4)
35
+ SEP2_HARDWARE_MODULE_NAME = ObjectIdentifier("1.3.6.1.5.5.7.8.4")
36
+
37
+
38
+ DEFAULT_MICA_POLICIES = [
39
+ SEP2_DEV_GENERIC,
40
+ SEP2_TEST_CERT,
41
+ ]
42
+
43
+
44
+ DEFAULT_DEV_POLICIES = [
45
+ SEP2_DEV_GENERIC,
46
+ SEP2_TEST_CERT,
47
+ SEP2_SELFSIGNED,
48
+ SEP2_BULK_CERT,
49
+ ]
50
+
51
+
52
+ def random_id() -> str:
53
+ return str(uuid.uuid4()).replace("-", "")[:10].upper()
54
+
55
+
56
+ def generate_csr_file(key_file: Path, hostnames: list[str] | None = None) -> Path:
57
+ # Load the private key
58
+ with open(key_file, "rb") as fh:
59
+ pem_data = fh.read()
60
+ key = serialization.load_pem_private_key(
61
+ pem_data, password=None, backend=default_backend()
62
+ )
63
+
64
+ # CSR output file path
65
+ csr_file = key_file.with_suffix(".csr")
66
+
67
+ # Subject Alternative Names (SANs)
68
+ san_list = []
69
+ if hostnames:
70
+ msg = "Note that SEP2 Certificates do not require a hostname"
71
+ log.warning(msg)
72
+ for name in hostnames:
73
+ san = x509.DNSName(name)
74
+ san_list.append(san)
75
+
76
+ cb = x509.CertificateSigningRequestBuilder()
77
+ subject_name = ""
78
+ cb = cb.subject_name(x509.Name(subject_name))
79
+ if hostnames:
80
+ cb = cb.add_extension(
81
+ x509.SubjectAlternativeName(san_list),
82
+ critical=False,
83
+ )
84
+
85
+ csr = cb.sign(key, SHA256())
86
+
87
+ csr_pem = csr.public_bytes(serialization.Encoding.PEM)
88
+ with open(csr_file, "wb") as fh:
89
+ fh.write(csr_pem)
90
+ log.info("Created CSR at %s", csr_file)
91
+
92
+ return csr_file
93
+
94
+
95
+ def generate_key(
96
+ key_file: Path | None = None,
97
+ generate_csr: bool = True,
98
+ hostnames: list[str] | None = None,
99
+ ) -> tuple[Path, Path | None]:
100
+ """Generate a Private Key and Certificate Signing Request (CSR)"""
101
+
102
+ if not key_file:
103
+ output_dir = DEFAULT_OUTPUT
104
+ output_dir.mkdir(exist_ok=True)
105
+ name = random_id()
106
+ key_file = output_dir / f"{name}.key"
107
+
108
+ key = ec.generate_private_key(ec.SECP256R1())
109
+ key_pem = key.private_bytes(
110
+ encoding=serialization.Encoding.PEM,
111
+ format=serialization.PrivateFormat.PKCS8,
112
+ encryption_algorithm=serialization.NoEncryption(),
113
+ )
114
+ with open(key_file, "wb") as fh:
115
+ fh.write(key_pem)
116
+ log.info("Created Key at %s", key_file)
117
+
118
+ csr_file = None
119
+ if generate_csr:
120
+ csr_file = generate_csr_file(key_file, hostnames=hostnames)
121
+
122
+ return key_file, csr_file
123
+
124
+
125
+ def convert_pem_to_der(pem_file: Path, der_file: Path | None = None) -> Path:
126
+ """Convert a PEM file to DER Certificate"""
127
+ with open(pem_file, "rb") as fh:
128
+ cert_data = fh.read()
129
+ cert = x509.load_pem_x509_certificate(cert_data)
130
+ der_data = cert.public_bytes(encoding=serialization.Encoding.DER)
131
+
132
+ if not der_file:
133
+ der_file = pem_file.with_suffix(".cer")
134
+ with open(der_file, "wb") as fh:
135
+ fh.write(der_data)
136
+ log.info("Created %s from %s", der_file, pem_file)
137
+ return der_file
138
+
139
+
140
+ def generate_serca(
141
+ key_file: Path,
142
+ cert_file: Path | None = None,
143
+ org_name: str = "Smart Energy",
144
+ country_name: str = "AU",
145
+ ) -> tuple[Path, Path]:
146
+ """Use a CSR and MICA key pair to generate a SEP2 Certificate"""
147
+
148
+ if not cert_file:
149
+ output_dir = key_file.parent
150
+ output_dir.mkdir(exist_ok=True)
151
+ if key_file.suffix == "pem":
152
+ cert_name = f"{key_file.stem}-serca.pem"
153
+ else:
154
+ cert_name = f"{key_file.stem}.pem"
155
+ cert_file = output_dir / cert_name
156
+
157
+ with open(key_file, "rb") as fh:
158
+ pem_data = fh.read()
159
+ key = serialization.load_pem_private_key(pem_data, password=None)
160
+
161
+ valid_from = datetime.now(tz=tz.UTC)
162
+ valid_to = INDEF_EXPIRY # as per standard
163
+
164
+ policies = [x509.PolicyInformation(ANY_POLICY_OID, None)]
165
+
166
+ # Define the Subject Name
167
+ subject = x509.Name(
168
+ [
169
+ x509.NameAttribute(NameOID.COUNTRY_NAME, country_name),
170
+ x509.NameAttribute(NameOID.ORGANIZATION_NAME, org_name),
171
+ x509.NameAttribute(NameOID.COMMON_NAME, "IEEE 2030.5 Root"),
172
+ x509.NameAttribute(NameOID.SERIAL_NUMBER, "1"),
173
+ ]
174
+ )
175
+
176
+ # Generate a Subject Key Identifier (SKI)
177
+ ski = key.public_key().public_bytes(
178
+ encoding=serialization.Encoding.DER,
179
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
180
+ )
181
+
182
+ ski_digest = hashes.Hash(hashes.SHA1(), backend=default_backend())
183
+ ski_digest.update(ski)
184
+ ski_value = ski_digest.finalize()
185
+
186
+ cb = x509.CertificateBuilder()
187
+ cb = cb.subject_name(subject)
188
+ cb = cb.issuer_name(subject)
189
+ cb = cb.public_key(key.public_key())
190
+ cb = cb.serial_number(x509.random_serial_number())
191
+ cb = cb.not_valid_before(valid_from)
192
+ cb = cb.not_valid_after(valid_to)
193
+ cb = cb.add_extension(
194
+ x509.BasicConstraints(ca=True, path_length=None),
195
+ critical=True,
196
+ )
197
+ cb = cb.add_extension(
198
+ x509.KeyUsage(
199
+ key_agreement=False,
200
+ key_cert_sign=True,
201
+ crl_sign=True,
202
+ digital_signature=True,
203
+ content_commitment=False,
204
+ key_encipherment=False,
205
+ data_encipherment=False,
206
+ encipher_only=False,
207
+ decipher_only=False,
208
+ ),
209
+ critical=True,
210
+ )
211
+ cb = cb.add_extension(
212
+ x509.SubjectKeyIdentifier(ski_value),
213
+ critical=False,
214
+ )
215
+ cb = cb.add_extension(
216
+ x509.CertificatePolicies(policies=policies),
217
+ critical=True,
218
+ )
219
+ cert = cb.sign(key, SHA256())
220
+
221
+ cert_pem = cert.public_bytes(encoding=serialization.Encoding.PEM)
222
+ with open(cert_file, "wb") as fh:
223
+ fh.write(cert_pem)
224
+ log.info("Created SERCA %s", cert_file)
225
+
226
+ der_file = convert_pem_to_der(cert_file)
227
+
228
+ return cert_file, der_file
229
+
230
+
231
+ def generate_mica(
232
+ csr_path: Path,
233
+ ca_cert_path: Path,
234
+ ca_key_path: Path,
235
+ cert_file: Path | None = None,
236
+ org_name: str = "Example Org Name",
237
+ country_name: str = "AU",
238
+ common_name: str = "IEEE 2030.5 MICA",
239
+ policy_oids: list[ObjectIdentifier] = DEFAULT_MICA_POLICIES,
240
+ ) -> tuple[Path, Path]:
241
+ """Use a CSR and MICA key pair to generate a SEP2 Certificate"""
242
+
243
+ with open(csr_path, "rb") as fh:
244
+ csr_data = fh.read()
245
+ csr = x509.load_pem_x509_csr(csr_data)
246
+
247
+ if not cert_file:
248
+ output_dir = csr_path.parent
249
+ output_dir.mkdir(exist_ok=True)
250
+ if csr_path.suffix == "pem":
251
+ cert_name = f"{csr_path.stem}-mica.pem"
252
+ else:
253
+ cert_name = f"{csr_path.stem}.pem"
254
+ cert_file = output_dir / cert_name
255
+
256
+ # Load certificate authority
257
+ with open(ca_cert_path, "rb") as fh:
258
+ ca_pem_data = fh.read()
259
+ ca_cert = x509.load_pem_x509_certificate(ca_pem_data)
260
+ with open(ca_key_path, "rb") as fh:
261
+ pem_data = fh.read()
262
+ ca_key = serialization.load_pem_private_key(pem_data, password=None)
263
+
264
+ valid_from = datetime.now(tz=tz.UTC)
265
+ valid_to = INDEF_EXPIRY # as per standard
266
+
267
+ policies = [x509.PolicyInformation(oid, None) for oid in policy_oids]
268
+
269
+ # Define the Subject Name
270
+ subject = x509.Name(
271
+ [
272
+ x509.NameAttribute(NameOID.COUNTRY_NAME, country_name),
273
+ x509.NameAttribute(NameOID.ORGANIZATION_NAME, org_name),
274
+ x509.NameAttribute(NameOID.COMMON_NAME, common_name),
275
+ x509.NameAttribute(NameOID.SERIAL_NUMBER, "1"),
276
+ ]
277
+ )
278
+
279
+ # Generate a Subject Key Identifier (SKI)
280
+ ski = csr.public_key().public_bytes(
281
+ encoding=serialization.Encoding.DER,
282
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
283
+ )
284
+
285
+ ski_digest = hashes.Hash(hashes.SHA1(), backend=default_backend())
286
+ ski_digest.update(ski)
287
+ ski_value = ski_digest.finalize()
288
+
289
+ issuer_name = ca_cert.subject
290
+ iname = x509.Name(issuer_name)
291
+
292
+ cb = x509.CertificateBuilder()
293
+
294
+ cb = cb.subject_name(subject)
295
+ cb = cb.issuer_name(iname)
296
+ cb = cb.public_key(csr.public_key())
297
+ cb = cb.serial_number(x509.random_serial_number())
298
+ cb = cb.not_valid_before(valid_from)
299
+ cb = cb.not_valid_after(valid_to)
300
+ cb = cb.add_extension(
301
+ x509.BasicConstraints(ca=True, path_length=0),
302
+ critical=True,
303
+ )
304
+ cb = cb.add_extension(
305
+ x509.KeyUsage(
306
+ key_agreement=False,
307
+ key_cert_sign=True,
308
+ crl_sign=False,
309
+ digital_signature=True,
310
+ content_commitment=False,
311
+ key_encipherment=False,
312
+ data_encipherment=False,
313
+ encipher_only=False,
314
+ decipher_only=False,
315
+ ),
316
+ critical=True,
317
+ )
318
+ cb = cb.add_extension(
319
+ x509.SubjectKeyIdentifier(ski_value),
320
+ critical=False,
321
+ )
322
+ cb = cb.add_extension(
323
+ x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(
324
+ ca_cert.extensions.get_extension_for_class(x509.SubjectKeyIdentifier).value
325
+ ),
326
+ critical=False,
327
+ )
328
+ cb = cb.add_extension(
329
+ x509.CertificatePolicies(policies=policies),
330
+ critical=True,
331
+ )
332
+ cert = cb.sign(ca_key, SHA256())
333
+
334
+ cert_pem = cert.public_bytes(encoding=serialization.Encoding.PEM)
335
+ with open(cert_file, "wb") as fh:
336
+ fh.write(cert_pem)
337
+ fh.write(ca_pem_data) # Append the signing certificate
338
+ log.info("Created MICA %s", cert_file)
339
+
340
+ der_file = convert_pem_to_der(cert_file)
341
+
342
+ return cert_file, der_file
343
+
344
+
345
+ def generate_device_certificate(
346
+ csr_path: Path,
347
+ ca_cert_path: Path,
348
+ ca_key_path: Path,
349
+ pen: int,
350
+ hardware_serial_number: str,
351
+ cert_file: Path | None = None,
352
+ policy_oids: list[ObjectIdentifier] = DEFAULT_DEV_POLICIES,
353
+ valid_to: datetime | None = None,
354
+ ) -> Path:
355
+ """Use a CSR and Signing Certificate key pair to generate a SEP2 Certificate"""
356
+
357
+ if not cert_file:
358
+ output_dir = csr_path.parent
359
+ cert_name = f"{csr_path.stem}-cert.pem"
360
+ cert_file = output_dir / cert_name
361
+
362
+ with open(csr_path, "rb") as fh:
363
+ csr_data = fh.read()
364
+ csr = x509.load_pem_x509_csr(csr_data)
365
+ with open(ca_cert_path, "rb") as fh:
366
+ ca_pem_data = fh.read()
367
+ ca_cert = x509.load_pem_x509_certificate(ca_pem_data)
368
+ with open(ca_key_path, "rb") as fh:
369
+ pem_data = fh.read()
370
+ ca_key = serialization.load_pem_private_key(pem_data, password=None)
371
+ valid_from = datetime.now(tz=tz.UTC)
372
+
373
+ if valid_to and valid_to.year != 9999:
374
+ log.warning("Using a valid to year other than 9999 is a deviation from SEP2")
375
+ else:
376
+ valid_to = INDEF_EXPIRY # as per standard
377
+
378
+ policies = [x509.PolicyInformation(oid, None) for oid in policy_oids]
379
+
380
+ hardware_type_oid = f"1.3.6.1.4.1.40732.{pen}.1"
381
+
382
+ # SAN OtherName encoder
383
+ encoder = asn1.Encoder()
384
+ encoder.start()
385
+ encoder.enter(asn1.Numbers.Sequence)
386
+ encoder.write(str(hardware_type_oid), asn1.Numbers.ObjectIdentifier)
387
+ encoder.write(str(hardware_serial_number), asn1.Numbers.OctetString)
388
+ encoder.leave()
389
+ hw_module_name = encoder.output()
390
+ hw_other_name = x509.OtherName(SEP2_HARDWARE_MODULE_NAME, hw_module_name)
391
+
392
+ # Load Subject Alt Names from CSR if they exist
393
+ sans = [hw_other_name]
394
+ try:
395
+ san_oid = ExtensionOID.SUBJECT_ALTERNATIVE_NAME
396
+ san_extension = csr.extensions.get_extension_for_oid(san_oid)
397
+ san = san_extension.value
398
+ log.warning("Specifying a SubjectAlternativeName is a deviation from SEP2")
399
+ sans.extend(san)
400
+ except x509.ExtensionNotFound:
401
+ pass
402
+
403
+ # Define the Subject Name
404
+ subject = x509.Name("")
405
+
406
+ issuer_name = ca_cert.subject
407
+ iname = x509.Name(issuer_name)
408
+
409
+ cb = x509.CertificateBuilder()
410
+ cb = cb.subject_name(subject)
411
+ cb = cb.issuer_name(iname)
412
+ cb = cb.public_key(csr.public_key())
413
+ cb = cb.serial_number(x509.random_serial_number())
414
+ cb = cb.not_valid_before(valid_from)
415
+ cb = cb.not_valid_after(valid_to)
416
+ cb = cb.add_extension(
417
+ x509.BasicConstraints(ca=False, path_length=None),
418
+ critical=True,
419
+ )
420
+ cb = cb.add_extension(
421
+ x509.KeyUsage(
422
+ key_agreement=True,
423
+ key_cert_sign=False,
424
+ crl_sign=False,
425
+ digital_signature=True,
426
+ content_commitment=False,
427
+ key_encipherment=False,
428
+ data_encipherment=False,
429
+ encipher_only=False,
430
+ decipher_only=False,
431
+ ),
432
+ critical=True,
433
+ )
434
+ cb = cb.add_extension(
435
+ x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(
436
+ ca_cert.extensions.get_extension_for_class(x509.SubjectKeyIdentifier).value
437
+ ),
438
+ critical=False,
439
+ )
440
+ cb = cb.add_extension(
441
+ x509.CertificatePolicies(policies=policies),
442
+ critical=True,
443
+ )
444
+ cb = cb.add_extension(
445
+ x509.SubjectAlternativeName(general_names=sans),
446
+ critical=True,
447
+ )
448
+
449
+ cert = cb.sign(ca_key, SHA256())
450
+
451
+ cert_pem = cert.public_bytes(encoding=serialization.Encoding.PEM)
452
+ with open(cert_file, "wb") as fh:
453
+ fh.write(cert_pem)
454
+ # Append the intermediate certificate(s)
455
+ fh.write(ca_pem_data)
456
+ log.info("Created Cert %s", cert_file)
457
+
458
+ convert_pem_to_der(cert_file)
459
+ return cert_file
@@ -0,0 +1,102 @@
1
+ import hashlib
2
+ import logging
3
+ from itertools import zip_longest
4
+ from pathlib import Path
5
+
6
+ from cryptography import x509
7
+ from cryptography.hazmat.primitives import serialization
8
+
9
+ log = logging.getLogger(__name__)
10
+
11
+
12
+ def group_hex(item: str, group_size: int = 4) -> str:
13
+ """Group into groups of four"""
14
+
15
+ def grouper(iterable, n, fillvalue=None):
16
+ args = [iter(iterable)] * n
17
+ return zip_longest(*args, fillvalue=fillvalue)
18
+
19
+ return "-".join("".join(x) for x in grouper(item, n=group_size))
20
+
21
+
22
+ def get_lfdi(fingerprint: str, group: bool = True) -> str:
23
+ """Long-form device identifier (LFDI)
24
+ The LFDI SHALL be the certificate fingerprint left-truncated to 160 bits.
25
+ For display purposes, this SHALL be expressed as 40 hexadecimal digits in
26
+ groups of four.
27
+ """
28
+ lfdi = fingerprint.replace("-", "")[0:40].upper()
29
+ if not group:
30
+ return lfdi
31
+ return group_hex(lfdi)
32
+
33
+
34
+ def get_sfdi(fingerprint, group: bool = True) -> str:
35
+ """Short-form device identifier (SFDI)
36
+ The SFDI SHALL be the certificate fingerprint left-truncated to 36 bits.
37
+ For display purposes, this SHALL be expressed as 11 decimal (base 10) digits,
38
+ with an additional sum-of-digits checksum digit right concatenated.
39
+ """
40
+ trunc = fingerprint.replace("-", "")[0:9]
41
+ sfdi = int(trunc, base=16) # Convert from Base 16 to Base 10
42
+ digits = map(int, str(sfdi))
43
+ checksum = 10 - sum(digits) % 10
44
+ if checksum == 10:
45
+ checksum = 0
46
+
47
+ full_sfdi = str(sfdi) + str(checksum)
48
+ if not group:
49
+ return full_sfdi
50
+ return group_hex(full_sfdi, 3)
51
+
52
+
53
+ def get_fingerprint(cert_path: Path) -> str:
54
+ """Certificate fingerprint
55
+ The certificate fingerprint is the result of performing a SHA256 operation
56
+ over the whole DER-encoded certificate and is used to derive the SFDI and LFDI.
57
+ """
58
+ with open(cert_path, "rb") as f:
59
+ fbytes = f.read() # read entire file as bytes
60
+ readable_hash = hashlib.sha256(fbytes).hexdigest().upper()
61
+ return readable_hash
62
+
63
+
64
+ def is_pem_certificate(cert_path: Path) -> bool:
65
+ with open(cert_path, "rb") as f:
66
+ first_line = f.readline()
67
+ return b"-----BEGIN CERTIFICATE-----" in first_line
68
+
69
+
70
+ def get_der_certificate_lfdi(cert_path: Path, group: bool = True):
71
+ """Load X.509 DER Certificate and return LFDI"""
72
+
73
+ if not cert_path.exists():
74
+ raise ValueError(f"Cert not found at {cert_path}")
75
+
76
+ fingerprint = get_fingerprint(cert_path)
77
+ return get_lfdi(fingerprint, group=group)
78
+
79
+
80
+ def get_pem_certificate_lfdi(cert_path: Path, group: bool = True):
81
+ """Load X.509 DER Certificate in PEM format and return LFDI"""
82
+
83
+ if not cert_path.exists():
84
+ raise ValueError(f"Cert not found at {cert_path}")
85
+
86
+ with open(cert_path, "rb") as pem_file:
87
+ cert_data = pem_file.read()
88
+
89
+ cert = x509.load_pem_x509_certificate(cert_data)
90
+ der_bytes = cert.public_bytes(serialization.Encoding.DER)
91
+ fingerprint = hashlib.sha256(der_bytes).hexdigest().upper()
92
+ lfdi = get_lfdi(fingerprint, group=group)
93
+ return lfdi
94
+
95
+
96
+ def get_certificate_lfdi(cert_path: Path, group: bool = True):
97
+ """Load X.509 DER Certificate in PEM or DER format and return LFDI"""
98
+ if is_pem_certificate(cert_path):
99
+ lfdi = get_pem_certificate_lfdi(cert_path, group=group)
100
+ else:
101
+ lfdi = get_der_certificate_lfdi(cert_path, group=group)
102
+ return lfdi
@@ -0,0 +1,142 @@
1
+ import logging
2
+ from datetime import UTC, datetime
3
+ from pathlib import Path
4
+
5
+ from cryptography import x509
6
+ from cryptography.x509 import Certificate
7
+
8
+ from .cert_id import is_pem_certificate
9
+
10
+ log = logging.getLogger(__name__)
11
+ INDEF_EXPIRY = datetime(9999, 12, 31, 23, 59, 59, 0, UTC) # As per standard
12
+
13
+
14
+ def load_certificate(cert_path: Path) -> Certificate:
15
+ if not cert_path.exists():
16
+ raise ValueError(f"Cert not found at {cert_path}")
17
+ with open(cert_path, "rb") as pem_file:
18
+ cert_data = pem_file.read()
19
+ if is_pem_certificate(cert_path):
20
+ cert = x509.load_pem_x509_certificate(cert_data)
21
+ else:
22
+ cert = x509.load_der_x509_certificate(cert_data)
23
+ return cert
24
+
25
+
26
+ def get_certificate_sans(cert_path: Path) -> list[str]:
27
+ """Load X.509 DER Certificate in PEM format and return Policy OIDs"""
28
+ cert = load_certificate(cert_path)
29
+ sans = cert.extensions.get_extension_for_oid(
30
+ x509.ExtensionOID.SUBJECT_ALTERNATIVE_NAME
31
+ ).value
32
+ output = []
33
+ for entry in sans:
34
+ if isinstance(entry, x509.DNSName):
35
+ output.append(("DNS", entry.value))
36
+ elif isinstance(entry, x509.OtherName):
37
+ oid = entry.type_id.dotted_string
38
+ output.append((oid, entry.value))
39
+ return output
40
+
41
+
42
+ def get_certificate_policy_oids(cert_path: Path) -> list[str]:
43
+ """Load X.509 DER Certificate in PEM format and return Policy OIDs"""
44
+
45
+ cert = load_certificate(cert_path)
46
+
47
+ oids = []
48
+ cert_policies = cert.extensions.get_extension_for_oid(
49
+ x509.ExtensionOID.CERTIFICATE_POLICIES
50
+ ).value
51
+ for policy in cert_policies:
52
+ oid = policy.policy_identifier.dotted_string
53
+ oids.append(oid)
54
+ return oids
55
+
56
+
57
+ def get_cert_san_other_names_policy_oids(cert_path: Path) -> list[str]:
58
+ """Load X.509 DER Certificate in PEM format and
59
+ return Subject Alternative Name Other Names Policy OIDs"""
60
+
61
+ cert = load_certificate(cert_path)
62
+ sans = cert.extensions.get_extension_for_oid(
63
+ x509.ExtensionOID.SUBJECT_ALTERNATIVE_NAME
64
+ ).value
65
+ other_names = []
66
+ for entry in sans:
67
+ if isinstance(entry, x509.OtherName):
68
+ oid = entry.type_id.dotted_string
69
+ other_names.append(oid)
70
+ return other_names
71
+
72
+
73
+ def validate_certificate(cert_path: Path) -> bool:
74
+ """Load X.509 DER Certificate in PEM format and validate"""
75
+
76
+ cert = load_certificate(cert_path)
77
+ valid = True
78
+
79
+ # Check the validity period
80
+ current_time_utc = datetime.now(UTC)
81
+ if not cert.not_valid_before_utc <= current_time_utc:
82
+ msg = "Certificate is not valid yet. "
83
+ msg += "Not valid before {cert.not_valid_before_utc}"
84
+ log.error(msg)
85
+ valid = False
86
+ if not current_time_utc <= cert.not_valid_after_utc:
87
+ msg = "Certificate is no longer valid. "
88
+ msg += "Not valid after {cert.not_valid_after_utc}"
89
+ log.error(msg)
90
+ valid = False
91
+ if cert.not_valid_after_utc != INDEF_EXPIRY:
92
+ msg = f"Certificate expiry not {INDEF_EXPIRY} as per standard. "
93
+ msg += f"Expires {cert.not_valid_after_utc}"
94
+ log.warning(msg)
95
+ valid = False
96
+
97
+ # Verify the OIDs
98
+ oid_intro = "1.3.6.1.4.1"
99
+ oid_pen = "40732"
100
+
101
+ oid_into = f"{oid_intro}.{oid_pen}"
102
+ oid_device_start = f"{oid_intro}.{oid_pen}.1."
103
+ sep2_dev_type = False
104
+
105
+ oid_test_end = f".{oid_pen}.2.1"
106
+ test_oid = False
107
+
108
+ oids = get_certificate_policy_oids(cert_path)
109
+ for oid in oids:
110
+ if oid_test_end in oid:
111
+ test_oid = oid
112
+ elif oid_device_start in oid:
113
+ sep2_dev_type = True
114
+ elif oid_into in oid:
115
+ pass
116
+ else:
117
+ log.warning("Certificate has a non SEP2 policy specified: %s", oid)
118
+
119
+ if test_oid:
120
+ msg = f"Certificate is a test certificate as {test_oid} set."
121
+ log.warning(msg)
122
+
123
+ if not sep2_dev_type:
124
+ msg = "At least one SEP2 device type assignment policy must be specified. "
125
+ msg += f"That is an OID like {oid_device_start}X"
126
+ log.error(msg)
127
+ valid = False
128
+
129
+ # Verify the Other Name OID in the SAN
130
+ sep2_hw_mod_name = False
131
+ oid_hw_mod_name = "1.3.6.1.5.5.7.8.4"
132
+ san_oids = get_cert_san_other_names_policy_oids(cert_path)
133
+ for oid in san_oids:
134
+ if oid == oid_hw_mod_name:
135
+ sep2_hw_mod_name = True
136
+
137
+ if not sep2_hw_mod_name:
138
+ msg = f"SEP2 Hardware Module Name OID {oid_hw_mod_name} "
139
+ msg += "not found in SAN OtherName."
140
+ log.error(msg)
141
+
142
+ return valid
@@ -0,0 +1,114 @@
1
+ import logging
2
+ from pathlib import Path
3
+
4
+ import typer
5
+
6
+ from .cert_create import (
7
+ generate_device_certificate,
8
+ generate_key,
9
+ generate_mica,
10
+ generate_serca,
11
+ )
12
+ from .cert_id import get_certificate_lfdi
13
+ from .cert_validate import validate_certificate
14
+ from .version import __version__
15
+
16
+ LOG_FORMAT = "%(asctime)s %(levelname)-8s %(message)s"
17
+ DEFAULT_DIR = Path("certs")
18
+ app = typer.Typer(no_args_is_help=True)
19
+
20
+
21
+ def version_callback(value: bool):
22
+ if value:
23
+ typer.echo(f"sep2cert version: {__version__}")
24
+ raise typer.Exit()
25
+
26
+
27
+ @app.callback()
28
+ def callback(
29
+ version: bool = typer.Option(False, "--version", callback=version_callback),
30
+ ) -> None:
31
+ """sep2cert
32
+
33
+ Functions for creating and validating IEEE 2030.5 (SEP2) certificates.
34
+ """
35
+ pass
36
+
37
+
38
+ @app.command()
39
+ def cert_lfdi(filepath: Path, verbose: bool = False) -> None:
40
+ log_level = "DEBUG" if verbose else "INFO"
41
+ logging.basicConfig(level=log_level, format=LOG_FORMAT)
42
+
43
+ if not filepath.exists():
44
+ msg = f"No such file {filepath}"
45
+ raise FileNotFoundError(msg)
46
+
47
+ lfdi = get_certificate_lfdi(filepath)
48
+ typer.echo(f"The LFDI is: {lfdi}")
49
+
50
+ validate_certificate(filepath)
51
+
52
+
53
+ @app.command()
54
+ def create_key(
55
+ key_file: Path | None = None,
56
+ hostname: str = "",
57
+ verbose: bool = False,
58
+ ) -> None:
59
+ log_level = "DEBUG" if verbose else "INFO"
60
+ logging.basicConfig(level=log_level, format=LOG_FORMAT)
61
+
62
+ hostnames = None if not hostname else [hostname]
63
+ _key, _csr = generate_key(key_file, generate_csr=True, hostnames=hostnames)
64
+
65
+
66
+ @app.command()
67
+ def create_serca(
68
+ org_name: str = "Smart Energy",
69
+ verbose: bool = False,
70
+ output_dir: Path = DEFAULT_DIR,
71
+ ) -> None:
72
+ log_level = "DEBUG" if verbose else "INFO"
73
+ logging.basicConfig(level=log_level, format=LOG_FORMAT)
74
+
75
+ output_dir.mkdir(exist_ok=True)
76
+ key_file = output_dir / "serca.key"
77
+ key, _csr = generate_key(key_file, generate_csr=False)
78
+ generate_serca(key, org_name=org_name)
79
+
80
+
81
+ @app.command()
82
+ def create_mica(
83
+ ca_cert: Path,
84
+ ca_key: Path,
85
+ org_name: str = "Example Org",
86
+ verbose: bool = False,
87
+ output_dir: Path = DEFAULT_DIR,
88
+ ) -> None:
89
+ log_level = "DEBUG" if verbose else "INFO"
90
+ logging.basicConfig(level=log_level, format=LOG_FORMAT)
91
+
92
+ output_dir.mkdir(exist_ok=True)
93
+ key_file = output_dir / "mica.key"
94
+ _key, csr = generate_key(key_file, generate_csr=True)
95
+ generate_mica(csr, ca_cert, ca_key, org_name=org_name)
96
+
97
+
98
+ @app.command()
99
+ def create_cert(
100
+ csr_path: Path,
101
+ ca_cert: Path,
102
+ ca_key: Path,
103
+ pen: int = 12345,
104
+ serno: str = "1",
105
+ verbose: bool = False,
106
+ ) -> None:
107
+ log_level = "DEBUG" if verbose else "INFO"
108
+ logging.basicConfig(level=log_level, format=LOG_FORMAT)
109
+
110
+ cert_fp = generate_device_certificate(
111
+ csr_path, ca_cert, ca_key, pen=pen, hardware_serial_number=serno
112
+ )
113
+ lfdi = get_certificate_lfdi(cert_fp)
114
+ typer.echo(f"The LFDI is: {lfdi}")
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"