correct-hooks 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
10
|
+
Ed25519PrivateKey,
|
|
11
|
+
Ed25519PublicKey,
|
|
12
|
+
)
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
14
|
+
from pydantic.alias_generators import to_camel
|
|
15
|
+
|
|
16
|
+
SIGNATURE_VERSION = "v1"
|
|
17
|
+
"""The signature version produced by this version of the library."""
|
|
18
|
+
|
|
19
|
+
_DEFAULT_SIGNATURE_TTL_SECONDS = 300
|
|
20
|
+
_DEFAULT_PUBLIC_VERIFYING_KEY_SET_TTL_SECONDS = 300
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CamelCaseBaseModel(BaseModel):
|
|
24
|
+
model_config = ConfigDict(
|
|
25
|
+
alias_generator=to_camel,
|
|
26
|
+
validate_by_name=True,
|
|
27
|
+
serialize_by_alias=True,
|
|
28
|
+
strict=True,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SecretSigningKey(CamelCaseBaseModel):
|
|
33
|
+
"""A key used to sign requests.
|
|
34
|
+
|
|
35
|
+
Must be kept secret and should only be held by the signer.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
kid: uuid.UUID = Field(strict=False)
|
|
39
|
+
kty: str
|
|
40
|
+
crv: str
|
|
41
|
+
x: str
|
|
42
|
+
d: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _PublicJwk(CamelCaseBaseModel):
|
|
46
|
+
kid: uuid.UUID = Field(strict=False)
|
|
47
|
+
kty: str
|
|
48
|
+
crv: str
|
|
49
|
+
x: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class PublicVerifyingKeySet(CamelCaseBaseModel):
|
|
53
|
+
"""A set of public keys used to verify signed requests."""
|
|
54
|
+
|
|
55
|
+
keys: list[_PublicJwk]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _SignatureComponents(CamelCaseBaseModel):
|
|
59
|
+
target_url: str
|
|
60
|
+
webhook_id: str
|
|
61
|
+
signed_at: int
|
|
62
|
+
key_id: uuid.UUID = Field(strict=False)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class InvalidKeyFound(Exception):
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SignatureData(CamelCaseBaseModel):
|
|
70
|
+
"""Header values to set on the webhook request.
|
|
71
|
+
You must set the request headers `correct-hooks-signature-components` and `correct-hooks-signature` to these values.
|
|
72
|
+
Webhook consumers will pass the values of the headers to the verify function. Future versions of Correct Hooks with
|
|
73
|
+
convenience functions that verify specific framework's request objects will expect these exact header names to be set.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
correct_hooks_signature_components: str
|
|
77
|
+
"""Set the header `correct-hooks-signature-components` to this value."""
|
|
78
|
+
correct_hooks_signature: str
|
|
79
|
+
"""Set the header `correct-hooks-signature` to this value."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class InvalidMethod(Exception):
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class InvalidSigningKey(Exception):
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class VerifiedData(CamelCaseBaseModel):
|
|
91
|
+
"""Validated request data."""
|
|
92
|
+
|
|
93
|
+
webhook_id: str
|
|
94
|
+
"""The validated `webhook_id` for the request. These id's can be put in a shared cache
|
|
95
|
+
by the webhook consumer and checked against for the highest level of replay protection.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class PublicVerifyingKeyNotFound(Exception):
|
|
100
|
+
"""The key needed to verify the request was not found in the verifying key set provided by the
|
|
101
|
+
webhook provider.
|
|
102
|
+
|
|
103
|
+
If you receive this error consistently, the webhook provider may need to update their
|
|
104
|
+
published public verifying key set. In some specific cases, this error can also be returned
|
|
105
|
+
due to an invalid signature.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class PublicVerifyingKeyRetrievalFailed(Exception):
|
|
110
|
+
"""The webhook provider's public verifying key set could not be retrieved.
|
|
111
|
+
|
|
112
|
+
If you get this error, likely either the `public_verifying_key_set_url` the `Verifier` was created with is incorrect,
|
|
113
|
+
or the webhook producer's endpoint is unavailable.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class InvalidSignature(Exception):
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class InvalidPublicVerifyingKey(Exception):
|
|
122
|
+
"""The public verifying key set retrieved from `public_verifying_key_set_url` could not be parsed and likely
|
|
123
|
+
needs to be updated by the webhook producer.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def generate_secret_signing_key() -> SecretSigningKey:
|
|
128
|
+
"""Generate a new `SecretSigningKey`."""
|
|
129
|
+
key_id = uuid.uuid4()
|
|
130
|
+
signing_key = Ed25519PrivateKey.generate()
|
|
131
|
+
secret_key = _encode_base64(signing_key.private_bytes_raw())
|
|
132
|
+
public_key = _encode_base64(signing_key.public_key().public_bytes_raw())
|
|
133
|
+
jwk = SecretSigningKey(
|
|
134
|
+
kid=key_id,
|
|
135
|
+
kty="OKP",
|
|
136
|
+
crv="Ed25519",
|
|
137
|
+
x=public_key,
|
|
138
|
+
d=secret_key,
|
|
139
|
+
)
|
|
140
|
+
return jwk
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def create_public_verifying_key_set(
|
|
144
|
+
secret_signing_keys: list[SecretSigningKey],
|
|
145
|
+
) -> PublicVerifyingKeySet:
|
|
146
|
+
"""Creates a `PublicVerifyingKeySet` from a list of `SecretSigningKey`.
|
|
147
|
+
|
|
148
|
+
The returned `PublicVerifyingKeySet` can be used to verify requests signed
|
|
149
|
+
by any of the `secret_signing_keys`.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
secret_signing_keys: The `SecretSigningKey`s to create the `PublicVerifyingKeySet` from.
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
InvalidKeyFound
|
|
156
|
+
"""
|
|
157
|
+
public_jwks = PublicVerifyingKeySet(keys=[])
|
|
158
|
+
for private_key in secret_signing_keys:
|
|
159
|
+
try:
|
|
160
|
+
public_key = _PublicJwk.model_validate(private_key.model_dump())
|
|
161
|
+
public_jwks.keys.append(public_key)
|
|
162
|
+
except Exception as e:
|
|
163
|
+
raise InvalidKeyFound from e
|
|
164
|
+
return public_jwks
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def sign(
|
|
168
|
+
*,
|
|
169
|
+
webhook_id: str,
|
|
170
|
+
target_url: str,
|
|
171
|
+
method: str,
|
|
172
|
+
request_body: bytes,
|
|
173
|
+
secret_signing_key: SecretSigningKey,
|
|
174
|
+
) -> SignatureData:
|
|
175
|
+
"""Creates a signature for a webhook request and returns the signature headers that should be set on the request.
|
|
176
|
+
|
|
177
|
+
You must set the request headers `correct-hooks-signature-components` and `correct-hooks-signature` to these values.
|
|
178
|
+
Webhook consumers will pass the values of the headers to the verify function. Future versions of Correct Hooks with
|
|
179
|
+
convenience functions that verify specific framework's request objects will expect these exact header names to be set.
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
`sign` should be called every time a webhook delivery is attempted. The signed headers should not be reused
|
|
183
|
+
between retried delivery attempts.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
webhook_id: An id for the webhook.
|
|
187
|
+
This id should be unique for the event triggering the webhook, and should
|
|
188
|
+
be reused for any delivery retries.
|
|
189
|
+
target_url: The url the webhook is being sent to.
|
|
190
|
+
Must be the exact url string provided by the webhook consumer.
|
|
191
|
+
method: The method the webhook is being sent with. Only `"POST"` is supported.
|
|
192
|
+
request_body: The exact body of the webhook request in bytes.
|
|
193
|
+
secret_signing_key: The `SecretSigningKey` the signature will be signed with.
|
|
194
|
+
|
|
195
|
+
Raises:
|
|
196
|
+
InvalidMethod
|
|
197
|
+
InvalidSigningKey
|
|
198
|
+
"""
|
|
199
|
+
if method != "POST":
|
|
200
|
+
raise InvalidMethod()
|
|
201
|
+
|
|
202
|
+
key_id = secret_signing_key.kid
|
|
203
|
+
signed_at = _seconds_since_unix_epoch()
|
|
204
|
+
|
|
205
|
+
signature_components = _SignatureComponents(
|
|
206
|
+
target_url=target_url,
|
|
207
|
+
webhook_id=webhook_id,
|
|
208
|
+
signed_at=signed_at,
|
|
209
|
+
key_id=key_id,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
encoded_signature_components = _encode_signature_components(signature_components)
|
|
213
|
+
signature_components_header = _version_header_value(encoded_signature_components)
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
secret_key_bytes = decode_base64(secret_signing_key.d)
|
|
217
|
+
signing_key = Ed25519PrivateKey.from_private_bytes(secret_key_bytes)
|
|
218
|
+
except Exception as e:
|
|
219
|
+
raise InvalidSigningKey from e
|
|
220
|
+
|
|
221
|
+
signature_message = _construct_signature_message(signature_components, request_body)
|
|
222
|
+
|
|
223
|
+
signature = signing_key.sign(signature_message)
|
|
224
|
+
signature_base64 = _encode_base64(signature)
|
|
225
|
+
signature_header_value = _version_header_value(signature_base64)
|
|
226
|
+
|
|
227
|
+
return SignatureData(
|
|
228
|
+
correct_hooks_signature_components=signature_components_header,
|
|
229
|
+
correct_hooks_signature=signature_header_value,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class Verifier:
|
|
234
|
+
"""Verifier for signed webhook requests
|
|
235
|
+
|
|
236
|
+
The `Verifier` handles retrieving and refreshing the webhook producer's verifying key set for you.
|
|
237
|
+
|
|
238
|
+
Ideally you should create one `Verifier` when your backend instance starts and reuse it for all requests sent to that backend instance.
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
def __init__(
|
|
242
|
+
self,
|
|
243
|
+
*,
|
|
244
|
+
public_verifying_key_set_url: str,
|
|
245
|
+
signature_ttl_seconds: int | None = None,
|
|
246
|
+
public_verifying_key_set_ttl_seconds: int | None = None,
|
|
247
|
+
) -> None:
|
|
248
|
+
"""Create a new `Verifier`.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
public_verifying_key_set_url: The url serving the webhook provider's verfiying key set.
|
|
252
|
+
The webhook provider should give you this url.
|
|
253
|
+
signature_ttl_seconds: How long a signature is considered valid. Defaults to 300 seconds (5 minutes).
|
|
254
|
+
In most cases, the default parameter should be used.
|
|
255
|
+
This parameter should not be set to a value larger than 300 (may be enforced in the future).
|
|
256
|
+
public_verifying_key_set_ttl_seconds: How long a retrieved `public_verifying_key_set` can be used before it is considered stale. Defaults to 300 seconds (5 minutes).
|
|
257
|
+
In most cases, the default parameter should be used.
|
|
258
|
+
This parameter should not be set to a value larger than 300 (may be enforced in the future).
|
|
259
|
+
"""
|
|
260
|
+
self._public_verifying_key_set_url = public_verifying_key_set_url
|
|
261
|
+
if signature_ttl_seconds is not None:
|
|
262
|
+
self._signature_ttl_seconds = signature_ttl_seconds
|
|
263
|
+
else:
|
|
264
|
+
self._signature_ttl_seconds = _DEFAULT_SIGNATURE_TTL_SECONDS
|
|
265
|
+
if public_verifying_key_set_ttl_seconds is not None:
|
|
266
|
+
self._public_verifying_key_set_ttl_seconds = public_verifying_key_set_ttl_seconds
|
|
267
|
+
else:
|
|
268
|
+
self._public_verifying_key_set_ttl_seconds = (
|
|
269
|
+
_DEFAULT_PUBLIC_VERIFYING_KEY_SET_TTL_SECONDS
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
self._public_verifying_key_set = PublicVerifyingKeySet(keys=[])
|
|
273
|
+
self._public_verifying_key_set_refreshed_at = 0
|
|
274
|
+
self._public_verifying_key_set_refresh_id = uuid.uuid4()
|
|
275
|
+
self._public_verifying_key_set_refresh_lock = asyncio.Lock()
|
|
276
|
+
|
|
277
|
+
async def verify(
|
|
278
|
+
self,
|
|
279
|
+
*,
|
|
280
|
+
target_url: str,
|
|
281
|
+
method: str,
|
|
282
|
+
correct_hooks_signature_components: str,
|
|
283
|
+
correct_hooks_signature: str,
|
|
284
|
+
request_body: bytes,
|
|
285
|
+
) -> VerifiedData:
|
|
286
|
+
"""Verifies a request and returns validated information from the Correct Hooks request headers.
|
|
287
|
+
|
|
288
|
+
This function confirms that the provided request signature is valid, that the signature has not expired, and that the request was sent to the intended endpoint.
|
|
289
|
+
|
|
290
|
+
Any raised error indicates that the signature could not be verified and the request should be rejected with a 4xx error
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
target_url: The url the webhook should be sent to.
|
|
294
|
+
`target_url` needs to be the exact string given to the webhook provider,
|
|
295
|
+
preferably retrieved from an environment variable or hardcoded.
|
|
296
|
+
method: The method the request was sent with. Only `"POST"` is supported.
|
|
297
|
+
correct_hooks_signature_components: The value from the request's `correct-hooks-signature-components` header.
|
|
298
|
+
correct_hooks_signature: The value from the request's `correct-hooks-signature` header.
|
|
299
|
+
request_body: The unparsed request body in bytes.
|
|
300
|
+
|
|
301
|
+
Raises:
|
|
302
|
+
PublicVerifyingKeyNotFound
|
|
303
|
+
PublicVerifyingKeyRetrievalFailed
|
|
304
|
+
InvalidSignature
|
|
305
|
+
InvalidPublicVerifyingKey
|
|
306
|
+
"""
|
|
307
|
+
now = _seconds_since_unix_epoch()
|
|
308
|
+
return await self.verify_with_time(
|
|
309
|
+
target_url=target_url,
|
|
310
|
+
method=method,
|
|
311
|
+
correct_hooks_signature_components=correct_hooks_signature_components,
|
|
312
|
+
correct_hooks_signature=correct_hooks_signature,
|
|
313
|
+
request_body=request_body,
|
|
314
|
+
now=now,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
async def verify_with_time(
|
|
318
|
+
self,
|
|
319
|
+
*,
|
|
320
|
+
target_url: str,
|
|
321
|
+
method: str,
|
|
322
|
+
correct_hooks_signature_components: str,
|
|
323
|
+
correct_hooks_signature: str,
|
|
324
|
+
request_body: bytes,
|
|
325
|
+
now: int,
|
|
326
|
+
) -> VerifiedData:
|
|
327
|
+
"""The same as `Verifier.verify` but with the current time provided by the caller, which
|
|
328
|
+
can be useful for testing.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
now: The current time in seconds since Unix epoch.
|
|
332
|
+
"""
|
|
333
|
+
if (
|
|
334
|
+
now
|
|
335
|
+
> self._public_verifying_key_set_refreshed_at
|
|
336
|
+
+ self._public_verifying_key_set_ttl_seconds
|
|
337
|
+
):
|
|
338
|
+
try:
|
|
339
|
+
await self._refresh_jwks(self._public_verifying_key_set_refresh_id)
|
|
340
|
+
except Exception: # noqa
|
|
341
|
+
pass
|
|
342
|
+
try:
|
|
343
|
+
return verify_with_time(
|
|
344
|
+
target_url=target_url,
|
|
345
|
+
method=method,
|
|
346
|
+
correct_hooks_signature_components=correct_hooks_signature_components,
|
|
347
|
+
correct_hooks_signature=correct_hooks_signature,
|
|
348
|
+
request_body=request_body,
|
|
349
|
+
signature_ttl_seconds=self._signature_ttl_seconds,
|
|
350
|
+
public_verifying_key_set=self._public_verifying_key_set,
|
|
351
|
+
now=now,
|
|
352
|
+
)
|
|
353
|
+
except PublicVerifyingKeyNotFound:
|
|
354
|
+
try:
|
|
355
|
+
await self._refresh_jwks(self._public_verifying_key_set_refresh_id)
|
|
356
|
+
except Exception: # noqa
|
|
357
|
+
raise PublicVerifyingKeyRetrievalFailed()
|
|
358
|
+
return verify_with_time(
|
|
359
|
+
target_url=target_url,
|
|
360
|
+
method=method,
|
|
361
|
+
correct_hooks_signature_components=correct_hooks_signature_components,
|
|
362
|
+
correct_hooks_signature=correct_hooks_signature,
|
|
363
|
+
request_body=request_body,
|
|
364
|
+
signature_ttl_seconds=self._signature_ttl_seconds,
|
|
365
|
+
public_verifying_key_set=self._public_verifying_key_set,
|
|
366
|
+
now=now,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
def verify_sync(
|
|
370
|
+
self,
|
|
371
|
+
*,
|
|
372
|
+
target_url: str,
|
|
373
|
+
method: str,
|
|
374
|
+
correct_hooks_signature_components: str,
|
|
375
|
+
correct_hooks_signature: str,
|
|
376
|
+
request_body: bytes,
|
|
377
|
+
) -> VerifiedData:
|
|
378
|
+
"""A synchronous version of `Verifier.verify`."""
|
|
379
|
+
now = _seconds_since_unix_epoch()
|
|
380
|
+
return self.verify_with_time_sync(
|
|
381
|
+
target_url=target_url,
|
|
382
|
+
method=method,
|
|
383
|
+
correct_hooks_signature_components=correct_hooks_signature_components,
|
|
384
|
+
correct_hooks_signature=correct_hooks_signature,
|
|
385
|
+
request_body=request_body,
|
|
386
|
+
now=now,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
def verify_with_time_sync(
|
|
390
|
+
self,
|
|
391
|
+
*,
|
|
392
|
+
target_url: str,
|
|
393
|
+
method: str,
|
|
394
|
+
correct_hooks_signature_components: str,
|
|
395
|
+
correct_hooks_signature: str,
|
|
396
|
+
request_body: bytes,
|
|
397
|
+
now: int,
|
|
398
|
+
) -> VerifiedData:
|
|
399
|
+
"""A synchronous version of `Verifier.verify_with_time`."""
|
|
400
|
+
if (
|
|
401
|
+
now
|
|
402
|
+
> self._public_verifying_key_set_refreshed_at
|
|
403
|
+
+ self._public_verifying_key_set_ttl_seconds
|
|
404
|
+
):
|
|
405
|
+
try:
|
|
406
|
+
self._refresh_jwks_sync()
|
|
407
|
+
except Exception: # noqa
|
|
408
|
+
pass
|
|
409
|
+
try:
|
|
410
|
+
return verify_with_time(
|
|
411
|
+
target_url=target_url,
|
|
412
|
+
method=method,
|
|
413
|
+
correct_hooks_signature_components=correct_hooks_signature_components,
|
|
414
|
+
correct_hooks_signature=correct_hooks_signature,
|
|
415
|
+
request_body=request_body,
|
|
416
|
+
signature_ttl_seconds=self._signature_ttl_seconds,
|
|
417
|
+
public_verifying_key_set=self._public_verifying_key_set,
|
|
418
|
+
now=now,
|
|
419
|
+
)
|
|
420
|
+
except PublicVerifyingKeyNotFound:
|
|
421
|
+
try:
|
|
422
|
+
self._refresh_jwks_sync()
|
|
423
|
+
except Exception: # noqa
|
|
424
|
+
raise PublicVerifyingKeyRetrievalFailed()
|
|
425
|
+
result = verify_with_time(
|
|
426
|
+
target_url=target_url,
|
|
427
|
+
method=method,
|
|
428
|
+
correct_hooks_signature_components=correct_hooks_signature_components,
|
|
429
|
+
correct_hooks_signature=correct_hooks_signature,
|
|
430
|
+
request_body=request_body,
|
|
431
|
+
signature_ttl_seconds=self._signature_ttl_seconds,
|
|
432
|
+
public_verifying_key_set=self._public_verifying_key_set,
|
|
433
|
+
now=now,
|
|
434
|
+
)
|
|
435
|
+
return result
|
|
436
|
+
|
|
437
|
+
async def _refresh_jwks(self, old_refresh_id: uuid.UUID) -> None:
|
|
438
|
+
async with self._public_verifying_key_set_refresh_lock:
|
|
439
|
+
if self._public_verifying_key_set_refresh_id != old_refresh_id:
|
|
440
|
+
return
|
|
441
|
+
async with httpx.AsyncClient() as client:
|
|
442
|
+
response = await client.get(self._public_verifying_key_set_url)
|
|
443
|
+
if response.status_code != 200:
|
|
444
|
+
raise PublicVerifyingKeyRetrievalFailed()
|
|
445
|
+
self._public_verifying_key_set = PublicVerifyingKeySet.model_validate_json(
|
|
446
|
+
response.content
|
|
447
|
+
)
|
|
448
|
+
self._public_verifying_key_set_refreshed_at = _seconds_since_unix_epoch()
|
|
449
|
+
self._public_verifying_key_set_refresh_id = uuid.uuid4()
|
|
450
|
+
|
|
451
|
+
def _refresh_jwks_sync(self) -> None:
|
|
452
|
+
new_refresh_time = _seconds_since_unix_epoch()
|
|
453
|
+
with httpx.Client() as client:
|
|
454
|
+
response = client.get(self._public_verifying_key_set_url)
|
|
455
|
+
if response.status_code != 200:
|
|
456
|
+
raise PublicVerifyingKeyRetrievalFailed()
|
|
457
|
+
self._public_verifying_key_set = PublicVerifyingKeySet.model_validate_json(
|
|
458
|
+
response.content
|
|
459
|
+
)
|
|
460
|
+
self._public_verifying_key_set_refreshed_at = new_refresh_time
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def verify_with_time(
|
|
464
|
+
*,
|
|
465
|
+
target_url: str,
|
|
466
|
+
method: str,
|
|
467
|
+
correct_hooks_signature_components: str,
|
|
468
|
+
correct_hooks_signature: str,
|
|
469
|
+
request_body: bytes,
|
|
470
|
+
signature_ttl_seconds: int,
|
|
471
|
+
public_verifying_key_set: PublicVerifyingKeySet,
|
|
472
|
+
now: int,
|
|
473
|
+
) -> VerifiedData:
|
|
474
|
+
"""This function is only public for internal testing purposes and will be made private in a future version of this library."""
|
|
475
|
+
if method != "POST":
|
|
476
|
+
raise InvalidSignature()
|
|
477
|
+
|
|
478
|
+
signature_components_encoded = _split_versioned_header(
|
|
479
|
+
correct_hooks_signature_components
|
|
480
|
+
).get(SIGNATURE_VERSION)
|
|
481
|
+
if signature_components_encoded is None:
|
|
482
|
+
raise InvalidSignature()
|
|
483
|
+
try:
|
|
484
|
+
signature_components = _decode_signature_components(signature_components_encoded)
|
|
485
|
+
except Exception as e:
|
|
486
|
+
raise InvalidSignature() from e
|
|
487
|
+
|
|
488
|
+
verifying_key_jwk = next(
|
|
489
|
+
(
|
|
490
|
+
key
|
|
491
|
+
for key in public_verifying_key_set.keys
|
|
492
|
+
if key.kid == signature_components.key_id
|
|
493
|
+
),
|
|
494
|
+
None,
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
if verifying_key_jwk is None:
|
|
498
|
+
raise PublicVerifyingKeyNotFound()
|
|
499
|
+
|
|
500
|
+
try:
|
|
501
|
+
public_key_bytes = decode_base64(verifying_key_jwk.x)
|
|
502
|
+
verifying_key = Ed25519PublicKey.from_public_bytes(public_key_bytes)
|
|
503
|
+
except Exception as e:
|
|
504
|
+
raise InvalidPublicVerifyingKey() from e
|
|
505
|
+
|
|
506
|
+
signature_message = _construct_signature_message(signature_components, request_body)
|
|
507
|
+
|
|
508
|
+
signature_encoded = _split_versioned_header(correct_hooks_signature).get(
|
|
509
|
+
SIGNATURE_VERSION
|
|
510
|
+
)
|
|
511
|
+
if signature_encoded is None:
|
|
512
|
+
raise InvalidSignature()
|
|
513
|
+
|
|
514
|
+
try:
|
|
515
|
+
signature_bytes = decode_base64(signature_encoded)
|
|
516
|
+
except Exception as e:
|
|
517
|
+
raise InvalidSignature() from e
|
|
518
|
+
|
|
519
|
+
try:
|
|
520
|
+
verifying_key.verify(signature_bytes, signature_message)
|
|
521
|
+
except Exception as e:
|
|
522
|
+
raise InvalidSignature() from e
|
|
523
|
+
|
|
524
|
+
if (
|
|
525
|
+
now > signature_components.signed_at + signature_ttl_seconds
|
|
526
|
+
or now + signature_ttl_seconds < signature_components.signed_at
|
|
527
|
+
):
|
|
528
|
+
raise InvalidSignature()
|
|
529
|
+
|
|
530
|
+
if target_url != signature_components.target_url:
|
|
531
|
+
raise InvalidSignature()
|
|
532
|
+
|
|
533
|
+
return VerifiedData(webhook_id=signature_components.webhook_id)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _construct_signature_message(
|
|
537
|
+
signature_components: _SignatureComponents, request_body: bytes
|
|
538
|
+
) -> bytes:
|
|
539
|
+
encoded_signature_components = _encode_signature_components(signature_components)
|
|
540
|
+
signature_components_bytes = f"{encoded_signature_components}.".encode()
|
|
541
|
+
return signature_components_bytes + request_body
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _version_header_value(header_value: str) -> str:
|
|
545
|
+
return f"{SIGNATURE_VERSION},{header_value}"
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _split_versioned_header(header: str) -> dict[str, str]:
|
|
549
|
+
split_headers: dict[str, str] = {}
|
|
550
|
+
for versioned_value in header.split(" "):
|
|
551
|
+
parts = versioned_value.split(",")
|
|
552
|
+
if len(parts) < 2:
|
|
553
|
+
continue
|
|
554
|
+
split_headers[parts[0]] = parts[1]
|
|
555
|
+
return split_headers
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _encode_signature_components(signature_components: _SignatureComponents) -> str:
|
|
559
|
+
target_url_base64 = _encode_base64_from_string(signature_components.target_url)
|
|
560
|
+
webhook_id_base64 = _encode_base64_from_string(signature_components.webhook_id)
|
|
561
|
+
signed_at_base64 = _encode_base64_from_string(str(signature_components.signed_at))
|
|
562
|
+
key_id_base64 = _encode_base64_from_string(str(signature_components.key_id))
|
|
563
|
+
encoded_signature_components = (
|
|
564
|
+
f"{target_url_base64}.{webhook_id_base64}.{signed_at_base64}.{key_id_base64}"
|
|
565
|
+
)
|
|
566
|
+
return encoded_signature_components
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _decode_signature_components(
|
|
570
|
+
encoded_signature_components: str,
|
|
571
|
+
) -> _SignatureComponents:
|
|
572
|
+
components = list(encoded_signature_components.split("."))
|
|
573
|
+
if len(components) != 4:
|
|
574
|
+
raise ValueError("Invalid component count")
|
|
575
|
+
target_url = _decode_base64_to_string(components[0])
|
|
576
|
+
webhook_id = _decode_base64_to_string(components[1])
|
|
577
|
+
signed_at = int(_decode_base64_to_string(components[2]))
|
|
578
|
+
key_id = uuid.UUID(_decode_base64_to_string(components[3]))
|
|
579
|
+
return _SignatureComponents(
|
|
580
|
+
target_url=target_url, webhook_id=webhook_id, signed_at=signed_at, key_id=key_id
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _encode_base64(data: bytes) -> str:
|
|
585
|
+
return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=")
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def decode_base64(base64_string: str) -> bytes:
|
|
589
|
+
"""This function is only public for internal testing purposes and will be made private in a future version of this library."""
|
|
590
|
+
return base64.urlsafe_b64decode(base64_string + "===")
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _encode_base64_from_string(data: str) -> str:
|
|
594
|
+
return _encode_base64(data.encode("utf-8"))
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _decode_base64_to_string(base64_string: str) -> str:
|
|
598
|
+
return decode_base64(base64_string).decode("utf-8")
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _seconds_since_unix_epoch() -> int:
|
|
602
|
+
return int(time.time())
|
correct_hooks/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: correct-hooks
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Asymmetric signing, verification, and key management for webhooks
|
|
5
|
+
Author: Phillip Diffley
|
|
6
|
+
Author-email: Phillip Diffley <7283778+pdiffley@users.noreply.github.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Requires-Dist: cryptography>=50.0.2
|
|
9
|
+
Requires-Dist: httpx>=0.28.1
|
|
10
|
+
Requires-Dist: pydantic>=2.13.5
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
correct_hooks/__init__.py,sha256=o2hm8bQrXJKfEuUr53umFFpzqzz8bu3wUU9s9x4boRM,20063
|
|
2
|
+
correct_hooks/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
correct_hooks-0.1.0.dist-info/WHEEL,sha256=VOHKyukui3UMeyUkkBf5F7N5xN_6X6W7zHVvalNVVvI,81
|
|
4
|
+
correct_hooks-0.1.0.dist-info/METADATA,sha256=quXAdRShmpwryt_jlt_JE9ZI7J3eVsNTxE1cl6KIOM0,416
|
|
5
|
+
correct_hooks-0.1.0.dist-info/RECORD,,
|