33
44from dataclasses import dataclass
55from enum import auto
6+ from hashlib import sha256
7+ from typing import Literal
68
79from parsec ._parsec import (
810 AsyncEnrollmentAcceptPayload ,
1517 PkiSignatureAlgorithm ,
1618 UserCertificate ,
1719 VerifyKey ,
20+ X509Certificate ,
1821 anonymous_cmds ,
1922 authenticated_cmds ,
2023)
@@ -49,8 +52,8 @@ class AsyncEnrollmentSubmitBadOutcome(BadOutcomeEnum):
4952class AsyncEnrollmentSubmitValidateBadOutcome (BadOutcomeEnum ):
5053 INVALID_SUBMIT_PAYLOAD = auto ()
5154 INVALID_SUBMIT_PAYLOAD_SIGNATURE = auto () # TODO: server-side validation not yet implemented
52- INVALID_DER_X509_CERTIFICATE = auto () # TODO: server-side validation not yet implemented
53- INVALID_X509_TRUSTCHAIN = auto () # TODO: server-side validation not yet implemented
55+ INVALID_DER_X509_CERTIFICATE = auto ()
56+ INVALID_X509_TRUSTCHAIN = auto ()
5457
5558
5659class AsyncEnrollmentInfoBadOutcome (BadOutcomeEnum ):
@@ -165,21 +168,104 @@ class AsyncEnrollmentListItem:
165168 submit_payload_signature : AsyncEnrollmentPayloadSignature
166169
167170
171+ # TODO: `X509Certificate` is error prone since it's validation is currently lazy
172+ # (see https://github.com/Scille/parsec-cloud/issues/12012).
173+ @dataclass (slots = True , frozen = True )
174+ class X509Certificate2 :
175+ der : bytes
176+ issuer : bytes
177+ subject : bytes
178+ sha256_fingerprint : bytes
179+
180+ @classmethod
181+ def from_der (cls , der : bytes ) -> X509Certificate2 :
182+ certificate = X509Certificate .from_der (der )
183+ return X509Certificate2 (
184+ der = der ,
185+ # Raises `PkiInvalidCertificateDER` which is a subclass of `ValueError`
186+ issuer = certificate .issuer (),
187+ subject = certificate .subject (),
188+ sha256_fingerprint = sha256 (der ).digest (),
189+ )
190+
191+ def __repr__ (self ) -> str :
192+ return f"{ self .__class__ .__qualname__ } (subject={ self .subject } , issuer={ self .issuer } )"
193+
194+
195+ def _validate_x509_trustchain (
196+ leaf : bytes , intermediates : list [bytes ]
197+ ) -> (
198+ list [X509Certificate2 ]
199+ | Literal ["INVALID_DER_X509_CERTIFICATE" ]
200+ | Literal ["INVALID_X509_TRUSTCHAIN" ]
201+ ):
202+ # Validate format for each x509 certificate
203+ try :
204+ author_der_x509_certificate = X509Certificate2 .from_der (leaf )
205+ intermediate_der_x509_certificates = [
206+ X509Certificate2 .from_der (certif ) for certif in intermediates
207+ ]
208+ except ValueError :
209+ return "INVALID_DER_X509_CERTIFICATE"
210+ x509_trustchain = [author_der_x509_certificate , * intermediate_der_x509_certificates ]
211+
212+ # Validate the x509 trustchain:
213+ # - Should not be longer than 10 items (including leaf and root)
214+ # - Each intermediate certificate should be used exactly once
215+
216+ if len (intermediate_der_x509_certificates ) > 8 : # 8 since we exclude leaf and root
217+ return "INVALID_X509_TRUSTCHAIN"
218+
219+ current = author_der_x509_certificate
220+ while True :
221+ try :
222+ i , current = next (
223+ (i , c )
224+ for i , c in enumerate (intermediate_der_x509_certificates )
225+ if c .subject == current .issuer
226+ )
227+ intermediate_der_x509_certificates .pop (i )
228+ except StopIteration : # Parent not found, we should have reached the root
229+ if intermediate_der_x509_certificates :
230+ # Unused intermediate certificates is not allowed!
231+ return "INVALID_X509_TRUSTCHAIN"
232+ break
233+
234+ return x509_trustchain
235+
236+
168237def async_enrollment_submit_validate (
169238 now : DateTime ,
170239 submit_payload : bytes ,
171240 submit_payload_signature : AsyncEnrollmentPayloadSignature ,
172- ) -> tuple [AsyncEnrollmentSubmitPayload ] | AsyncEnrollmentSubmitValidateBadOutcome :
241+ ) -> (
242+ tuple [AsyncEnrollmentSubmitPayload , list [X509Certificate2 ] | None ]
243+ | AsyncEnrollmentSubmitValidateBadOutcome
244+ ):
173245 try :
174246 p_data = AsyncEnrollmentSubmitPayload .load (submit_payload )
175247 except ValueError :
176248 return AsyncEnrollmentSubmitValidateBadOutcome .INVALID_SUBMIT_PAYLOAD
177249
250+ if isinstance (submit_payload_signature , AsyncEnrollmentPayloadSignaturePKI ):
251+ match _validate_x509_trustchain (
252+ submit_payload_signature .author_der_x509_certificate ,
253+ submit_payload_signature .intermediate_der_x509_certificates ,
254+ ):
255+ case list () as x509_trustchain :
256+ pass
257+ case "INVALID_DER_X509_CERTIFICATE" :
258+ return AsyncEnrollmentSubmitValidateBadOutcome .INVALID_DER_X509_CERTIFICATE
259+ case "INVALID_X509_TRUSTCHAIN" :
260+ return AsyncEnrollmentSubmitValidateBadOutcome .INVALID_X509_TRUSTCHAIN
261+ else :
262+ x509_trustchain = None
263+
178264 # TODO: Payload signature is currently not validated.
179265 # Note this has no big impact on security since the client always
180266 # validate the payload signature on its end.
181267
182- return (p_data ,)
268+ return (p_data , x509_trustchain )
183269
184270
185271def async_enrollment_accept_validate (
@@ -193,7 +279,12 @@ def async_enrollment_accept_validate(
193279 redacted_user_certificate : bytes ,
194280 redacted_device_certificate : bytes ,
195281) -> (
196- tuple [AsyncEnrollmentAcceptPayload , UserCertificate , DeviceCertificate ]
282+ tuple [
283+ AsyncEnrollmentAcceptPayload ,
284+ UserCertificate ,
285+ DeviceCertificate ,
286+ list [X509Certificate2 ] | None ,
287+ ]
197288 | TimestampOutOfBallpark
198289 | AsyncEnrollmentAcceptValidateBadOutcome
199290):
@@ -202,6 +293,20 @@ def async_enrollment_accept_validate(
202293 except ValueError :
203294 return AsyncEnrollmentAcceptValidateBadOutcome .INVALID_ACCEPT_PAYLOAD
204295
296+ if isinstance (accept_payload_signature , AsyncEnrollmentPayloadSignaturePKI ):
297+ match _validate_x509_trustchain (
298+ accept_payload_signature .author_der_x509_certificate ,
299+ accept_payload_signature .intermediate_der_x509_certificates ,
300+ ):
301+ case list () as x509_trustchain :
302+ pass
303+ case "INVALID_DER_X509_CERTIFICATE" :
304+ return AsyncEnrollmentAcceptValidateBadOutcome .INVALID_DER_X509_CERTIFICATE
305+ case "INVALID_X509_TRUSTCHAIN" :
306+ return AsyncEnrollmentAcceptValidateBadOutcome .INVALID_X509_TRUSTCHAIN
307+ else :
308+ x509_trustchain = None
309+
205310 # TODO: Payload signature is currently not validated.
206311 # Note this has no big impact on security since the client always
207312 # validate the payload signature on its end.
@@ -255,7 +360,7 @@ def async_enrollment_accept_validate(
255360 if not d_data .redacted_compare (rd_data ):
256361 return AsyncEnrollmentAcceptValidateBadOutcome .CERTIFICATES_REDACTED_MISMATCH
257362
258- return p_data , u_data , d_data
363+ return p_data , u_data , d_data , x509_trustchain
259364
260365
261366class BaseAsyncEnrollmentComponent :
@@ -303,6 +408,7 @@ async def accept(
303408 now : DateTime ,
304409 organization_id : OrganizationID ,
305410 author : DeviceID ,
411+ author_verify_key : VerifyKey ,
306412 enrollment_id : AsyncEnrollmentID ,
307413 accept_payload : bytes ,
308414 accept_payload_signature : AsyncEnrollmentPayloadSignature ,
@@ -549,6 +655,7 @@ async def api_async_enrollment_accept(
549655 now = DateTime .now (),
550656 organization_id = client_ctx .organization_id ,
551657 author = client_ctx .device_id ,
658+ author_verify_key = client_ctx .device_verify_key ,
552659 enrollment_id = req .enrollment_id ,
553660 accept_payload = req .accept_payload ,
554661 accept_payload_signature = accept_payload_signature ,
0 commit comments