Skip to content

Commit d99a738

Browse files
committed
feat: LoTL Provider X.509 cert creation tools and docs
1 parent 06efde1 commit d99a738

5 files changed

Lines changed: 442 additions & 0 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,5 @@ celerybeat.pid
9393

9494
# mkdocs
9595
/site
96+
97+
lotl/certs/

task4-trust-infrastructure-api/lotl-automation-and-tl-integration.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ Each `{participant_id}.json` file MUST contain:
131131
tools/lotl/
132132
├── __init__.py
133133
├── cli.py
134+
├── create_signing_cert.py # ETSI-compliant self-signed cert generator
134135
├── producer.py
135136
├── collector.py
136137
├── validator.py
@@ -170,6 +171,43 @@ python -m tools.lotl --validate-only --tl-entries-dir lotl/tl_entries/
170171

171172
**Testing**: pytest required, minimum 90% code coverage. See [Running Tests](#51-running-tests).
172173

174+
#### 4.1 Signing Certificate Creation and Configuration
175+
176+
The LoTL signing certificate must comply with ETSI TS 119 612 clause 5.7.1 (Subject DN, KeyUsage, ExtendedKeyUsage id-tsl-kp-tslSigning, BasicConstraints CA=false, SubjectKeyIdentifier). Use the provided command to create a self-signed, ETSI-compliant certificate:
177+
178+
```bash
179+
# Create cert (default: lotl/certs/, Scheme Territory=EU)
180+
python -m tools.lotl.create_signing_cert
181+
182+
# Custom scheme territory and operator name (must match LoTL scheme info)
183+
python -m tools.lotl.create_signing_cert \
184+
--output-dir lotl/certs/ \
185+
--scheme-territory IT \
186+
--scheme-operator-name "Example TLP"
187+
```
188+
189+
**Configuration of LOTL_SIGNING_KEY and LOTL_SIGNING_CERT**:
190+
191+
| Method | Example |
192+
|--------|---------|
193+
| Environment variables | `export LOTL_SIGNING_KEY=$(cat lotl/certs/lotl_signing_key.pem)`<br>`export LOTL_SIGNING_CERT=$(cat lotl/certs/lotl_signing_cert.pem)` |
194+
| CLI arguments | `python -m tools.lotl --signing-key lotl/certs/lotl_signing_key.pem --signing-cert lotl/certs/lotl_signing_cert.pem ...` |
195+
196+
Full workflow example:
197+
```bash
198+
# 1. Create certificate
199+
python -m tools.lotl.create_signing_cert -o lotl/certs/ -t EU -n "WP4 Trust Group"
200+
201+
# 2. Produce LoTL using env vars
202+
export LOTL_SIGNING_KEY=$(cat lotl/certs/lotl_signing_key.pem)
203+
export LOTL_SIGNING_CERT=$(cat lotl/certs/lotl_signing_cert.pem)
204+
python -m tools.lotl --tl-entries-dir lotl/tl_entries/ --output-dir lotl/
205+
206+
# Or using file paths
207+
python -m tools.lotl --signing-key lotl/certs/lotl_signing_key.pem --signing-cert lotl/certs/lotl_signing_cert.pem \
208+
--tl-entries-dir lotl/tl_entries/ --output-dir lotl/
209+
```
210+
173211
### 5. TL Entry Validation
174212

175213
**Location**: `tools/lotl/`

tools/lotl/README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,50 @@
22

33
List of Trusted Lists (LoTL) producer and validator for the WP4 Trust Infrastructure. Generates and signs LoTL in XML (XAdES Baseline B) and JSON (JAdES Compact Baseline B) formats per ETSI TS 119 612 and TS 119 602.
44

5+
## Creating a Signing Certificate
6+
7+
The LoTL must be signed with an ETSI-compliant X.509 certificate. Use the provided command to generate a self-signed certificate:
8+
9+
```bash
10+
# Create ETSI-compliant self-signed cert (default: lotl/certs/, Scheme Territory=EU)
11+
python -m tools.lotl.create_signing_cert
12+
13+
# Custom scheme territory and operator name (must match LoTL scheme info)
14+
python -m tools.lotl.create_signing_cert \
15+
--output-dir lotl/certs/ \
16+
--scheme-territory IT \
17+
--scheme-operator-name "Example TLP"
18+
19+
# Custom output filenames
20+
python -m tools.lotl.create_signing_cert -o lotl/certs/ --key-file key.pem --cert-file cert.pem
21+
```
22+
23+
This produces `lotl_signing_key.pem` and `lotl_signing_cert.pem` (or your chosen names) with:
24+
- Subject DN: `C={territory}, O={operator_name}` (per ETSI TS 119 612 clause 5.7.1)
25+
- KeyUsage: digitalSignature, nonRepudiation
26+
- ExtendedKeyUsage: id-tsl-kp-tslSigning (0.4.0.2231.3.0)
27+
- ECDSA P-256 (minimum 3 years usable key per ETSI TS 119 312)
28+
29+
## Configuration: LOTL_SIGNING_KEY and LOTL_SIGNING_CERT
30+
31+
Provide the signing key and certificate either via environment variables or CLI arguments:
32+
33+
**Option 1: Environment variables** (PEM content as string)
34+
```bash
35+
export LOTL_SIGNING_KEY=$(cat lotl/certs/lotl_signing_key.pem)
36+
export LOTL_SIGNING_CERT=$(cat lotl/certs/lotl_signing_cert.pem)
37+
python -m tools.lotl --tl-entries-dir lotl/tl_entries/ --output-dir lotl/
38+
```
39+
40+
**Option 2: File paths** (recommended for local use)
41+
```bash
42+
python -m tools.lotl \
43+
--signing-key lotl/certs/lotl_signing_key.pem \
44+
--signing-cert lotl/certs/lotl_signing_cert.pem \
45+
--tl-entries-dir lotl/tl_entries/ \
46+
--output-dir lotl/
47+
```
48+
549
## Usage
650

751
```bash

tools/lotl/create_signing_cert.py

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Create a self-signed, ETSI-compliant X.509 certificate for LoTL/Trusted List signing.
4+
5+
Per ETSI TS 119 612 clause 5.7.1 and Annex B:
6+
- Self-signed (TLSO as issuer)
7+
- Subject DN: C=SchemeTerritory, O=SchemeOperatorName
8+
- KeyUsage: digitalSignature, nonRepudiation
9+
- ExtendedKeyUsage: id-tsl-kp-tslSigning (0.4.0.2231.3.0)
10+
- SubjectKeyIdentifier: present
11+
- BasicConstraints: CA=false
12+
- Key: ECDSA P-256 (minimum 3 years usable per ETSI TS 119 312)
13+
"""
14+
15+
import argparse
16+
import subprocess
17+
import sys
18+
from datetime import datetime, timezone, timedelta
19+
from pathlib import Path
20+
21+
from cryptography import x509
22+
from cryptography.hazmat.backends import default_backend
23+
from cryptography.hazmat.primitives import hashes, serialization
24+
from cryptography.hazmat.primitives.asymmetric import ec
25+
from cryptography.x509.oid import ExtensionOID, ObjectIdentifier
26+
27+
# ETSI id-tsl-kp-tslSigning OID (TS 119 612 clause 5.7.1)
28+
ID_TSL_KP_TSL_SIGNING = ObjectIdentifier("0.4.0.2231.3.0")
29+
30+
31+
def create_lotl_signing_cert(
32+
scheme_territory: str,
33+
scheme_operator_name: str,
34+
output_dir: Path,
35+
validity_days: int = 365 * 3, # 3 years per ETSI TS 119 312
36+
key_path: Path | None = None,
37+
cert_path: Path | None = None,
38+
) -> tuple[bytes, bytes]:
39+
"""Generate ETSI-compliant self-signed cert and private key. Returns (key_pem, cert_pem)."""
40+
output_dir.mkdir(parents=True, exist_ok=True)
41+
42+
# ECDSA P-256 for minimum 3 years usable key (ETSI TS 119 312)
43+
key = ec.generate_private_key(ec.SECP256R1())
44+
45+
subject = issuer = x509.Name(
46+
[
47+
x509.NameAttribute(x509.oid.NameOID.COUNTRY_NAME, scheme_territory),
48+
x509.NameAttribute(x509.oid.NameOID.ORGANIZATION_NAME, scheme_operator_name),
49+
]
50+
)
51+
52+
now = datetime.now(timezone.utc)
53+
cert = (
54+
x509.CertificateBuilder()
55+
.subject_name(subject)
56+
.issuer_name(issuer)
57+
.public_key(key.public_key())
58+
.serial_number(x509.random_serial_number())
59+
.not_valid_before(now)
60+
.not_valid_after(now + timedelta(days=validity_days))
61+
.add_extension(
62+
x509.KeyUsage(
63+
digital_signature=True,
64+
content_commitment=True, # nonRepudiation
65+
key_encipherment=False,
66+
data_encipherment=False,
67+
key_agreement=False,
68+
key_cert_sign=False,
69+
crl_sign=False,
70+
encipher_only=False,
71+
decipher_only=False,
72+
),
73+
critical=True,
74+
)
75+
.add_extension(
76+
x509.ExtendedKeyUsage([ID_TSL_KP_TSL_SIGNING]),
77+
critical=False,
78+
)
79+
.add_extension(
80+
x509.BasicConstraints(ca=False, path_length=None),
81+
critical=True,
82+
)
83+
.add_extension(
84+
x509.SubjectKeyIdentifier.from_public_key(key.public_key()),
85+
critical=False,
86+
)
87+
.sign(key, hashes.SHA256())
88+
)
89+
90+
key_pem = key.private_bytes(
91+
encoding=serialization.Encoding.PEM,
92+
format=serialization.PrivateFormat.TraditionalOpenSSL,
93+
encryption_algorithm=serialization.NoEncryption(),
94+
)
95+
cert_pem = cert.public_bytes(serialization.Encoding.PEM)
96+
97+
if key_path:
98+
key_path.write_bytes(key_pem)
99+
if cert_path:
100+
cert_path.write_bytes(cert_pem)
101+
102+
return key_pem, cert_pem
103+
104+
105+
def _print_cert_diagnostic(cert_path: Path, cert_pem: bytes) -> None:
106+
"""Print certificate diagnostic (ASN.1 / OpenSSL text) and raw PEM."""
107+
print("--- Certificate (diagnostic ASN / OpenSSL text) ---")
108+
result = subprocess.run(
109+
["openssl", "x509", "-in", str(cert_path), "-text", "-noout"],
110+
capture_output=True,
111+
text=True,
112+
)
113+
if result.returncode == 0:
114+
print(result.stdout)
115+
else:
116+
# Fallback: basic Python representation
117+
cert = x509.load_pem_x509_certificate(cert_pem, default_backend())
118+
print(f"Certificate:")
119+
print(f" Subject: {cert.subject.rfc4514_string()}")
120+
print(f" Issuer: {cert.issuer.rfc4514_string()}")
121+
print(f" Serial: {cert.serial_number}")
122+
print(f" Not Before: {cert.not_valid_before_utc}")
123+
print(f" Not After: {cert.not_valid_after_utc}")
124+
print(f" Signature: {getattr(cert.signature_algorithm_oid, '_name', cert.signature_algorithm_oid.dotted_string)}")
125+
for ext in cert.extensions:
126+
oid_name = getattr(ext.oid, "_name", ext.oid.dotted_string)
127+
print(f" {oid_name}: {ext.value}")
128+
129+
print("--- Certificate (raw PEM) ---")
130+
print(cert_pem.decode())
131+
132+
133+
def main() -> int:
134+
parser = argparse.ArgumentParser(
135+
description="Create ETSI-compliant self-signed X.509 certificate for LoTL signing",
136+
)
137+
parser.add_argument(
138+
"--output-dir",
139+
"-o",
140+
type=Path,
141+
default=Path("lotl/certs"),
142+
help="Output directory for key.pem and cert.pem (default: lotl/certs)",
143+
)
144+
parser.add_argument(
145+
"--scheme-territory",
146+
"-t",
147+
default="EU",
148+
help="Scheme territory (ISO 3166-1 alpha-2, e.g. EU, IT) (default: EU)",
149+
)
150+
parser.add_argument(
151+
"--scheme-operator-name",
152+
"-n",
153+
default="WP4 Trust Registry Group",
154+
help="Scheme operator name for Subject/Issuer O (default: WP4 Trust Registry Group)",
155+
)
156+
parser.add_argument(
157+
"--validity-days",
158+
type=int,
159+
default=365 * 3,
160+
help="Certificate validity in days (default: 1095 = 3 years)",
161+
)
162+
parser.add_argument(
163+
"--key-file",
164+
default="lotl_signing_key.pem",
165+
help="Output key filename (default: lotl_signing_key.pem)",
166+
)
167+
parser.add_argument(
168+
"--cert-file",
169+
default="lotl_signing_cert.pem",
170+
help="Output cert filename (default: lotl_signing_cert.pem)",
171+
)
172+
173+
args = parser.parse_args()
174+
175+
key_path = args.output_dir / args.key_file
176+
cert_path = args.output_dir / args.cert_file
177+
178+
try:
179+
_, cert_pem = create_lotl_signing_cert(
180+
scheme_territory=args.scheme_territory,
181+
scheme_operator_name=args.scheme_operator_name,
182+
output_dir=args.output_dir,
183+
validity_days=args.validity_days,
184+
key_path=key_path,
185+
cert_path=cert_path,
186+
)
187+
except OSError as e:
188+
print(f"Error writing files: {e}", file=sys.stderr)
189+
return 1
190+
191+
print(f"Created ETSI-compliant LoTL signing certificate:")
192+
print(f" Key: {key_path}")
193+
print(f" Cert: {cert_path}")
194+
print(f" Subject: C={args.scheme_territory}, O={args.scheme_operator_name}")
195+
print()
196+
print("Use with LoTL producer:")
197+
print(f" export LOTL_SIGNING_KEY=$(cat {key_path})")
198+
print(f" export LOTL_SIGNING_CERT=$(cat {cert_path})")
199+
print(f" python -m tools.lotl --tl-entries-dir lotl/tl_entries/ --output-dir lotl/")
200+
print()
201+
print("Or with inline paths:")
202+
print(f" python -m tools.lotl --signing-key {key_path} --signing-cert {cert_path} \\")
203+
print(" --tl-entries-dir lotl/tl_entries/ --output-dir lotl/")
204+
print()
205+
_print_cert_diagnostic(cert_path, cert_pem)
206+
207+
return 0
208+
209+
210+
if __name__ == "__main__":
211+
sys.exit(main())

0 commit comments

Comments
 (0)