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)
3437logger = get_logger ()
3538
3639
40+ # Maximum number of certificates a trustchain can contains (excluding leaf and root).
41+ # We follow OpenSSL's default here, which should be plenty for most use-cases.
42+ # (see https://docs.openssl.org/3.2/man3/SSL_CTX_set_verify/)
43+ MAX_X509_INTERMEDIATE_CERTIFICATES_DEPTH = 100
44+
45+
3746@dataclass (slots = True )
3847class AsyncEnrollmentEmailAlreadySubmitted (BadOutcome ):
3948 submitted_on : DateTime
@@ -49,8 +58,8 @@ class AsyncEnrollmentSubmitBadOutcome(BadOutcomeEnum):
4958class AsyncEnrollmentSubmitValidateBadOutcome (BadOutcomeEnum ):
5059 INVALID_SUBMIT_PAYLOAD = auto ()
5160 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
61+ INVALID_DER_X509_CERTIFICATE = auto ()
62+ INVALID_X509_TRUSTCHAIN = auto ()
5463
5564
5665class AsyncEnrollmentInfoBadOutcome (BadOutcomeEnum ):
@@ -165,21 +174,104 @@ class AsyncEnrollmentListItem:
165174 submit_payload_signature : AsyncEnrollmentPayloadSignature
166175
167176
177+ # TODO: `X509Certificate` is error prone since it's validation is currently lazy
178+ # (see https://github.com/Scille/parsec-cloud/issues/12012).
179+ @dataclass (slots = True , frozen = True )
180+ class X509Certificate2 :
181+ der : bytes
182+ issuer : bytes
183+ subject : bytes
184+ sha256_fingerprint : bytes
185+
186+ @classmethod
187+ def from_der (cls , der : bytes ) -> X509Certificate2 :
188+ certificate = X509Certificate .from_der (der )
189+ return X509Certificate2 (
190+ der = der ,
191+ # Raises `PkiInvalidCertificateDER` which is a subclass of `ValueError`
192+ issuer = certificate .issuer (),
193+ subject = certificate .subject (),
194+ sha256_fingerprint = sha256 (der ).digest (),
195+ )
196+
197+ def __repr__ (self ) -> str :
198+ return f"{ self .__class__ .__qualname__ } (subject={ self .subject } , issuer={ self .issuer } )"
199+
200+
201+ def _validate_x509_trustchain (
202+ leaf : bytes , intermediates : list [bytes ]
203+ ) -> (
204+ list [X509Certificate2 ]
205+ | Literal ["INVALID_DER_X509_CERTIFICATE" ]
206+ | Literal ["INVALID_X509_TRUSTCHAIN" ]
207+ ):
208+ # Validate format for each x509 certificate
209+ try :
210+ author_der_x509_certificate = X509Certificate2 .from_der (leaf )
211+ intermediate_der_x509_certificates = [
212+ X509Certificate2 .from_der (certif ) for certif in intermediates
213+ ]
214+ except ValueError :
215+ return "INVALID_DER_X509_CERTIFICATE"
216+ x509_trustchain = [author_der_x509_certificate , * intermediate_der_x509_certificates ]
217+
218+ # Validate the x509 trustchain:
219+ # - Should not be longer than `MAX_X509_INTERMEDIATE_CERTIFICATES_DEPTH` items (excluding leaf and root)
220+ # - Each intermediate certificate should be used exactly once
221+
222+ if len (intermediate_der_x509_certificates ) > MAX_X509_INTERMEDIATE_CERTIFICATES_DEPTH :
223+ return "INVALID_X509_TRUSTCHAIN"
224+
225+ current = author_der_x509_certificate
226+ while True :
227+ try :
228+ i , current = next (
229+ (i , c )
230+ for i , c in enumerate (intermediate_der_x509_certificates )
231+ if c .subject == current .issuer
232+ )
233+ intermediate_der_x509_certificates .pop (i )
234+ except StopIteration : # Parent not found, we should have reached the root
235+ if intermediate_der_x509_certificates :
236+ # Unused intermediate certificates is not allowed!
237+ return "INVALID_X509_TRUSTCHAIN"
238+ break
239+
240+ return x509_trustchain
241+
242+
168243def async_enrollment_submit_validate (
169244 now : DateTime ,
170245 submit_payload : bytes ,
171246 submit_payload_signature : AsyncEnrollmentPayloadSignature ,
172- ) -> tuple [AsyncEnrollmentSubmitPayload ] | AsyncEnrollmentSubmitValidateBadOutcome :
247+ ) -> (
248+ tuple [AsyncEnrollmentSubmitPayload , list [X509Certificate2 ] | None ]
249+ | AsyncEnrollmentSubmitValidateBadOutcome
250+ ):
173251 try :
174252 p_data = AsyncEnrollmentSubmitPayload .load (submit_payload )
175253 except ValueError :
176254 return AsyncEnrollmentSubmitValidateBadOutcome .INVALID_SUBMIT_PAYLOAD
177255
256+ if isinstance (submit_payload_signature , AsyncEnrollmentPayloadSignaturePKI ):
257+ match _validate_x509_trustchain (
258+ submit_payload_signature .author_der_x509_certificate ,
259+ submit_payload_signature .intermediate_der_x509_certificates ,
260+ ):
261+ case list () as x509_trustchain :
262+ pass
263+ case "INVALID_DER_X509_CERTIFICATE" :
264+ return AsyncEnrollmentSubmitValidateBadOutcome .INVALID_DER_X509_CERTIFICATE
265+ case "INVALID_X509_TRUSTCHAIN" :
266+ return AsyncEnrollmentSubmitValidateBadOutcome .INVALID_X509_TRUSTCHAIN
267+ else :
268+ x509_trustchain = None
269+
178270 # TODO: Payload signature is currently not validated.
179271 # Note this has no big impact on security since the client always
180272 # validate the payload signature on its end.
181273
182- return (p_data ,)
274+ return (p_data , x509_trustchain )
183275
184276
185277def async_enrollment_accept_validate (
@@ -193,7 +285,12 @@ def async_enrollment_accept_validate(
193285 redacted_user_certificate : bytes ,
194286 redacted_device_certificate : bytes ,
195287) -> (
196- tuple [AsyncEnrollmentAcceptPayload , UserCertificate , DeviceCertificate ]
288+ tuple [
289+ AsyncEnrollmentAcceptPayload ,
290+ UserCertificate ,
291+ DeviceCertificate ,
292+ list [X509Certificate2 ] | None ,
293+ ]
197294 | TimestampOutOfBallpark
198295 | AsyncEnrollmentAcceptValidateBadOutcome
199296):
@@ -202,6 +299,20 @@ def async_enrollment_accept_validate(
202299 except ValueError :
203300 return AsyncEnrollmentAcceptValidateBadOutcome .INVALID_ACCEPT_PAYLOAD
204301
302+ if isinstance (accept_payload_signature , AsyncEnrollmentPayloadSignaturePKI ):
303+ match _validate_x509_trustchain (
304+ accept_payload_signature .author_der_x509_certificate ,
305+ accept_payload_signature .intermediate_der_x509_certificates ,
306+ ):
307+ case list () as x509_trustchain :
308+ pass
309+ case "INVALID_DER_X509_CERTIFICATE" :
310+ return AsyncEnrollmentAcceptValidateBadOutcome .INVALID_DER_X509_CERTIFICATE
311+ case "INVALID_X509_TRUSTCHAIN" :
312+ return AsyncEnrollmentAcceptValidateBadOutcome .INVALID_X509_TRUSTCHAIN
313+ else :
314+ x509_trustchain = None
315+
205316 # TODO: Payload signature is currently not validated.
206317 # Note this has no big impact on security since the client always
207318 # validate the payload signature on its end.
@@ -255,7 +366,7 @@ def async_enrollment_accept_validate(
255366 if not d_data .redacted_compare (rd_data ):
256367 return AsyncEnrollmentAcceptValidateBadOutcome .CERTIFICATES_REDACTED_MISMATCH
257368
258- return p_data , u_data , d_data
369+ return p_data , u_data , d_data , x509_trustchain
259370
260371
261372class BaseAsyncEnrollmentComponent :
@@ -303,6 +414,7 @@ async def accept(
303414 now : DateTime ,
304415 organization_id : OrganizationID ,
305416 author : DeviceID ,
417+ author_verify_key : VerifyKey ,
306418 enrollment_id : AsyncEnrollmentID ,
307419 accept_payload : bytes ,
308420 accept_payload_signature : AsyncEnrollmentPayloadSignature ,
@@ -549,6 +661,7 @@ async def api_async_enrollment_accept(
549661 now = DateTime .now (),
550662 organization_id = client_ctx .organization_id ,
551663 author = client_ctx .device_id ,
664+ author_verify_key = client_ctx .device_verify_key ,
552665 enrollment_id = req .enrollment_id ,
553666 accept_payload = req .accept_payload ,
554667 accept_payload_signature = accept_payload_signature ,
0 commit comments