Skip to content

Commit b475f78

Browse files
committed
Async enrollement postgresql implementation
1 parent 5f024ae commit b475f78

16 files changed

+1868
-77
lines changed

server/parsec/components/async_enrollment.py

Lines changed: 113 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
)
@@ -49,8 +52,8 @@ class AsyncEnrollmentSubmitBadOutcome(BadOutcomeEnum):
4952
class 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

5659
class 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+
168237
def 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

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

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

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
Lines changed: 141 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,152 @@
11
# Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS
22
from __future__ import annotations
33

4+
from typing import override
5+
6+
from parsec._parsec import (
7+
AsyncEnrollmentID,
8+
DateTime,
9+
DeviceCertificate,
10+
DeviceID,
11+
OrganizationID,
12+
UserCertificate,
13+
VerifyKey,
14+
)
15+
from parsec.ballpark import RequireGreaterTimestamp, TimestampOutOfBallpark
416
from parsec.components.async_enrollment import (
17+
AsyncEnrollmentAcceptBadOutcome,
18+
AsyncEnrollmentAcceptValidateBadOutcome,
19+
AsyncEnrollmentEmailAlreadySubmitted,
20+
AsyncEnrollmentInfo,
21+
AsyncEnrollmentInfoBadOutcome,
22+
AsyncEnrollmentListBadOutcome,
23+
AsyncEnrollmentListItem,
24+
AsyncEnrollmentPayloadSignature,
25+
AsyncEnrollmentRejectBadOutcome,
26+
AsyncEnrollmentSubmitBadOutcome,
27+
AsyncEnrollmentSubmitValidateBadOutcome,
528
BaseAsyncEnrollmentComponent,
629
)
7-
from parsec.components.postgresql import AsyncpgPool
30+
from parsec.components.postgresql import AsyncpgConnection, AsyncpgPool
31+
from parsec.components.postgresql.async_enrollment_accept import async_enrollment_accept
32+
from parsec.components.postgresql.async_enrollment_info import async_enrollment_info
33+
from parsec.components.postgresql.async_enrollment_list import async_enrollment_list
34+
from parsec.components.postgresql.async_enrollment_reject import async_enrollment_reject
35+
from parsec.components.postgresql.async_enrollment_submit import async_enrollment_submit
36+
from parsec.components.postgresql.utils import no_transaction, transaction
837

938

1039
class PGAsyncEnrollmentComponent(BaseAsyncEnrollmentComponent):
1140
def __init__(self, pool: AsyncpgPool) -> None:
1241
self.pool = pool
42+
43+
@override
44+
@transaction
45+
async def submit(
46+
self,
47+
conn: AsyncpgConnection,
48+
now: DateTime,
49+
organization_id: OrganizationID,
50+
enrollment_id: AsyncEnrollmentID,
51+
force: bool,
52+
submit_payload: bytes,
53+
submit_payload_signature: AsyncEnrollmentPayloadSignature,
54+
) -> (
55+
None
56+
| AsyncEnrollmentSubmitValidateBadOutcome
57+
| AsyncEnrollmentEmailAlreadySubmitted
58+
| AsyncEnrollmentSubmitBadOutcome
59+
):
60+
return await async_enrollment_submit(
61+
conn,
62+
now,
63+
organization_id,
64+
enrollment_id,
65+
force,
66+
submit_payload,
67+
submit_payload_signature,
68+
)
69+
70+
@override
71+
@no_transaction
72+
async def info(
73+
self,
74+
conn: AsyncpgConnection,
75+
organization_id: OrganizationID,
76+
enrollment_id: AsyncEnrollmentID,
77+
) -> AsyncEnrollmentInfo | AsyncEnrollmentInfoBadOutcome:
78+
return await async_enrollment_info(
79+
conn,
80+
organization_id,
81+
enrollment_id,
82+
)
83+
84+
@override
85+
@no_transaction
86+
async def list(
87+
self,
88+
conn: AsyncpgConnection,
89+
organization_id: OrganizationID,
90+
author: DeviceID,
91+
) -> list[AsyncEnrollmentListItem] | AsyncEnrollmentListBadOutcome:
92+
return await async_enrollment_list(
93+
conn,
94+
organization_id,
95+
author,
96+
)
97+
98+
@override
99+
@transaction
100+
async def reject(
101+
self,
102+
conn: AsyncpgConnection,
103+
now: DateTime,
104+
organization_id: OrganizationID,
105+
author: DeviceID,
106+
enrollment_id: AsyncEnrollmentID,
107+
) -> None | AsyncEnrollmentRejectBadOutcome:
108+
return await async_enrollment_reject(
109+
conn,
110+
now,
111+
organization_id,
112+
author,
113+
enrollment_id,
114+
)
115+
116+
@override
117+
@transaction
118+
async def accept(
119+
self,
120+
conn: AsyncpgConnection,
121+
now: DateTime,
122+
organization_id: OrganizationID,
123+
author: DeviceID,
124+
author_verify_key: VerifyKey,
125+
enrollment_id: AsyncEnrollmentID,
126+
accept_payload: bytes,
127+
accept_payload_signature: AsyncEnrollmentPayloadSignature,
128+
submitter_user_certificate: bytes,
129+
submitter_redacted_user_certificate: bytes,
130+
submitter_device_certificate: bytes,
131+
submitter_redacted_device_certificate: bytes,
132+
) -> (
133+
tuple[UserCertificate, DeviceCertificate]
134+
| AsyncEnrollmentAcceptValidateBadOutcome
135+
| AsyncEnrollmentAcceptBadOutcome
136+
| RequireGreaterTimestamp
137+
| TimestampOutOfBallpark
138+
):
139+
return await async_enrollment_accept(
140+
conn,
141+
now,
142+
organization_id,
143+
author,
144+
author_verify_key,
145+
enrollment_id,
146+
accept_payload,
147+
accept_payload_signature,
148+
submitter_user_certificate,
149+
submitter_redacted_user_certificate,
150+
submitter_device_certificate,
151+
submitter_redacted_device_certificate,
152+
)

0 commit comments

Comments
 (0)