easycontract 0.1.1__tar.gz → 0.1.4__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.
- {easycontract-0.1.1 → easycontract-0.1.4}/CHANGELOG.md +48 -0
- {easycontract-0.1.1 → easycontract-0.1.4}/PKG-INFO +1 -1
- {easycontract-0.1.1 → easycontract-0.1.4}/pyproject.toml +1 -1
- {easycontract-0.1.1 → easycontract-0.1.4}/src/easycontract/__init__.py +1 -1
- {easycontract-0.1.1 → easycontract-0.1.4}/src/easycontract/client.py +2 -0
- {easycontract-0.1.1 → easycontract-0.1.4}/src/easycontract/resources.py +183 -12
- {easycontract-0.1.1 → easycontract-0.1.4}/src/easycontract/webhooks.py +26 -8
- {easycontract-0.1.1 → easycontract-0.1.4}/.gitignore +0 -0
- {easycontract-0.1.1 → easycontract-0.1.4}/LICENSE.txt +0 -0
- {easycontract-0.1.1 → easycontract-0.1.4}/README.md +0 -0
- {easycontract-0.1.1 → easycontract-0.1.4}/src/easycontract/errors.py +0 -0
- {easycontract-0.1.1 → easycontract-0.1.4}/tests/smoke_test.py +0 -0
|
@@ -2,6 +2,54 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the `easycontract` Python SDK.
|
|
4
4
|
|
|
5
|
+
## 0.1.4 (2026-10-20)
|
|
6
|
+
|
|
7
|
+
**Features**
|
|
8
|
+
|
|
9
|
+
- Projects (paid plans): `client.projects.list/create/get/update`. An
|
|
10
|
+
organization key sees everything; a project key only its project.
|
|
11
|
+
`project=` on `envelopes.create`, `envelopes.list`, `templates.create_envelope`
|
|
12
|
+
and `webhooks.create` (organization keys only). `client.whoami()` now returns
|
|
13
|
+
`project` (null = organization key) and `key`.
|
|
14
|
+
- Webhooks: `client.webhooks.get`, `update` (url, event_types, is_active,
|
|
15
|
+
description) and `rotate_secret(grace_period_hours=24)`, which returns the
|
|
16
|
+
new `secret` once.
|
|
17
|
+
|
|
18
|
+
**Fixes**
|
|
19
|
+
|
|
20
|
+
- `easycontract.webhooks.verify_signature` accepts ANY `v1=` in the header
|
|
21
|
+
and `secret` may be a list: after a rotation, during the grace period,
|
|
22
|
+
deliveries carry `v1=<new>,v1=<old>`. 0.1.3 and earlier only checked the
|
|
23
|
+
last `v1` (the old secret), so they failed once you switched to the new one
|
|
24
|
+
before the grace period ended.
|
|
25
|
+
|
|
26
|
+
## 0.1.3 (2026-10-20)
|
|
27
|
+
|
|
28
|
+
**Features**
|
|
29
|
+
|
|
30
|
+
- Cryptographic restrictions: `client.organization.update(seal_profile=
|
|
31
|
+
"hybrid" | "classic", acknowledge=True)` chooses how the platform seals
|
|
32
|
+
the certificate, the final PDFs and the evidence package (hybrid ECDSA +
|
|
33
|
+
ML-DSA-87, or ECDSA only). `client.organization.get()["cryptography"]`
|
|
34
|
+
shows the choice and what each option implies; every envelope reports the
|
|
35
|
+
profile frozen at send in `seal_profile`.
|
|
36
|
+
- The evidence package (`client.envelopes.download_evidence(...)`) is now an ASiC-E
|
|
37
|
+
container (still a `.zip`) signed by the platform.
|
|
38
|
+
|
|
39
|
+
## 0.1.2 (2026-10-19)
|
|
40
|
+
|
|
41
|
+
**Features**
|
|
42
|
+
|
|
43
|
+
- Machine-drafted template translations: `client.templates.autotranslate(
|
|
44
|
+
template_id, document_id, language, acknowledge=True)` queues a draft made
|
|
45
|
+
by the platform's translators and reviewed by a panel with quorum;
|
|
46
|
+
`client.templates.get_translation_job(job_id)` returns its progress,
|
|
47
|
+
`verdict` (`consensus`, `quorum`, `no_quorum`, `unreviewed`, `simulated`)
|
|
48
|
+
and the reviewers' notes with English back-translations. The result is
|
|
49
|
+
always a draft that a person approves in the panel.
|
|
50
|
+
- The platform now has 16 languages (es, en, ca, gl, eu, ast, an, oc, pt, fr,
|
|
51
|
+
it, de, ru, zh-hans, ar, he) for `language` everywhere.
|
|
52
|
+
|
|
5
53
|
## 0.1.1 (2026-10-07)
|
|
6
54
|
|
|
7
55
|
**Security (audit 2026-10-07)**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: easycontract
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: SDK oficial de Python para el API de easycontract (firma electrónica eIDAS)
|
|
5
5
|
Project-URL: Documentation, https://easycontract.online/docs/api
|
|
6
6
|
Author-email: Román Ramírez <roman@easycontract.online>
|
|
@@ -29,6 +29,7 @@ from .resources import (
|
|
|
29
29
|
Invitations,
|
|
30
30
|
Members,
|
|
31
31
|
Organization,
|
|
32
|
+
Projects,
|
|
32
33
|
Retention,
|
|
33
34
|
Templates,
|
|
34
35
|
Webhooks,
|
|
@@ -109,6 +110,7 @@ class EasyContract:
|
|
|
109
110
|
self.retention = Retention(self)
|
|
110
111
|
self.templates = Templates(self)
|
|
111
112
|
self.webhooks = Webhooks(self)
|
|
113
|
+
self.projects = Projects(self)
|
|
112
114
|
self.events = Events(self)
|
|
113
115
|
self.organization = Organization(self)
|
|
114
116
|
self.members = Members(self)
|
|
@@ -135,6 +135,11 @@ OPERATIONS: dict[tuple[str, str], str] = {
|
|
|
135
135
|
"DELETE",
|
|
136
136
|
"/templates/{template_id}/documents/{document_id}/translations/{language}/",
|
|
137
137
|
): "client.templates.delete_translation",
|
|
138
|
+
(
|
|
139
|
+
"POST",
|
|
140
|
+
"/templates/{template_id}/documents/{document_id}/translations/{language}/autotranslate/",
|
|
141
|
+
): "client.templates.autotranslate",
|
|
142
|
+
("GET", "/translation-jobs/{job_id}/"): "client.templates.get_translation_job",
|
|
138
143
|
("POST", "/templates/{template_id}/preview/"): "client.templates.preview",
|
|
139
144
|
("POST", "/templates/{template_id}/duplicate/"): "client.templates.duplicate",
|
|
140
145
|
("POST", "/templates/{template_id}/roles/"): "client.templates.add_role",
|
|
@@ -143,7 +148,14 @@ OPERATIONS: dict[tuple[str, str], str] = {
|
|
|
143
148
|
("GET", "/events/"): "client.events.list",
|
|
144
149
|
("GET", "/webhooks/"): "client.webhooks.list",
|
|
145
150
|
("POST", "/webhooks/"): "client.webhooks.create",
|
|
151
|
+
("GET", "/webhooks/{endpoint_id}/"): "client.webhooks.get",
|
|
152
|
+
("PATCH", "/webhooks/{endpoint_id}/"): "client.webhooks.update",
|
|
146
153
|
("DELETE", "/webhooks/{endpoint_id}/"): "client.webhooks.delete",
|
|
154
|
+
("POST", "/webhooks/{endpoint_id}/rotate-secret/"): "client.webhooks.rotate_secret",
|
|
155
|
+
("GET", "/projects/"): "client.projects.list",
|
|
156
|
+
("POST", "/projects/"): "client.projects.create",
|
|
157
|
+
("GET", "/projects/{project_id}/"): "client.projects.get",
|
|
158
|
+
("PATCH", "/projects/{project_id}/"): "client.projects.update",
|
|
147
159
|
("POST", "/webhooks/deliveries/{delivery_id}/redeliver/"): "client.webhooks.redeliver",
|
|
148
160
|
}
|
|
149
161
|
|
|
@@ -198,9 +210,12 @@ class Envelopes(_Resource):
|
|
|
198
210
|
biometrics: bool = False,
|
|
199
211
|
document_type: str | None = None,
|
|
200
212
|
kyc_images_retention: str | None = None,
|
|
213
|
+
project: str | None = None,
|
|
201
214
|
idempotency_key: str | None = None,
|
|
202
215
|
) -> dict:
|
|
203
|
-
"""`
|
|
216
|
+
"""`project` (organization keys only): create inside that project; a
|
|
217
|
+
project key always creates in its own.
|
|
218
|
+
`biometrics=True` offers signers the OPTIONAL stroke capture (only
|
|
204
219
|
effective when the organization enabled it: Enterprise + DPIA).
|
|
205
220
|
`document_type` is a key of `client.retention.document_types()`.
|
|
206
221
|
`kyc_images_retention`: "after_review" (default) or
|
|
@@ -220,17 +235,25 @@ class Envelopes(_Resource):
|
|
|
220
235
|
body["document_type"] = document_type
|
|
221
236
|
if kyc_images_retention:
|
|
222
237
|
body["kyc_images_retention"] = kyc_images_retention
|
|
238
|
+
if project:
|
|
239
|
+
body["project"] = project
|
|
223
240
|
return self._client.request(
|
|
224
241
|
"POST", "/envelopes/", json=body, idempotency_key=idempotency_key
|
|
225
242
|
)
|
|
226
243
|
|
|
227
244
|
def list(
|
|
228
|
-
self,
|
|
245
|
+
self,
|
|
246
|
+
*,
|
|
247
|
+
status: str | None = None,
|
|
248
|
+
project: str | None = None,
|
|
249
|
+
limit: int | None = None,
|
|
250
|
+
cursor: str | None = None,
|
|
229
251
|
) -> dict:
|
|
230
252
|
params = {
|
|
231
253
|
k: v
|
|
232
254
|
for k, v in {
|
|
233
255
|
"status": status,
|
|
256
|
+
"project": project,
|
|
234
257
|
"limit": limit,
|
|
235
258
|
"cursor": cursor,
|
|
236
259
|
}.items()
|
|
@@ -402,7 +425,9 @@ class Envelopes(_Resource):
|
|
|
402
425
|
include_biometrics: bool = False,
|
|
403
426
|
include_identity_proofs: bool = False,
|
|
404
427
|
) -> bytes:
|
|
405
|
-
"""The full evidence ZIP — what a tenant would take to court.
|
|
428
|
+
"""The full evidence ZIP — what a tenant would take to court. With the
|
|
429
|
+
platform seal on, it is an ASiC-E container signed by the platform
|
|
430
|
+
(manifest.json → `container` describes it)."""
|
|
406
431
|
content = self._client.request(
|
|
407
432
|
"GET",
|
|
408
433
|
f"/envelopes/{_id(envelope_id)}/evidence/",
|
|
@@ -578,6 +603,34 @@ class Templates(_Resource):
|
|
|
578
603
|
f"/templates/{_id(template_id)}/documents/{_id(document_id)}/translations/{_seg(language)}/",
|
|
579
604
|
)
|
|
580
605
|
|
|
606
|
+
def autotranslate(
|
|
607
|
+
self,
|
|
608
|
+
template_id: str,
|
|
609
|
+
document_id: str,
|
|
610
|
+
language: str,
|
|
611
|
+
*,
|
|
612
|
+
acknowledge: bool,
|
|
613
|
+
overwrite: bool = False,
|
|
614
|
+
idempotency_key: str | None = None,
|
|
615
|
+
) -> dict:
|
|
616
|
+
"""Queue a MACHINE DRAFT of a language version: translators plus a
|
|
617
|
+
reviewer panel with quorum. Returns the job (202); poll
|
|
618
|
+
`get_translation_job`. `acknowledge=True` is required: the result is
|
|
619
|
+
a draft that a person reviews and approves in the panel. `overwrite`
|
|
620
|
+
replaces a translation that already has its own text."""
|
|
621
|
+
return self._client.request(
|
|
622
|
+
"POST",
|
|
623
|
+
f"/templates/{_id(template_id)}/documents/{_id(document_id)}/translations/{_seg(language)}/autotranslate/",
|
|
624
|
+
json={"acknowledge": acknowledge, "overwrite": overwrite},
|
|
625
|
+
idempotency_key=idempotency_key,
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
def get_translation_job(self, job_id: str) -> dict:
|
|
629
|
+
"""Status of a machine translation: `verdict` (consensus, quorum,
|
|
630
|
+
no_quorum, unreviewed, simulated) and, when done, the reviewers'
|
|
631
|
+
`notes` with an English back-translation."""
|
|
632
|
+
return self._client.request("GET", f"/translation-jobs/{_id(job_id)}/")
|
|
633
|
+
|
|
581
634
|
def download_docx(
|
|
582
635
|
self, template_id: str, document_id: str, *, to: str | Path | None = None
|
|
583
636
|
) -> bytes:
|
|
@@ -714,6 +767,7 @@ class Templates(_Resource):
|
|
|
714
767
|
language: str | None = None,
|
|
715
768
|
kyc_images_retention: str | None = None,
|
|
716
769
|
biometrics: bool = False,
|
|
770
|
+
project: str | None = None,
|
|
717
771
|
idempotency_key: str | None = None,
|
|
718
772
|
) -> dict:
|
|
719
773
|
"""The one-call automation flow (§10.4): instantiate and optionally
|
|
@@ -736,6 +790,8 @@ class Templates(_Resource):
|
|
|
736
790
|
body["biometrics"] = True
|
|
737
791
|
if expires_in_days is not None:
|
|
738
792
|
body["expires_in_days"] = expires_in_days
|
|
793
|
+
if project:
|
|
794
|
+
body["project"] = project
|
|
739
795
|
return self._client.request(
|
|
740
796
|
"POST",
|
|
741
797
|
f"/templates/{_id(template_id)}/envelopes/",
|
|
@@ -746,19 +802,59 @@ class Templates(_Resource):
|
|
|
746
802
|
|
|
747
803
|
class Webhooks(_Resource):
|
|
748
804
|
def create(
|
|
749
|
-
self,
|
|
805
|
+
self,
|
|
806
|
+
*,
|
|
807
|
+
url: str,
|
|
808
|
+
event_types: list[str] | None = None,
|
|
809
|
+
description: str = "",
|
|
810
|
+
project: str | None = None,
|
|
750
811
|
) -> dict:
|
|
751
|
-
"""The response includes `secret` (whsec_…) ONCE — store it now.
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
812
|
+
"""The response includes `secret` (whsec_…) ONCE — store it now.
|
|
813
|
+
Mode = the key's. `project` (organization keys only): receive only
|
|
814
|
+
that project's events; a project key always registers for its own."""
|
|
815
|
+
body: dict = {"url": url, "event_types": event_types or [], "description": description}
|
|
816
|
+
if project:
|
|
817
|
+
body["project"] = project
|
|
818
|
+
return self._client.request("POST", "/webhooks/", json=body)
|
|
757
819
|
|
|
758
820
|
def list(self, *, limit: int | None = None, cursor: str | None = None) -> dict:
|
|
759
821
|
params = {k: v for k, v in {"limit": limit, "cursor": cursor}.items() if v}
|
|
760
822
|
return self._client.request("GET", "/webhooks/", params=params)
|
|
761
823
|
|
|
824
|
+
def get(self, endpoint_id: str) -> dict:
|
|
825
|
+
return self._client.request("GET", f"/webhooks/{_id(endpoint_id)}/")
|
|
826
|
+
|
|
827
|
+
def update(
|
|
828
|
+
self,
|
|
829
|
+
endpoint_id: str,
|
|
830
|
+
*,
|
|
831
|
+
url: str | None = None,
|
|
832
|
+
event_types: list[str] | None = None,
|
|
833
|
+
is_active: bool | None = None,
|
|
834
|
+
description: str | None = None,
|
|
835
|
+
) -> dict:
|
|
836
|
+
"""Change only what you pass. Reactivating counts against the plan."""
|
|
837
|
+
body = {
|
|
838
|
+
k: v
|
|
839
|
+
for k, v in {
|
|
840
|
+
"url": url,
|
|
841
|
+
"event_types": event_types,
|
|
842
|
+
"is_active": is_active,
|
|
843
|
+
"description": description,
|
|
844
|
+
}.items()
|
|
845
|
+
if v is not None
|
|
846
|
+
}
|
|
847
|
+
return self._client.request("PATCH", f"/webhooks/{_id(endpoint_id)}/", json=body)
|
|
848
|
+
|
|
849
|
+
def rotate_secret(self, endpoint_id: str, *, grace_period_hours: int = 24) -> dict:
|
|
850
|
+
"""New `secret` returned ONCE. For `grace_period_hours` (0-72) deliveries
|
|
851
|
+
carry both signatures: verify with [new, old] until you switch."""
|
|
852
|
+
return self._client.request(
|
|
853
|
+
"POST",
|
|
854
|
+
f"/webhooks/{_id(endpoint_id)}/rotate-secret/",
|
|
855
|
+
json={"grace_period_hours": grace_period_hours},
|
|
856
|
+
)
|
|
857
|
+
|
|
762
858
|
def delete(self, endpoint_id: str) -> None:
|
|
763
859
|
self._client.request("DELETE", f"/webhooks/{_id(endpoint_id)}/")
|
|
764
860
|
|
|
@@ -766,6 +862,69 @@ class Webhooks(_Resource):
|
|
|
766
862
|
return self._client.request("POST", f"/webhooks/deliveries/{_id(delivery_id)}/redeliver/")
|
|
767
863
|
|
|
768
864
|
|
|
865
|
+
class Projects(_Resource):
|
|
866
|
+
"""Projects inside the organization (paid plans). An organization key
|
|
867
|
+
manages them all; a project key only reads its own."""
|
|
868
|
+
|
|
869
|
+
def list(
|
|
870
|
+
self,
|
|
871
|
+
*,
|
|
872
|
+
is_active: bool | None = None,
|
|
873
|
+
limit: int | None = None,
|
|
874
|
+
cursor: str | None = None,
|
|
875
|
+
) -> dict:
|
|
876
|
+
params = {
|
|
877
|
+
k: v
|
|
878
|
+
for k, v in {
|
|
879
|
+
"is_active": None if is_active is None else str(is_active).lower(),
|
|
880
|
+
"limit": limit,
|
|
881
|
+
"cursor": cursor,
|
|
882
|
+
}.items()
|
|
883
|
+
if v is not None
|
|
884
|
+
}
|
|
885
|
+
return self._client.request("GET", "/projects/", params=params)
|
|
886
|
+
|
|
887
|
+
def create(
|
|
888
|
+
self,
|
|
889
|
+
*,
|
|
890
|
+
name: str,
|
|
891
|
+
description: str = "",
|
|
892
|
+
embed_allowed_origins: list[str] | None = None,
|
|
893
|
+
idempotency_key: str | None = None,
|
|
894
|
+
) -> dict:
|
|
895
|
+
body: dict = {"name": name, "description": description}
|
|
896
|
+
if embed_allowed_origins is not None:
|
|
897
|
+
body["embed_allowed_origins"] = list(embed_allowed_origins)
|
|
898
|
+
return self._client.request(
|
|
899
|
+
"POST", "/projects/", json=body, idempotency_key=idempotency_key
|
|
900
|
+
)
|
|
901
|
+
|
|
902
|
+
def get(self, project_id: str) -> dict:
|
|
903
|
+
return self._client.request("GET", f"/projects/{_id(project_id)}/")
|
|
904
|
+
|
|
905
|
+
def update(
|
|
906
|
+
self,
|
|
907
|
+
project_id: str,
|
|
908
|
+
*,
|
|
909
|
+
name: str | None = None,
|
|
910
|
+
description: str | None = None,
|
|
911
|
+
is_active: bool | None = None,
|
|
912
|
+
embed_allowed_origins: list[str] | None = None,
|
|
913
|
+
) -> dict:
|
|
914
|
+
"""`is_active=False` archives: its keys and webhooks stop working."""
|
|
915
|
+
body = {
|
|
916
|
+
k: v
|
|
917
|
+
for k, v in {
|
|
918
|
+
"name": name,
|
|
919
|
+
"description": description,
|
|
920
|
+
"is_active": is_active,
|
|
921
|
+
"embed_allowed_origins": embed_allowed_origins,
|
|
922
|
+
}.items()
|
|
923
|
+
if v is not None
|
|
924
|
+
}
|
|
925
|
+
return self._client.request("PATCH", f"/projects/{_id(project_id)}/", json=body)
|
|
926
|
+
|
|
927
|
+
|
|
769
928
|
class Events(_Resource):
|
|
770
929
|
"""The consultable event log — reconciliation source when a webhook is
|
|
771
930
|
lost (poll `list` and compare against what you received)."""
|
|
@@ -875,15 +1034,27 @@ class Organization(_Resource):
|
|
|
875
1034
|
*,
|
|
876
1035
|
retention_policies: dict | None = None,
|
|
877
1036
|
embed_allowed_origins: list[str] | None = None,
|
|
1037
|
+
seal_profile: str | None = None,
|
|
1038
|
+
acknowledge: bool = False,
|
|
878
1039
|
) -> dict:
|
|
879
1040
|
"""Organization settings an API key may change: retention policy per
|
|
880
|
-
document type (months within the plan's range)
|
|
881
|
-
allowed to embed the signer flow (the FULL list; [] disables it)
|
|
1041
|
+
document type (months within the plan's range), the web origins
|
|
1042
|
+
allowed to embed the signer flow (the FULL list; [] disables it) and
|
|
1043
|
+
the cryptographic restrictions of the platform seal.
|
|
1044
|
+
|
|
1045
|
+
`seal_profile`: "hybrid" (ECDSA P-384 + ML-DSA-87, quantum resistant;
|
|
1046
|
+
readers without ML-DSA show that signature as unknown) or "classic"
|
|
1047
|
+
(ECDSA only, no warnings, not quantum resistant). Changing it needs
|
|
1048
|
+
`acknowledge=True`: you confirm the trade-off described in
|
|
1049
|
+
`get()["cryptography"]["options"]`. It applies to envelopes sent
|
|
1050
|
+
afterwards."""
|
|
882
1051
|
body: dict = {}
|
|
883
1052
|
if retention_policies is not None:
|
|
884
1053
|
body["retention_policies"] = retention_policies
|
|
885
1054
|
if embed_allowed_origins is not None:
|
|
886
1055
|
body["embed_allowed_origins"] = list(embed_allowed_origins)
|
|
1056
|
+
if seal_profile is not None:
|
|
1057
|
+
body["cryptography"] = {"seal_profile": seal_profile, "acknowledge": acknowledge}
|
|
887
1058
|
return self._client.request("PATCH", "/organization/", json=body)
|
|
888
1059
|
|
|
889
1060
|
|
|
@@ -5,12 +5,16 @@ easycontract signs every delivery with
|
|
|
5
5
|
X-EasyContract-Signature: t=<unix>,v1=<hex>
|
|
6
6
|
|
|
7
7
|
where v1 = HMAC-SHA256(secret, f"{t}.{body}"). Verify with your endpoint's
|
|
8
|
-
whsec_… secret (shown once at creation):
|
|
8
|
+
whsec_… secret (shown once at creation or rotation):
|
|
9
9
|
|
|
10
10
|
event = easycontract.webhooks.verify_signature(
|
|
11
11
|
payload=request.body, header=request.headers["X-EasyContract-Signature"],
|
|
12
12
|
secret=WEBHOOK_SECRET,
|
|
13
13
|
)
|
|
14
|
+
|
|
15
|
+
After a secret rotation, during the grace period, the header carries one v1
|
|
16
|
+
per valid secret (`t=…,v1=<new>,v1=<old>`): the delivery is valid if ANY v1
|
|
17
|
+
matches. `secret` may also be a list (e.g. [new, old]) while you switch.
|
|
14
18
|
"""
|
|
15
19
|
|
|
16
20
|
from __future__ import annotations
|
|
@@ -29,7 +33,7 @@ def verify_signature(
|
|
|
29
33
|
*,
|
|
30
34
|
payload: bytes | str,
|
|
31
35
|
header: str,
|
|
32
|
-
secret: bytes | str,
|
|
36
|
+
secret: bytes | str | list | tuple,
|
|
33
37
|
tolerance: int | None = DEFAULT_TOLERANCE_SECONDS,
|
|
34
38
|
) -> dict:
|
|
35
39
|
"""Verify a delivery and return the parsed event. Raises
|
|
@@ -51,20 +55,34 @@ def verify_signature(
|
|
|
51
55
|
body = payload.encode("utf-8")
|
|
52
56
|
else:
|
|
53
57
|
raise SignatureVerificationError("payload must be bytes or str")
|
|
54
|
-
|
|
58
|
+
secrets = list(secret) if isinstance(secret, (list, tuple)) else [secret]
|
|
59
|
+
if not secrets:
|
|
60
|
+
raise SignatureVerificationError("no secret given")
|
|
61
|
+
secret_keys = [
|
|
62
|
+
s if isinstance(s, (bytes, bytearray)) else str(s).encode("utf-8") for s in secrets
|
|
63
|
+
]
|
|
55
64
|
|
|
56
65
|
try:
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
66
|
+
pairs = [part.split("=", 1) for part in header.split(",")]
|
|
67
|
+
timestamps = [value for key, value in pairs if key.strip() == "t"]
|
|
68
|
+
signatures = [value.strip() for key, value in pairs if key.strip() == "v1"]
|
|
69
|
+
if len(timestamps) != 1 or not signatures:
|
|
70
|
+
raise KeyError("t/v1")
|
|
71
|
+
timestamp = int(timestamps[0])
|
|
60
72
|
except (ValueError, KeyError) as exc:
|
|
61
73
|
raise SignatureVerificationError("malformed signature header") from exc
|
|
62
74
|
|
|
63
75
|
if tolerance is not None and abs(time.time() - timestamp) > tolerance:
|
|
64
76
|
raise SignatureVerificationError("timestamp outside tolerance (possible replay)")
|
|
65
77
|
|
|
66
|
-
|
|
67
|
-
|
|
78
|
+
signed = b"%d." % timestamp + body
|
|
79
|
+
matched = False
|
|
80
|
+
for key in secret_keys:
|
|
81
|
+
expected = hmac.new(key, signed, hashlib.sha256).hexdigest().encode()
|
|
82
|
+
for signature in signatures:
|
|
83
|
+
# no early exit: constant work whatever matches
|
|
84
|
+
matched |= hmac.compare_digest(expected, signature.encode())
|
|
85
|
+
if not matched:
|
|
68
86
|
raise SignatureVerificationError("signature mismatch")
|
|
69
87
|
try:
|
|
70
88
|
return json.loads(body.decode("utf-8"))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|