Skip to content

Commit 0faa602

Browse files
committed
Async enrollement postgresql implementation
1 parent cdd029a commit 0faa602

16 files changed

+1762
-77
lines changed

server/parsec/components/async_enrollment.py

Lines changed: 119 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
from dataclasses import dataclass
55
from enum import auto
6+
from hashlib import sha256
7+
from typing import Literal
68

79
from parsec._parsec import (
810
AsyncEnrollmentAcceptPayload,
@@ -15,6 +17,7 @@
1517
PkiSignatureAlgorithm,
1618
UserCertificate,
1719
VerifyKey,
20+
X509Certificate,
1821
anonymous_cmds,
1922
authenticated_cmds,
2023
)
@@ -34,6 +37,12 @@
3437
logger = 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)
3847
class AsyncEnrollmentEmailAlreadySubmitted(BadOutcome):
3948
submitted_on: DateTime
@@ -49,8 +58,8 @@ class AsyncEnrollmentSubmitBadOutcome(BadOutcomeEnum):
4958
class 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

5665
class 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+
168243
def 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

185277
def 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

261372
class 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,

server/parsec/components/memory/async_enrollment.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
OrganizationID,
1212
UserCertificate,
1313
UserProfile,
14+
VerifyKey,
1415
)
1516
from parsec.ballpark import (
1617
RequireGreaterTimestamp,
@@ -86,7 +87,7 @@ async def submit(
8687
submit_payload=submit_payload,
8788
submit_payload_signature=submit_payload_signature,
8889
):
89-
case (s_payload,):
90+
case (s_payload, _):
9091
pass
9192
case error:
9293
return error
@@ -259,6 +260,7 @@ async def accept(
259260
now: DateTime,
260261
organization_id: OrganizationID,
261262
author: DeviceID,
263+
author_verify_key: VerifyKey,
262264
enrollment_id: AsyncEnrollmentID,
263265
accept_payload: bytes,
264266
accept_payload_signature: AsyncEnrollmentPayloadSignature,
@@ -317,15 +319,15 @@ async def accept(
317319
match async_enrollment_accept_validate(
318320
now=now,
319321
expected_author=author,
320-
author_verify_key=author_device.cooked.verify_key,
322+
author_verify_key=author_verify_key,
321323
accept_payload=accept_payload,
322324
accept_payload_signature=accept_payload_signature,
323325
user_certificate=submitter_user_certificate,
324326
device_certificate=submitter_device_certificate,
325327
redacted_user_certificate=submitter_redacted_user_certificate,
326328
redacted_device_certificate=submitter_redacted_device_certificate,
327329
):
328-
case (a_payload, u_certif, d_certif):
330+
case (a_payload, u_certif, d_certif, _):
329331
pass
330332
case error:
331333
return error

0 commit comments

Comments
 (0)