Skip to content

Commit 611c942

Browse files
committed
Implement ScwsComponent.anonymous_server_scws_service_mutual_challenges and load RSA keys eagerly when building ScwsConfig
1 parent 86c9dbf commit 611c942

File tree

4 files changed

+361
-26
lines changed

4 files changed

+361
-26
lines changed

server/parsec/cli/run.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -684,14 +684,19 @@ async def run_cmd(
684684
scws_idopte_public_keys_file is not None
685685
and scws_web_application_private_key_file is not None
686686
):
687-
idopte_public_keys_pem = await asyncio.to_thread(scws_idopte_public_keys_file.read_text)
688-
web_application_private_key_pem = await asyncio.to_thread(
689-
scws_web_application_private_key_file.read_text
690-
)
691-
scws_config = ScwsConfig(
692-
idopte_public_keys_pem=idopte_public_keys_pem,
693-
web_application_private_key_pem=web_application_private_key_pem,
694-
)
687+
try:
688+
idopte_public_keys_pem = await asyncio.to_thread(
689+
scws_idopte_public_keys_file.read_bytes
690+
)
691+
web_application_private_key_pem = await asyncio.to_thread(
692+
scws_web_application_private_key_file.read_bytes
693+
)
694+
scws_config = ScwsConfig.new(
695+
idopte_public_keys_pem=idopte_public_keys_pem,
696+
web_application_private_key_pem=web_application_private_key_pem,
697+
)
698+
except ValueError as exc:
699+
raise ValueError(f"Invalid SCWS configuration: {exc}") from exc
695700
elif (
696701
scws_idopte_public_keys_file is not None
697702
or scws_web_application_private_key_file is not None

server/parsec/components/scws.py

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,110 @@
11
# Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS
22
from __future__ import annotations
33

4+
import re
5+
6+
from cryptography.exceptions import InvalidSignature
7+
from cryptography.hazmat.primitives.asymmetric import padding
8+
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
9+
from cryptography.hazmat.primitives.serialization import load_pem_public_key
10+
411
from parsec._parsec import anonymous_server_cmds
512
from parsec.api import api
613
from parsec.client_context import AnonymousServerClientContext
714
from parsec.config import BackendConfig
15+
from parsec.logging import get_logger
16+
17+
logger = get_logger()
18+
19+
# The code in this file is based on the Idopte code example from
20+
# https://idopte.fr/scwsapi/javascript/2_API/envsetup.html#server-side
21+
22+
# Regex to extract PEM public key blocks from the `IdoptePublicKeys`` file
23+
_PEM_PUBLIC_KEY_RE = rb"-----BEGIN PUBLIC KEY-----.*?-----END PUBLIC KEY-----"
24+
25+
26+
def parse_idopte_public_keys(pem: bytes) -> list[RSAPublicKey | None]:
27+
"""
28+
Parse the `IdoptePublicKeys` file and return a list of public keys.
29+
30+
Revoked keys (whose PEM body contains `!REVOKED!`) are represented as `None`.
31+
"""
32+
keys: list[RSAPublicKey | None] = []
33+
for match in re.finditer(_PEM_PUBLIC_KEY_RE, pem, re.DOTALL):
34+
block = match.group()
35+
if b"!REVOKED!" in block:
36+
keys.append(None)
37+
else:
38+
public_key = load_pem_public_key(block)
39+
assert isinstance(public_key, RSAPublicKey)
40+
keys.append(public_key)
41+
return keys
42+
43+
44+
def _raw_rsa_sign(private_key: RSAPrivateKey, data: bytes) -> bytes:
45+
"""
46+
Sign raw data using PKCS#1 v1.5 signature padding *without* DigestInfo.
47+
48+
This is the equivalent of PHP's `openssl_private_encrypt` used in Idopte's example:
49+
50+
$k = <<<EOT
51+
-----BEGIN RSA PRIVATE KEY-----
52+
MIIBOgIBAAJBAMPMNNpbZZddeT/GTjU0PWuuN9VEGpxXJTAkmZY02o8238fQ2ynt
53+
N40FVl08YksWBO/74XEjU30mAjuaz/FB2kkCAwEAAQJBALoMlsROSLCWD5q8EqCX
54+
rS1e9IrgFfEtFZczkAWc33lo3FnFeFTXSMVCloNCBWU35od4zTOhdRPAWpQ1Mzxi
55+
aCkCIQD9qjKjNvbDXjUcCNqdiJxPDlPGpa78yzyCCUA/+TNwVwIhAMWZoqZO3eWq
56+
SCBTLelVQsg6CwJh9W7vlezvWxUni+ZfAiAopBAg3jmC66EOsMx12OFSOTVq6jiy
57+
/8zd+KV2mnKHWQIgVpZiLZo1piQeAvwwDCUuZGr61Ap08C3QdsjUEssHhOUCIBee
58+
72JZuJeABcv7lHhAWzsiCddVAkdnZKUo6ubaxw3u
59+
-----END RSA PRIVATE KEY-----
60+
EOT;
61+
$output = "";
62+
openssl_private_encrypt(hex2bin($_GET["rnd"]), $output, openssl_pkey_get_private($k));
63+
echo bin2hex($output);
64+
65+
see: https://idopte.fr/scwsapi/javascript/2_API/envsetup.html#server-side
66+
"""
67+
key_size_bytes = (private_key.key_size + 7) // 8
68+
69+
# PKCS#1 v1.5 signature type padding: `0x00 0x01 || PS || 0x00 || data`
70+
# where PS is a string of `0xFF` bytes making the total equal to the key
71+
# size in bytes (with at least 8 bytes of `0xFF`).
72+
ps_len = key_size_bytes - len(data) - 3
73+
if ps_len < 8:
74+
raise ValueError("Data too long for the RSA key size")
75+
padded = b"\x00\x01" + (b"\xff" * ps_len) + b"\x00" + data
76+
77+
# Raw RSA: compute m^d mod n
78+
padded_int = int.from_bytes(padded, byteorder="big")
79+
private_numbers = private_key.private_numbers()
80+
signature_int = pow(padded_int, private_numbers.d, private_numbers.public_numbers.n)
81+
return signature_int.to_bytes(key_size_bytes, byteorder="big")
82+
83+
84+
def _raw_rsa_verify(public_key: RSAPublicKey, data: bytes, signature: bytes) -> bool:
85+
"""
86+
Verify a raw RSA PKCS#1 v1.5 signature without DigestInfo.
87+
88+
This is the equivalent of PHP's `openssl_public_decrypt` used in Idopte's example:
89+
90+
$challenge = $_SESSION['challenge'];
91+
$output = "";
92+
openssl_public_decrypt(hex2bin($_GET["cryptogram"]), $output, openssl_pkey_get_public($k));
93+
if ($output == $challenge) {
94+
...
95+
96+
see: https://idopte.fr/scwsapi/javascript/2_API/envsetup.html#server-side
97+
"""
98+
try:
99+
recovered = public_key.recover_data_from_signature(
100+
signature,
101+
padding.PKCS1v15(),
102+
# TODO: use `hashes.NoDigestInfo` instead of `None` once we upgrade to cryptography >= 47.0.0
103+
None,
104+
)
105+
return recovered == data
106+
except InvalidSignature:
107+
return False
8108

9109

10110
class ScwsComponent:
@@ -17,4 +117,39 @@ async def anonymous_server_scws_service_mutual_challenges(
17117
client_ctx: AnonymousServerClientContext,
18118
req: anonymous_server_cmds.latest.scws_service_mutual_challenges.Req,
19119
) -> anonymous_server_cmds.latest.scws_service_mutual_challenges.Rep:
20-
raise NotImplementedError()
120+
Rep = anonymous_server_cmds.latest.scws_service_mutual_challenges
121+
122+
scws_config = self._config.scws_config
123+
if scws_config is None:
124+
return Rep.RepNotAvailable()
125+
126+
# Validate key ID
127+
key_id = req.scws_service_challenge_key_id
128+
if key_id < 0 or key_id >= len(scws_config.idopte_public_keys):
129+
return Rep.RepUnknownScwsServiceChallengeKeyId()
130+
131+
public_key = scws_config.idopte_public_keys[key_id]
132+
if public_key is None:
133+
# Key has been revoked
134+
return Rep.RepUnknownScwsServiceChallengeKeyId()
135+
136+
# Verify the SCWS service challenge signature
137+
if not _raw_rsa_verify(
138+
public_key,
139+
req.scws_service_challenge_payload,
140+
req.scws_service_challenge_signature,
141+
):
142+
return Rep.RepInvalidScwsServiceChallengeSignature()
143+
144+
# Sign the web application challenge payload
145+
try:
146+
web_application_challenge_signature = _raw_rsa_sign(
147+
scws_config.web_application_private_key,
148+
req.web_application_challenge_payload,
149+
)
150+
except ValueError:
151+
return Rep.RepInvalidWebApplicationChallengePayload()
152+
153+
return Rep.RepOk(
154+
web_application_challenge_signature=web_application_challenge_signature,
155+
)

server/parsec/config.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from typing import TYPE_CHECKING, Literal
88
from urllib.parse import urlparse, urlunparse
99

10+
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
11+
from cryptography.hazmat.primitives.serialization import load_pem_private_key
1012
from jinja2.environment import Environment
1113

1214
from parsec._parsec import (
@@ -254,8 +256,36 @@ class OpenBaoConfig:
254256

255257
@dataclass(slots=True)
256258
class ScwsConfig:
257-
idopte_public_keys_pem: str
258-
web_application_private_key_pem: str
259+
# Order of the keys in the file is meaningful as it correspond to the key ID.
260+
# For this reasons, we represent the revoked keys in the list as `None`.
261+
idopte_public_keys: list[RSAPublicKey | None]
262+
web_application_private_key: RSAPrivateKey
263+
264+
@staticmethod
265+
def new(
266+
idopte_public_keys_pem: bytes,
267+
web_application_private_key_pem: bytes,
268+
) -> ScwsConfig:
269+
from parsec.components.scws import parse_idopte_public_keys
270+
271+
try:
272+
idopte_public_keys = parse_idopte_public_keys(idopte_public_keys_pem)
273+
except ValueError as exc:
274+
raise ValueError(f"Invalid Idopte public keys: {exc}") from exc
275+
276+
try:
277+
private_key = load_pem_private_key(
278+
web_application_private_key_pem,
279+
password=None,
280+
)
281+
except ValueError as exc:
282+
raise ValueError(f"Invalid web application private key: {exc}") from exc
283+
284+
assert isinstance(private_key, RSAPrivateKey)
285+
return ScwsConfig(
286+
idopte_public_keys=idopte_public_keys,
287+
web_application_private_key=private_key,
288+
)
259289

260290

261291
@dataclass(slots=True)

0 commit comments

Comments
 (0)