correct-hooks 0.1.0__tar.gz

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,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
+
File without changes
@@ -0,0 +1,20 @@
1
+ [project]
2
+ name = "correct-hooks"
3
+ version = "0.1.0"
4
+ description = "Asymmetric signing, verification, and key management for webhooks"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.11"
8
+ dependencies = [
9
+ "cryptography>=50.0.2",
10
+ "httpx>=0.28.1",
11
+ "pydantic>=2.13.5",
12
+ ]
13
+
14
+ [[project.authors]]
15
+ name = "Phillip Diffley"
16
+ email = "7283778+pdiffley@users.noreply.github.com"
17
+
18
+ [build-system]
19
+ requires = ["uv_build>=0.12.21,<0.13.0"]
20
+ build-backend = "uv_build"
@@ -0,0 +1,19 @@
1
+ [project]
2
+ name = "correct-hooks"
3
+ version = "0.1.0"
4
+ description = "Asymmetric signing, verification, and key management for webhooks"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ authors = [
8
+ { name = "Phillip Diffley", email = "7283778+pdiffley@users.noreply.github.com" }
9
+ ]
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "cryptography>=50.0.2",
13
+ "httpx>=0.28.1",
14
+ "pydantic>=2.13.5",
15
+ ]
16
+
17
+ [build-system]
18
+ requires = ["uv_build>=0.12.21,<0.13.0"]
19
+ build-backend = "uv_build"
@@ -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())
File without changes