11# Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS
22from __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+
411from parsec ._parsec import anonymous_server_cmds
512from parsec .api import api
613from parsec .client_context import AnonymousServerClientContext
714from 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
10110class 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+ )
0 commit comments