Skip to content
This repository was archived by the owner on Feb 5, 2025. It is now read-only.

Commit af0fa9c

Browse files
authored
Merge pull request #59 from minvws/uzipoc_q4_2024
Finalize UZI PoC Q4 for YubiSign project
2 parents 941458f + 0814b72 commit af0fa9c

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+1220
-374
lines changed

.editorconfig

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
root = true
2+
3+
[*]
4+
charset = utf-8
5+
end_of_line = lf
6+
insert_final_newline = true
7+
trim_trailing_whitespace = true
8+
indent_style = space
9+
indent_size = 2
10+
11+
[*.py]
12+
indent_size = 4
13+
max_line_length = 120
14+
15+
[Makefile]
16+
indent_style = tab
17+
18+
[*.md]
19+
trim_trailing_whitespace = false

.env.example

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
YUBIKEY_PIN="123456"
2+
ACME_SERVER_DIRECTORY_URL="https://acme.proeftuin.uzi-online.irealisatie.nl/directory"
3+
OIDC_PROVIDER_BASE_URL="https://proeftuin.uzi-online.irealisatie.nl"
4+
ACME_ACCOUNT_EMAIL="[email protected]"

.github/workflows/formatlint.yml

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: Format and lint
2+
3+
on:
4+
push:
5+
branches:
6+
- "main"
7+
pull_request:
8+
branches:
9+
- "*"
10+
types: [opened, synchronize, closed]
11+
12+
jobs:
13+
build:
14+
15+
runs-on: ubuntu-latest
16+
strategy:
17+
matrix:
18+
python-version: ["3.13"]
19+
20+
steps:
21+
- uses: actions/checkout@v4
22+
- name: Set up Python ${{ matrix.python-version }}
23+
uses: actions/setup-python@v5
24+
with:
25+
python-version: ${{ matrix.python-version }}
26+
27+
- name: Install developement requirements
28+
run: pip install -r requirements-dev.txt
29+
30+
- name: Check for linting errors
31+
run: ruff check .
32+
33+
- name: Check for formatting errors
34+
run: ruff format --check
35+

.gitignore

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
__pycache__
2+
.pytest_cache
3+
.env

README.md

+45-30
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,60 @@
1-
# Disclaimer
2-
This Repository is created as a PoC (Proof of Concept) as part of the project *Toekomstbestendig maken UZI*, and
3-
**should not be used as is in any production environment**.
1+
# PoC with Yubikey
42

5-
# Wat doet dit?
3+
In order to automate certificate issuance for UZI, a PoC was done with a YubiKey and an ACME server. The keypairs are generated on the YubiKey and the certificate is issued by the ACME server. This program is designed to start with a _new_ YubiKey, meaning it should have the default PIN. This document will give you an high overview.
64

7-
Dit neemt een yubikey (doe maar versie 5) en maakt daarin de PIV module *leeg*
5+
### Steps
86

9-
Nadat deze leeg is worden er 4 keys aangemaakt in de yubikey
7+
- The YubiKey is reset: all the certificates on the device will be removed and the PIN code will be reset.
8+
- We will generate 4 public and private key pairs on the YubiKey. These are for PIV Authentication, Digital Signature, Key Management and Card Authentication. Next to that, the YubiKey will generate additional attestation certificates, to prove that the private key is generated on the YubiKey itself. The private keys will always remain in the YubiKey.
9+
- The user logs in via the chosen [authentication flow](./AUTH_FLOW.md). This returns an JWT, containing the user information.
10+
- Per generated key pair, an certificate signing request (CSR) is created and signed by the private key.
11+
- Finally, each certificate signing request with the corresponding attestation certificate is validated at the ACME server. When this is done, the server will issue an certificate for every key pair. Here, the JWT of the user is also used. This is done with the ACME server of iRealisatie. These are then saved back into the YubiKey into the corresponding slot.
1012

11-
Er wordt contact gelegd met de rdo-acme service. En er worden 4 orders aangemaakt
13+
Now it is possible to use the certificate on the YubiKey to sign data.
1214

13-
Van deze 4 orders wordt de unique-anti-replay-token meegestuurd met een uzi-labs digid login verzoek
15+
#### Diagram flow
1416

15-
Er wordt een browser geopend in de applicatie zelf, daarmee log je in als zorg identiteit bij de ziekenboeg-uzi-labs
17+
This diagram expects that the Yubikey is already plugged in the user's computer. Next to that, it's expected that the user should use the **DigiD mock** login method.
1618

17-
De app haalt hierna de JWT-token op bij ziekenboeg-uzi-labs waarin de 4 acmetokens zitten.
19+
```mermaid
20+
sequenceDiagram
21+
actor APP
22+
participant YUBIKEY
1823
19-
Er wordt van de yubikey zelf opgehaald:
20-
* Het intermediate certificaat behorende bij de yubikey zoals geleverd door yubico op de yubikey zelf
24+
APP->>YUBIKEY: 1. Sends request to empty the Yubikeys certificates
25+
YUBIKEY-->YUBIKEY: Empties the certificates
2126
22-
Per sleutel op de yubikey:
23-
* Een door de yubikey ondertekend certificaat per sleutel waarin de garantie (attestation) staat dat de sleutel echt op een yubikey is gemaakt
24-
* een CSR verzoek ondertekend door de aangemaakte sleutel
27+
APP->>YUBIKEY: 2. Sends request to generate 4 new private key pairs
28+
YUBIKEY-->YUBIKEY: 2.1 Create key pair for PIV Authentication
29+
YUBIKEY-->YUBIKEY: 2.2 Create key pair for Digital Signature
30+
YUBIKEY-->YUBIKEY: 2.3 Create key pair for Key Management
31+
YUBIKEY-->YUBIKEY: 2.4 Create key pair for Card Authentication
2532
26-
Per order wordt dan verstuurd:
27-
* De JWT
28-
* Het yubikey intermediate certificaat
29-
* Het attestation certificaat
33+
create participant MAX
34+
APP->>MAX: 3. Opens browser to login the user
35+
MAX-->MAX: 3.1 Validates the user
36+
MAX-->>APP: Returns the JWT containing the user information.
3037
31-
De acme server controleert dan per order:
32-
* of het attestation certificaat van de sleutel klopt
33-
* het token voor order in de JWT zit
34-
* De JWT goed is en van een geldige uzi-cibg-labs-uitgever komt
38+
APP->>APP: 4. Per generated key pair, <br> an certificate signing request (CSR)<br> is created and signed by the private key.
3539
36-
Als dat klopt dan geeft de acme server terug dat het klopt en dan vraagt deze app in de laatste stap een certificaat aan met de eerder genoemde CSR.
40+
create participant ACME_SERVER
41+
loop 5. For every certificate signing request (CSR)
42+
APP->>ACME_SERVER: Validate every certificate signing request with the corresponding attestation certificate
43+
ACME_SERVER-->>APP: OK
44+
end
3745
38-
Als de CSR dezelfde public key heeft als in de vorige stap gecontroleerde gegevens wordt er een Labs-UZI certifcaat uitgegeven op basis van de gegevens in de JWT.
39-
Dit certificaat bevat de huidige UZI-Certificaten structuur.
46+
loop 6. For every key pair
47+
APP->>ACME_SERVER: Request certificate for every key pair, also using the users' JWT
48+
ACME_SERVER-->>APP: OK
49+
ACME_SERVER-->>YUBIKEY: Save certificates
50+
end
4051
41-
Als er een certificaat is opgehaald wordt dit opgeslagen op de juiste plek in de yubikey.
52+
```
4253

43-
Door het laden van de yubikey pkcs11 library in de browser, office, mac os, windows of linux plekken (zoals beschreven door yubico) kan de yubikey daarna
44-
worden gebruikt zoals een UZIpas ook gebruikt kan worden. Voor digitaal ondertekenen van documenten, verzoeken en om in te loggen in de browser bij
45-
partijen die UZI certificaten login mogelijk maken.
54+
### Disclaimer
55+
56+
This Repository is created as a PoC (Proof of Concept) as part of the project _Toekomstbestendig maken UZI_, and **should not be used as is in any production environment**.
57+
58+
### Licentie
59+
60+
This project is licensed under the [EUPL-1.2 license](./LICENSE.txt).

app/acme.py

+50-38
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,27 @@
44
import requests
55
from jwcrypto import jwk, jwt
66

7+
import urllib.parse
8+
9+
from app.acme_directory_configuration import ACMEDirectoryConfiguration
10+
711

812
class Acme:
9-
url = None
13+
url: urllib.parse.ParseResult
1014
key = None
1115
nonce = None
1216
kid = None
1317
order = None
1418
certurl = None
1519
finalize = {}
1620

17-
def __init__(self, url):
18-
self.url = url
21+
_directory_configuration: ACMEDirectoryConfiguration
22+
23+
def __init__(
24+
self,
25+
directory_config: ACMEDirectoryConfiguration,
26+
):
27+
self._directory_configuration = directory_config
1928

2029
def debugrequest(self, protected, payload):
2130
print(" protected")
@@ -36,9 +45,7 @@ def gen_key(self):
3645
This does only generate a P-256 key for use with JWT.
3746
This key is only used during the session to request a certificate from ACME.
3847
"""
39-
self.key = jwk.JWK.generate(
40-
kty="EC", crv="P-256", key_ops=["verify", "sign"], alg="ES256"
41-
)
48+
self.key = jwk.JWK.generate(kty="EC", crv="P-256", key_ops=["verify", "sign"], alg="ES256")
4249

4350
def get_nonce(self):
4451
"""
@@ -47,7 +54,10 @@ def get_nonce(self):
4754
without having to use the previous nonce. This is stored in self.nonce
4855
as with all updates, as every request answers with a new nonce.
4956
"""
50-
response = requests.get(self.url + "acme/new-nonce", timeout=60)
57+
response = requests.get(
58+
self._directory_configuration.new_nonce_url,
59+
timeout=60,
60+
)
5161
self.nonce = response.headers["Replay-Nonce"]
5262

5363
def account_request(self, request):
@@ -58,18 +68,20 @@ def account_request(self, request):
5868
add the nonce (see above), url and alg and tada.wav.
5969
"""
6070
print("Account Request")
71+
6172
protected = {
6273
"alg": "ES256",
6374
"nonce": self.nonce,
64-
"url": self.url + "acme/new-acct",
75+
"url": self._directory_configuration.new_account_url,
6576
"jwk": self.key.export_public(True),
6677
}
6778
token = jwt.JWS(payload=json.dumps(request))
6879
token.add_signature(self.key, alg="ES256", protected=protected)
6980
self.debugrequest(protected, request)
7081
headers = {"Content-Type": "application/jose+json"}
82+
7183
response = requests.post(
72-
self.url + "acme/new-acct",
84+
self._directory_configuration.new_account_url,
7385
data=token.serialize(),
7486
headers=headers,
7587
timeout=60,
@@ -88,18 +100,21 @@ def create_order(self, keynum, order):
88100
afterwards.
89101
"""
90102
print("Order")
103+
104+
new_order_url = self._directory_configuration.new_order_url
105+
91106
protected = {
92107
"alg": "ES256",
93108
"nonce": self.nonce,
94-
"url": self.url + "acme/new-order",
109+
"url": new_order_url,
95110
"kid": self.kid,
96111
}
97112
self.debugrequest(protected, order)
98113
token = jwt.JWS(payload=json.dumps(order))
99114
token.add_signature(self.key, alg="ES256", protected=protected)
100115
headers = {"Content-Type": "application/jose+json"}
101116
response = requests.post(
102-
self.url + "acme/new-order",
117+
new_order_url,
103118
data=token.serialize(),
104119
headers=headers,
105120
timeout=60,
@@ -132,13 +147,19 @@ def challenge(self, challengeurl):
132147
token = jwt.JWS(payload="")
133148
token.add_signature(self.key, alg="ES256", protected=protected)
134149
headers = {"Content-Type": "application/jose+json"}
135-
response = requests.post(
136-
challengeurl, data=token.serialize(), headers=headers, timeout=60
137-
)
150+
151+
# Request the challenge per PIV-slot from the ACME-server.
152+
# This will return a random token, with the status of pending.
153+
#
154+
# Later on, these tokens from the challenges should be contained in the users' JWT.
155+
response = requests.post(challengeurl, data=token.serialize(), headers=headers, timeout=60)
156+
returned_json = response.json()
157+
138158
self.debugresponse(response)
139159
self.nonce = response.headers["Replay-Nonce"]
140-
assert response.json()["status"] in ["pending", "valid"]
141-
return response.json()["challenges"], response.json()["status"]
160+
161+
assert returned_json["status"] in ["pending", "valid"]
162+
return returned_json["challenges"], returned_json["status"]
142163

143164
def send_challenge_jwt(self, challenge, hw_attestation, uzi_jwt, f9_cert):
144165
"""
@@ -167,9 +188,7 @@ def send_challenge_jwt(self, challenge, hw_attestation, uzi_jwt, f9_cert):
167188
}
168189
print(" headers")
169190
print(headers)
170-
response = requests.post(
171-
challengeurl, data=token.serialize(), headers=headers, timeout=60
172-
)
191+
response = requests.post(challengeurl, data=token.serialize(), headers=headers, timeout=60)
173192
self.nonce = response.headers["Replay-Nonce"]
174193
self.debugresponse(response)
175194

@@ -191,15 +210,13 @@ def notify(self, notifyurl):
191210
token = jwt.JWS(payload=json.dumps({}))
192211
token.add_signature(self.key, alg="ES256", protected=protected)
193212
headers = {"Content-Type": "application/jose+json"}
194-
response = requests.post(
195-
notifyurl, data=token.serialize(), headers=headers, timeout=60
196-
)
213+
response = requests.post(notifyurl, data=token.serialize(), headers=headers, timeout=60)
197214
self.nonce = response.headers["Replay-Nonce"]
198215
self.debugresponse(response)
199216
assert response.json()["status"] in ["pending", "valid"]
200217
return response.json()["status"], response.json()["url"]
201218

202-
def final(self, keynum, csr):
219+
def final(self, keynum, csr, jwt_token: str):
203220
"""
204221
There is an order, we are correct. Now we get to request a certificate.
205222
To do this we provide a CSR and that gets signed with the root/sub-CA
@@ -213,18 +230,18 @@ def final(self, keynum, csr):
213230
"kid": self.kid,
214231
}
215232
payload = {
216-
# "csr": b64encode(csr).decode().replace('+','-').replace('/','_'),
217-
"csr": urlsafe_b64encode(csr)
218-
.decode()
219-
.rstrip("="),
233+
"csr": urlsafe_b64encode(csr).decode().rstrip("="),
220234
}
221235
self.debugrequest(protected, payload)
222236
token = jwt.JWS(payload=json.dumps(payload))
223237
token.add_signature(self.key, alg="ES256", protected=protected)
224-
headers = {"Content-Type": "application/jose+json"}
225-
response = requests.post(
226-
self.finalize[keynum], data=token.serialize(), headers=headers, timeout=60
227-
)
238+
headers = {
239+
"Content-Type": "application/jose+json",
240+
"X-Acme-Jwt": jwt_token,
241+
}
242+
243+
# This calls the finalize method, preparing the certificate
244+
response = requests.post(self.finalize[keynum], data=token.serialize(), headers=headers, timeout=60)
228245
self.nonce = response.headers["Replay-Nonce"]
229246
self.debugresponse(response)
230247
assert response.json()["status"] == "valid"
@@ -246,11 +263,8 @@ def getcert(self):
246263
token = jwt.JWS(payload="")
247264
token.add_signature(self.key, alg="ES256", protected=protected)
248265
headers = {"Content-Type": "application/jose+json"}
249-
response = requests.post(
250-
self.certurl, data=token.serialize(), headers=headers, timeout=60
251-
)
266+
response = requests.post(self.certurl, data=token.serialize(), headers=headers, timeout=60)
252267
self.nonce = response.headers["Replay-Nonce"]
253-
self.debugresponse(response)
254268
return response.text
255269

256270
def clean_headers(self, headers):
@@ -292,6 +306,4 @@ def pprint(self, data):
292306
"""
293307
A simple hack to learn pprint to add some spaces upfront. Better for viewing
294308
"""
295-
print(
296-
"\n".join([" " + x for x in pprint.pformat(data, width=80).splitlines()])
297-
)
309+
print("\n".join([" " + x for x in pprint.pformat(data, width=80).splitlines()]))

app/acme_directory_configuration.py

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from dataclasses import dataclass
2+
3+
4+
@dataclass
5+
class ACMEDirectoryConfiguration:
6+
"""
7+
This data is generated from the directory endpoint. These endpoints can be different per server.
8+
"""
9+
10+
new_order_url: str
11+
new_account_url: str
12+
new_nonce_url: str
13+
revoke_cert_url: str

0 commit comments

Comments
 (0)