orangecheck 2.2.0__tar.gz → 2.3.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.
- {orangecheck-2.2.0 → orangecheck-2.3.0}/CHANGELOG.md +32 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/PKG-INFO +32 -3
- {orangecheck-2.2.0 → orangecheck-2.3.0}/README.md +29 -1
- {orangecheck-2.2.0 → orangecheck-2.3.0}/orangecheck/__init__.py +21 -1
- {orangecheck-2.2.0 → orangecheck-2.3.0}/orangecheck/canonical.py +19 -10
- orangecheck-2.3.0/orangecheck/integrator.py +327 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/pyproject.toml +4 -2
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/test_conformance.py +16 -0
- orangecheck-2.3.0/tests/test_integrator_vectors.py +149 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/.gitignore +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/orangecheck/__main__.py +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/orangecheck/bond.py +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/orangecheck/client.py +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/orangecheck/errors.py +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/orangecheck/pledge/__init__.py +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/orangecheck/pledge/bond.py +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/orangecheck/pledge/canonical.py +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/orangecheck/pledge/delegation.py +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/orangecheck/pledge/envelopes.py +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/orangecheck/pledge/resolution.py +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/orangecheck/pledge/state.py +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/orangecheck/py.typed +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/orangecheck/top_level.py +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/orangecheck/types.py +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/orangecheck/verify_sig.py +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/pledge/__init__.py +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/pledge/test_authority.py +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/pledge/test_delegation.py +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/pledge/test_vectors.py +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/pledge/test_wrap.py +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/test_bond_address.py +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/test_client.py +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/test_client_failure_modes.py +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/vectors/index.json +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/vectors/tv01.json +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/vectors/tv02.json +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/vectors/tv03.json +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/vectors/tv04.json +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/vectors/tv05.json +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/vectors/tv06.json +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/vectors/tv07.json +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/vectors/tv08.json +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/vectors/tv09.json +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/vectors/tv10.json +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/vectors/tv11.json +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/vectors/tv12.json +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/vectors/tv13.json +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/vectors/tv14.json +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/vectors/tv15.json +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/vectors/tv16.json +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/vectors/tv17.json +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/vectors/tv18.json +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/vectors/tv19.json +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/vectors/tv20.json +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/vectors/tv21.json +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/vectors/tv22.json +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/vectors/tv23.json +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/vectors/tv24.json +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/vectors/tv25.json +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/vectors/tv26.json +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/vectors/tv27.json +0 -0
- {orangecheck-2.2.0 → orangecheck-2.3.0}/tests/vectors/tv28.json +0 -0
|
@@ -4,6 +4,38 @@ All notable changes to the **`orangecheck`** Python package. Follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [2.3.0] — 2026-10-20
|
|
8
|
+
|
|
9
|
+
For a site that signs people in with ochk.app and runs a Python backend.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `verify_session(token, audience)` verifies a token from the sign-in popup
|
|
14
|
+
against `https://ochk.io/.well-known/jwks.json` (cached for an hour, kept
|
|
15
|
+
through an outage) and returns a `Session` with `did_oc`, `sub`, `jti`,
|
|
16
|
+
`exp` and any `oc_bond`. The audience is the exact origin of the page that
|
|
17
|
+
opened the popup; a token for any other origin, or with no `aud`, raises
|
|
18
|
+
`InvalidSession` with a one-word `reason`.
|
|
19
|
+
- `sign_event(secret, body)` builds the `X-OC-Signature` header for
|
|
20
|
+
`/api/integrator/event`: HMAC-SHA256 keyed by the secret's text over
|
|
21
|
+
`f"{t}.{body}"`.
|
|
22
|
+
- `verify_webhook(headers, body)` checks a delivery's Ed25519 signature
|
|
23
|
+
against `https://me.ochk.io/api/dev-jwks` and returns the parsed event, or
|
|
24
|
+
raises `InvalidWebhook`.
|
|
25
|
+
- These are held to `me-client/test-vectors/integrator.json`, the same vectors
|
|
26
|
+
`@orangecheck/me-client` is tested against.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- `cryptography` (41 or later) is now a dependency, for Ed25519.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- `build_canonical_message` and `format_identities` accept the
|
|
35
|
+
`IdentityBinding` exported as `orangecheck.IdentityBinding`. They took only
|
|
36
|
+
the canonical module's own class or a dict, so the documented example raised
|
|
37
|
+
`TypeError`.
|
|
38
|
+
|
|
7
39
|
## [2.2.0] — 2026-10-16
|
|
8
40
|
|
|
9
41
|
Matches `@orangecheck/sdk` 1.13.0 and the spec's regenerated `tv24`–`tv27`.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: orangecheck
|
|
3
|
-
Version: 2.
|
|
4
|
-
Summary: Python SDK for OrangeCheck — proof of Bitcoin stake
|
|
3
|
+
Version: 2.3.0
|
|
4
|
+
Summary: Python SDK for OrangeCheck — proof of Bitcoin stake, the OC Pledge primitive, and ochk.app sign-in for your backend.
|
|
5
5
|
Project-URL: Homepage, https://ochk.io
|
|
6
6
|
Project-URL: Documentation, https://ochk.io/docs
|
|
7
7
|
Project-URL: Repository, https://github.com/orangecheck/oc-packages
|
|
@@ -22,6 +22,7 @@ Classifier: Topic :: Internet :: WWW/HTTP
|
|
|
22
22
|
Classifier: Topic :: Security
|
|
23
23
|
Classifier: Typing :: Typed
|
|
24
24
|
Requires-Python: >=3.9
|
|
25
|
+
Requires-Dist: cryptography>=41
|
|
25
26
|
Requires-Dist: httpx>=0.25
|
|
26
27
|
Provides-Extra: dev
|
|
27
28
|
Requires-Dist: bip322>=0.2; extra == 'dev'
|
|
@@ -198,6 +199,34 @@ asyncio.run(main())
|
|
|
198
199
|
|
|
199
200
|
---
|
|
200
201
|
|
|
202
|
+
## ochk.app sign-in, for your backend
|
|
203
|
+
|
|
204
|
+
A site whose people sign in with ochk.app verifies the token the popup hands
|
|
205
|
+
back, signs billable events and checks webhooks with three functions. They
|
|
206
|
+
speak the same wire formats as `@orangecheck/me-client`.
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
import json, httpx
|
|
210
|
+
from orangecheck import verify_session, sign_event, verify_webhook, InvalidSession
|
|
211
|
+
|
|
212
|
+
# The origin of the page that opened the popup, exactly.
|
|
213
|
+
session = verify_session(token, audience="https://your-site.example")
|
|
214
|
+
session.did_oc # who signed in
|
|
215
|
+
|
|
216
|
+
body = json.dumps({"project_key": PROJECT_KEY, "subtype": "account_created",
|
|
217
|
+
"user_address": session.did_oc})
|
|
218
|
+
httpx.post("https://me.ochk.io/api/integrator/event", content=body, headers={
|
|
219
|
+
"Content-Type": "application/json",
|
|
220
|
+
"X-OC-Signature": sign_event(SIGNING_SECRET, body),
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
event = verify_webhook(request.headers, raw_body) # raises InvalidWebhook
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
The full guide is at [ochk.io/docs/sdk/python](https://ochk.io/docs/sdk/python).
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
201
230
|
## Configuration
|
|
202
231
|
|
|
203
232
|
```python
|
|
@@ -261,4 +290,4 @@ Exits `0` on pass, `2` on fail. Prefer the TypeScript `oc` CLI for richer output
|
|
|
261
290
|
|
|
262
291
|
## License
|
|
263
292
|
|
|
264
|
-
MIT. The OrangeCheck protocol
|
|
293
|
+
MIT. The OrangeCheck protocol specifications are MIT too.
|
|
@@ -162,6 +162,34 @@ asyncio.run(main())
|
|
|
162
162
|
|
|
163
163
|
---
|
|
164
164
|
|
|
165
|
+
## ochk.app sign-in, for your backend
|
|
166
|
+
|
|
167
|
+
A site whose people sign in with ochk.app verifies the token the popup hands
|
|
168
|
+
back, signs billable events and checks webhooks with three functions. They
|
|
169
|
+
speak the same wire formats as `@orangecheck/me-client`.
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
import json, httpx
|
|
173
|
+
from orangecheck import verify_session, sign_event, verify_webhook, InvalidSession
|
|
174
|
+
|
|
175
|
+
# The origin of the page that opened the popup, exactly.
|
|
176
|
+
session = verify_session(token, audience="https://your-site.example")
|
|
177
|
+
session.did_oc # who signed in
|
|
178
|
+
|
|
179
|
+
body = json.dumps({"project_key": PROJECT_KEY, "subtype": "account_created",
|
|
180
|
+
"user_address": session.did_oc})
|
|
181
|
+
httpx.post("https://me.ochk.io/api/integrator/event", content=body, headers={
|
|
182
|
+
"Content-Type": "application/json",
|
|
183
|
+
"X-OC-Signature": sign_event(SIGNING_SECRET, body),
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
event = verify_webhook(request.headers, raw_body) # raises InvalidWebhook
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
The full guide is at [ochk.io/docs/sdk/python](https://ochk.io/docs/sdk/python).
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
165
193
|
## Configuration
|
|
166
194
|
|
|
167
195
|
```python
|
|
@@ -225,4 +253,4 @@ Exits `0` on pass, `2` on fail. Prefer the TypeScript `oc` CLI for richer output
|
|
|
225
253
|
|
|
226
254
|
## License
|
|
227
255
|
|
|
228
|
-
MIT. The OrangeCheck protocol
|
|
256
|
+
MIT. The OrangeCheck protocol specifications are MIT too.
|
|
@@ -23,6 +23,11 @@ a custom API base URL, timeouts, or a shared httpx session:
|
|
|
23
23
|
from orangecheck import AsyncClient
|
|
24
24
|
async with AsyncClient() as oc:
|
|
25
25
|
result = await oc.check(addr="bc1q...", min_sats=100_000)
|
|
26
|
+
|
|
27
|
+
A site that signs people in with ochk.app verifies the sign-in token, signs
|
|
28
|
+
billable events and checks webhooks with three more:
|
|
29
|
+
|
|
30
|
+
from orangecheck import verify_session, sign_event, verify_webhook
|
|
26
31
|
"""
|
|
27
32
|
|
|
28
33
|
from .canonical import (
|
|
@@ -55,6 +60,14 @@ from .bond import (
|
|
|
55
60
|
)
|
|
56
61
|
from .client import AsyncClient, Client
|
|
57
62
|
from .errors import OrangeCheckError, RateLimitError, VerificationError
|
|
63
|
+
from .integrator import (
|
|
64
|
+
InvalidSession,
|
|
65
|
+
InvalidWebhook,
|
|
66
|
+
Session,
|
|
67
|
+
sign_event,
|
|
68
|
+
verify_session,
|
|
69
|
+
verify_webhook,
|
|
70
|
+
)
|
|
58
71
|
from .top_level import challenge_issue, challenge_verify, check, discover, verify
|
|
59
72
|
from .types import (
|
|
60
73
|
AttestationSummary,
|
|
@@ -99,6 +112,13 @@ __all__ = [
|
|
|
99
112
|
"BOND_SATS_FLOORS",
|
|
100
113
|
"BOND_DAYS_FLOORS",
|
|
101
114
|
"BOND_MAX_WINDOW_SECONDS",
|
|
115
|
+
# ochk.app sign-in, billable events and webhooks, for an integrator's backend
|
|
116
|
+
"verify_session",
|
|
117
|
+
"sign_event",
|
|
118
|
+
"verify_webhook",
|
|
119
|
+
"Session",
|
|
120
|
+
"InvalidSession",
|
|
121
|
+
"InvalidWebhook",
|
|
102
122
|
# Types
|
|
103
123
|
"CheckResult",
|
|
104
124
|
"VerifyOutcome",
|
|
@@ -113,7 +133,7 @@ __all__ = [
|
|
|
113
133
|
"VerificationError",
|
|
114
134
|
]
|
|
115
135
|
|
|
116
|
-
__version__ = "2.
|
|
136
|
+
__version__ = "2.3.0"
|
|
117
137
|
|
|
118
138
|
# OC Pledge submodule — sibling primitive for forward-looking commitments
|
|
119
139
|
# bound to a Bitcoin address. Imported here so consumers can write:
|
|
@@ -25,7 +25,9 @@ import re
|
|
|
25
25
|
import secrets
|
|
26
26
|
from dataclasses import dataclass
|
|
27
27
|
from datetime import datetime, timezone
|
|
28
|
-
from typing import Mapping, Sequence
|
|
28
|
+
from typing import Mapping, Sequence, Union
|
|
29
|
+
|
|
30
|
+
from . import types as _types
|
|
29
31
|
|
|
30
32
|
# ─── Identity safety rules ─────────────────────────────────────────────────
|
|
31
33
|
|
|
@@ -48,6 +50,11 @@ class IdentityBinding:
|
|
|
48
50
|
identifier: str
|
|
49
51
|
|
|
50
52
|
|
|
53
|
+
#: Either binding class (``orangecheck.IdentityBinding`` is the one in types),
|
|
54
|
+
#: or a plain dict with ``protocol`` / ``identifier`` keys.
|
|
55
|
+
IdentityLike = Union[IdentityBinding, _types.IdentityBinding, Mapping[str, str]]
|
|
56
|
+
|
|
57
|
+
|
|
51
58
|
def _assert_safe_identity(id: IdentityBinding) -> None:
|
|
52
59
|
if not _IDENTITY_PROTOCOL_RE.fullmatch(id.protocol):
|
|
53
60
|
raise ValueError(
|
|
@@ -60,24 +67,26 @@ def _assert_safe_identity(id: IdentityBinding) -> None:
|
|
|
60
67
|
)
|
|
61
68
|
|
|
62
69
|
|
|
63
|
-
def format_identities(identities: Sequence[
|
|
70
|
+
def format_identities(identities: Sequence[IdentityLike]) -> str:
|
|
64
71
|
"""
|
|
65
72
|
Format identities as comma-separated ``protocol:identifier`` pairs, sorted
|
|
66
73
|
lexicographically. Rejects unsafe characters at the boundary (see
|
|
67
74
|
``_assert_safe_identity``).
|
|
68
75
|
|
|
69
|
-
Accepts
|
|
70
|
-
``
|
|
76
|
+
Accepts ``IdentityBinding`` instances, including the one exported as
|
|
77
|
+
``orangecheck.IdentityBinding``, or plain dicts with ``protocol`` /
|
|
78
|
+
``identifier`` keys (for JSON interop).
|
|
71
79
|
"""
|
|
72
80
|
if not identities:
|
|
73
81
|
return ""
|
|
74
82
|
normalized: list[IdentityBinding] = []
|
|
75
83
|
for item in identities:
|
|
76
|
-
|
|
77
|
-
item
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
84
|
+
if isinstance(item, IdentityBinding):
|
|
85
|
+
b = item
|
|
86
|
+
elif isinstance(item, _types.IdentityBinding):
|
|
87
|
+
b = IdentityBinding(protocol=item.protocol, identifier=item.identifier)
|
|
88
|
+
else:
|
|
89
|
+
b = IdentityBinding(protocol=item["protocol"], identifier=item["identifier"])
|
|
81
90
|
_assert_safe_identity(b)
|
|
82
91
|
normalized.append(b)
|
|
83
92
|
return ",".join(sorted(f"{b.protocol}:{b.identifier}" for b in normalized))
|
|
@@ -129,7 +138,7 @@ def _now_rfc3339_utc() -> str:
|
|
|
129
138
|
def build_canonical_message(
|
|
130
139
|
*,
|
|
131
140
|
address: str,
|
|
132
|
-
identities: Sequence[
|
|
141
|
+
identities: Sequence[IdentityLike] = (),
|
|
133
142
|
extensions: Mapping[str, str | int | None] | None = None,
|
|
134
143
|
nonce: str | None = None,
|
|
135
144
|
issued_at: str | None = None,
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Helpers for a site's own backend when it signs people in with ochk.app.
|
|
3
|
+
|
|
4
|
+
Three jobs, the same wire formats as ``@orangecheck/me-client``:
|
|
5
|
+
|
|
6
|
+
from orangecheck import verify_session, sign_event, verify_webhook
|
|
7
|
+
|
|
8
|
+
session = verify_session(token, audience="https://your-site.example")
|
|
9
|
+
header = sign_event(signing_secret, body) # X-OC-Signature
|
|
10
|
+
event = verify_webhook(request.headers, raw_body)
|
|
11
|
+
|
|
12
|
+
Both SDKs are held to one set of vectors
|
|
13
|
+
(``me-client/test-vectors/integrator.json``), so they accept and refuse the
|
|
14
|
+
same tokens, signatures and deliveries.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import base64
|
|
20
|
+
import binascii
|
|
21
|
+
import hashlib
|
|
22
|
+
import hmac
|
|
23
|
+
import json
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
from collections.abc import Mapping
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import Any, Optional, Union
|
|
29
|
+
|
|
30
|
+
import httpx
|
|
31
|
+
from cryptography.exceptions import InvalidSignature
|
|
32
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
33
|
+
|
|
34
|
+
from .errors import OrangeCheckError
|
|
35
|
+
|
|
36
|
+
#: The auth host. Sign-in tokens name it as ``iss``; its key set signs them.
|
|
37
|
+
SESSION_ISSUER = "https://ochk.io"
|
|
38
|
+
SESSION_JWKS_URL = "https://ochk.io/.well-known/jwks.json"
|
|
39
|
+
#: me.ochk.io signs webhook deliveries with its own key (the path is historical).
|
|
40
|
+
WEBHOOK_JWKS_URL = "https://me.ochk.io/api/dev-jwks"
|
|
41
|
+
|
|
42
|
+
_JWKS_TTL_SECONDS = 3600
|
|
43
|
+
_FORCED_REFRESH_MIN_SECONDS = 60
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class InvalidSession(OrangeCheckError):
|
|
47
|
+
"""The sign-in token did not verify. ``reason`` says why, in one word."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, reason: str, message: str | None = None):
|
|
50
|
+
super().__init__(message or f"session token refused: {reason}")
|
|
51
|
+
self.reason = reason
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class InvalidWebhook(OrangeCheckError):
|
|
55
|
+
"""The webhook delivery did not verify. ``reason`` says why, in one word."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, reason: str, message: str | None = None):
|
|
58
|
+
super().__init__(message or f"webhook refused: {reason}")
|
|
59
|
+
self.reason = reason
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class Session:
|
|
64
|
+
"""A verified sign-in. ``did_oc`` is the person; ``sub`` is their account id."""
|
|
65
|
+
|
|
66
|
+
sub: str
|
|
67
|
+
did_oc: str
|
|
68
|
+
jti: str
|
|
69
|
+
aud: Union[str, list[str]]
|
|
70
|
+
iat: Optional[int]
|
|
71
|
+
exp: int
|
|
72
|
+
#: Coin backing, when the person backed their account (see require-bond).
|
|
73
|
+
oc_bond: Optional[dict[str, Any]] = None
|
|
74
|
+
claims: dict[str, Any] = field(default_factory=dict)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ─── key sets ────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class _KeySet:
|
|
81
|
+
"""A fetched JWKS, cached for an hour and kept through a failed refresh."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, url: str):
|
|
84
|
+
self.url = url
|
|
85
|
+
self._keys: dict[str, Ed25519PublicKey] = {}
|
|
86
|
+
self._fetched_at = 0.0
|
|
87
|
+
self._forced_at = 0.0
|
|
88
|
+
self._lock = threading.Lock()
|
|
89
|
+
|
|
90
|
+
def _fetch(self) -> None:
|
|
91
|
+
try:
|
|
92
|
+
r = httpx.get(self.url, headers={"Accept": "application/json"}, timeout=10.0)
|
|
93
|
+
r.raise_for_status()
|
|
94
|
+
self._keys = _import_jwks(r.json())
|
|
95
|
+
self._fetched_at = time.time()
|
|
96
|
+
except (httpx.HTTPError, ValueError):
|
|
97
|
+
# Stale on error: a key set seen before keeps working through an outage.
|
|
98
|
+
if not self._keys:
|
|
99
|
+
raise
|
|
100
|
+
|
|
101
|
+
def get(self, kid: str) -> Optional[Ed25519PublicKey]:
|
|
102
|
+
with self._lock:
|
|
103
|
+
if not self._keys or time.time() - self._fetched_at >= _JWKS_TTL_SECONDS:
|
|
104
|
+
self._fetch()
|
|
105
|
+
key = self._keys.get(kid)
|
|
106
|
+
# A key that rotated in since the last fetch: refetch, at most once a minute.
|
|
107
|
+
if key is None and time.time() - self._forced_at >= _FORCED_REFRESH_MIN_SECONDS:
|
|
108
|
+
self._forced_at = time.time()
|
|
109
|
+
self._fetch()
|
|
110
|
+
key = self._keys.get(kid)
|
|
111
|
+
return key
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
_session_keys = _KeySet(SESSION_JWKS_URL)
|
|
115
|
+
_webhook_keys = _KeySet(WEBHOOK_JWKS_URL)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _b64url(s: str) -> bytes:
|
|
119
|
+
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _import_jwks(doc: Any) -> dict[str, Ed25519PublicKey]:
|
|
123
|
+
if not isinstance(doc, dict) or not isinstance(doc.get("keys"), list):
|
|
124
|
+
raise ValueError("JWKS did not return {keys: [...]}")
|
|
125
|
+
keys: dict[str, Ed25519PublicKey] = {}
|
|
126
|
+
for k in doc["keys"]:
|
|
127
|
+
if not isinstance(k, dict) or not isinstance(k.get("kid"), str):
|
|
128
|
+
continue
|
|
129
|
+
if k.get("kty") != "OKP" or k.get("crv") != "Ed25519" or not isinstance(k.get("x"), str):
|
|
130
|
+
continue
|
|
131
|
+
try:
|
|
132
|
+
keys[k["kid"]] = Ed25519PublicKey.from_public_bytes(_b64url(k["x"]))
|
|
133
|
+
except (ValueError, binascii.Error):
|
|
134
|
+
continue
|
|
135
|
+
return keys
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ─── sign-in tokens ──────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def verify_session(
|
|
142
|
+
token: str,
|
|
143
|
+
audience: Union[str, list[str]],
|
|
144
|
+
*,
|
|
145
|
+
jwks: Optional[Mapping[str, Any]] = None,
|
|
146
|
+
issuer: str = SESSION_ISSUER,
|
|
147
|
+
leeway: int = 0,
|
|
148
|
+
) -> Session:
|
|
149
|
+
"""
|
|
150
|
+
Verify a token from the ochk.app sign-in popup and return who signed in.
|
|
151
|
+
|
|
152
|
+
``audience`` is the origin of the page that opened the popup, exactly
|
|
153
|
+
(``https://your-site.example``, or ``http://localhost:5173`` while you
|
|
154
|
+
develop). A token issued to any other origin, or a family token with no
|
|
155
|
+
``aud`` at all, is refused. The key set is fetched from
|
|
156
|
+
``https://ochk.io/.well-known/jwks.json`` and cached for an hour; pass
|
|
157
|
+
``jwks`` to verify offline.
|
|
158
|
+
|
|
159
|
+
Raises :class:`InvalidSession` on any failure. The token proves who signed
|
|
160
|
+
in until it expires (up to 30 days); it does not prove the person has not
|
|
161
|
+
signed out since. Keep your own session, and see the docs on ending one.
|
|
162
|
+
"""
|
|
163
|
+
parts = token.split(".") if isinstance(token, str) else []
|
|
164
|
+
if len(parts) != 3:
|
|
165
|
+
raise InvalidSession("malformed")
|
|
166
|
+
try:
|
|
167
|
+
header = json.loads(_b64url(parts[0]))
|
|
168
|
+
payload = json.loads(_b64url(parts[1]))
|
|
169
|
+
signature = _b64url(parts[2])
|
|
170
|
+
except (ValueError, binascii.Error) as err:
|
|
171
|
+
raise InvalidSession("malformed") from err
|
|
172
|
+
if not isinstance(header, dict) or not isinstance(payload, dict):
|
|
173
|
+
raise InvalidSession("malformed")
|
|
174
|
+
if header.get("alg") != "EdDSA":
|
|
175
|
+
raise InvalidSession("algorithm")
|
|
176
|
+
if "crit" in header:
|
|
177
|
+
raise InvalidSession("malformed")
|
|
178
|
+
kid = header.get("kid")
|
|
179
|
+
if not isinstance(kid, str) or not kid:
|
|
180
|
+
raise InvalidSession("malformed")
|
|
181
|
+
|
|
182
|
+
if jwks is not None:
|
|
183
|
+
try:
|
|
184
|
+
key = _import_jwks(dict(jwks)).get(kid)
|
|
185
|
+
except ValueError as err:
|
|
186
|
+
raise InvalidSession("unknown_key") from err
|
|
187
|
+
else:
|
|
188
|
+
try:
|
|
189
|
+
key = _session_keys.get(kid)
|
|
190
|
+
except (httpx.HTTPError, ValueError) as err:
|
|
191
|
+
raise InvalidSession("key_set_unavailable") from err
|
|
192
|
+
if key is None:
|
|
193
|
+
raise InvalidSession("unknown_key")
|
|
194
|
+
try:
|
|
195
|
+
key.verify(signature, f"{parts[0]}.{parts[1]}".encode("ascii"))
|
|
196
|
+
except InvalidSignature as err:
|
|
197
|
+
raise InvalidSession("signature") from err
|
|
198
|
+
|
|
199
|
+
if payload.get("iss") != issuer:
|
|
200
|
+
raise InvalidSession("issuer")
|
|
201
|
+
now = time.time()
|
|
202
|
+
exp = payload.get("exp")
|
|
203
|
+
if not isinstance(exp, (int, float)) or isinstance(exp, bool):
|
|
204
|
+
raise InvalidSession("claims")
|
|
205
|
+
if exp <= now - leeway:
|
|
206
|
+
raise InvalidSession("expired")
|
|
207
|
+
nbf = payload.get("nbf")
|
|
208
|
+
if nbf is not None and (not isinstance(nbf, (int, float)) or nbf > now + leeway):
|
|
209
|
+
raise InvalidSession("not_yet_valid")
|
|
210
|
+
iat = payload.get("iat")
|
|
211
|
+
if iat is not None and not isinstance(iat, (int, float)):
|
|
212
|
+
raise InvalidSession("claims")
|
|
213
|
+
for claim in ("sub", "did_oc", "jti"):
|
|
214
|
+
if not isinstance(payload.get(claim), str) or not payload[claim]:
|
|
215
|
+
raise InvalidSession("claims")
|
|
216
|
+
|
|
217
|
+
aud = payload.get("aud")
|
|
218
|
+
have = aud if isinstance(aud, list) else ([] if aud is None else [aud])
|
|
219
|
+
want = [audience] if isinstance(audience, str) else list(audience)
|
|
220
|
+
if not any(isinstance(a, str) and a in want for a in have):
|
|
221
|
+
raise InvalidSession("audience")
|
|
222
|
+
|
|
223
|
+
bond = payload.get("oc_bond")
|
|
224
|
+
return Session(
|
|
225
|
+
sub=payload["sub"],
|
|
226
|
+
did_oc=payload["did_oc"],
|
|
227
|
+
jti=payload["jti"],
|
|
228
|
+
aud=list(aud) if isinstance(aud, list) else str(aud),
|
|
229
|
+
iat=int(iat) if iat is not None else None,
|
|
230
|
+
exp=int(exp),
|
|
231
|
+
oc_bond=bond if isinstance(bond, dict) else None,
|
|
232
|
+
claims=payload,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ─── billable events ─────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def sign_event(
|
|
240
|
+
secret: str, body: Union[str, bytes], *, timestamp: Optional[int] = None
|
|
241
|
+
) -> str:
|
|
242
|
+
"""
|
|
243
|
+
The ``X-OC-Signature`` header for a request to ``/api/integrator/event``.
|
|
244
|
+
|
|
245
|
+
``body`` must be the exact bytes you send; serialise once and send that
|
|
246
|
+
string. The key is the signing secret's text as UTF-8 (it is not
|
|
247
|
+
hex-decoded), and the MAC covers ``f"{t}.{body}"``. me.ochk.io refuses a
|
|
248
|
+
timestamp more than 300 seconds from its clock.
|
|
249
|
+
|
|
250
|
+
body = json.dumps({"project_key": key, "subtype": "account_created",
|
|
251
|
+
"user_address": session.did_oc})
|
|
252
|
+
httpx.post(url, content=body, headers={
|
|
253
|
+
"Content-Type": "application/json",
|
|
254
|
+
"X-OC-Signature": sign_event(secret, body),
|
|
255
|
+
})
|
|
256
|
+
"""
|
|
257
|
+
t = int(time.time()) if timestamp is None else int(timestamp)
|
|
258
|
+
raw = body.encode("utf-8") if isinstance(body, str) else bytes(body)
|
|
259
|
+
mac = hmac.new(secret.encode("utf-8"), f"{t}.".encode("ascii") + raw, hashlib.sha256)
|
|
260
|
+
return f"t={t},v1={mac.hexdigest()}"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ─── webhooks ────────────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _header(headers: Mapping[str, Any], name: str) -> Optional[str]:
|
|
267
|
+
want = name.lower()
|
|
268
|
+
for k, v in headers.items():
|
|
269
|
+
if isinstance(k, str) and k.lower() == want:
|
|
270
|
+
return v if isinstance(v, str) else None
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def verify_webhook(
|
|
275
|
+
headers: Mapping[str, Any],
|
|
276
|
+
body: Union[str, bytes],
|
|
277
|
+
*,
|
|
278
|
+
jwks: Optional[Mapping[str, Any]] = None,
|
|
279
|
+
) -> dict[str, Any]:
|
|
280
|
+
"""
|
|
281
|
+
Verify a webhook from me.ochk.io and return its parsed body.
|
|
282
|
+
|
|
283
|
+
Pass the request headers and the raw body, before any JSON parsing. The
|
|
284
|
+
signature is Ed25519 over those bytes: ``OC-Signature`` (hex) under the key
|
|
285
|
+
named by ``OC-Key-Id``, from ``https://me.ochk.io/api/dev-jwks`` (cached for
|
|
286
|
+
an hour; pass ``jwks`` to verify offline). The signature carries no
|
|
287
|
+
timestamp, and a delivery may arrive more than once, so deduplicate on
|
|
288
|
+
``OC-Envelope-Id``.
|
|
289
|
+
|
|
290
|
+
Raises :class:`InvalidWebhook` on any failure.
|
|
291
|
+
"""
|
|
292
|
+
sig_hex = _header(headers, "OC-Signature")
|
|
293
|
+
kid = _header(headers, "OC-Key-Id")
|
|
294
|
+
if not sig_hex or not kid:
|
|
295
|
+
raise InvalidWebhook("missing")
|
|
296
|
+
try:
|
|
297
|
+
signature = bytes.fromhex(sig_hex.strip())
|
|
298
|
+
except ValueError as err:
|
|
299
|
+
raise InvalidWebhook("malformed") from err
|
|
300
|
+
if len(signature) != 64:
|
|
301
|
+
raise InvalidWebhook("malformed")
|
|
302
|
+
|
|
303
|
+
if jwks is not None:
|
|
304
|
+
try:
|
|
305
|
+
key = _import_jwks(dict(jwks)).get(kid)
|
|
306
|
+
except ValueError as err:
|
|
307
|
+
raise InvalidWebhook("unknown_key") from err
|
|
308
|
+
else:
|
|
309
|
+
try:
|
|
310
|
+
key = _webhook_keys.get(kid)
|
|
311
|
+
except (httpx.HTTPError, ValueError) as err:
|
|
312
|
+
raise InvalidWebhook("key_set_unavailable") from err
|
|
313
|
+
if key is None:
|
|
314
|
+
raise InvalidWebhook("unknown_key")
|
|
315
|
+
|
|
316
|
+
raw = body.encode("utf-8") if isinstance(body, str) else bytes(body)
|
|
317
|
+
try:
|
|
318
|
+
key.verify(signature, raw)
|
|
319
|
+
except InvalidSignature as err:
|
|
320
|
+
raise InvalidWebhook("signature") from err
|
|
321
|
+
try:
|
|
322
|
+
parsed = json.loads(raw)
|
|
323
|
+
except ValueError as err:
|
|
324
|
+
raise InvalidWebhook("malformed") from err
|
|
325
|
+
if not isinstance(parsed, dict):
|
|
326
|
+
raise InvalidWebhook("malformed")
|
|
327
|
+
return parsed
|
|
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "orangecheck"
|
|
7
|
-
version = "2.
|
|
8
|
-
description = "Python SDK for OrangeCheck — proof of Bitcoin stake
|
|
7
|
+
version = "2.3.0"
|
|
8
|
+
description = "Python SDK for OrangeCheck — proof of Bitcoin stake, the OC Pledge primitive, and ochk.app sign-in for your backend."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
11
11
|
authors = [{ name = "OrangeCheck" }]
|
|
@@ -34,6 +34,8 @@ classifiers = [
|
|
|
34
34
|
]
|
|
35
35
|
dependencies = [
|
|
36
36
|
"httpx>=0.25",
|
|
37
|
+
# Ed25519 for ochk.app sign-in tokens and webhook signatures (integrator.py).
|
|
38
|
+
"cryptography>=41",
|
|
37
39
|
]
|
|
38
40
|
|
|
39
41
|
[project.urls]
|
|
@@ -213,3 +213,19 @@ def test_vector_set_complete() -> None:
|
|
|
213
213
|
"bond_floors",
|
|
214
214
|
}
|
|
215
215
|
assert len(VECTORS) >= 28
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_build_accepts_the_exported_identity_binding() -> None:
|
|
219
|
+
# orangecheck.IdentityBinding (types) and the canonical module's class are
|
|
220
|
+
# separate; the docs build messages with the exported one.
|
|
221
|
+
from orangecheck import IdentityBinding
|
|
222
|
+
|
|
223
|
+
kw = dict(address="bc1qalice", nonce="a3f5b8c2d1e4f6a7b8c9d0e1f2a3b4c5",
|
|
224
|
+
issued_at="2026-04-24T06:47:29.977Z")
|
|
225
|
+
exported = build_canonical_message(
|
|
226
|
+
identities=[IdentityBinding(protocol="github", identifier="alice")], **kw
|
|
227
|
+
)
|
|
228
|
+
as_dict = build_canonical_message(
|
|
229
|
+
identities=[{"protocol": "github", "identifier": "alice"}], **kw
|
|
230
|
+
)
|
|
231
|
+
assert exported == as_dict
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ochk.app integrator wire formats, held to me-client's vectors.
|
|
3
|
+
|
|
4
|
+
``me-client/test-vectors/integrator.json`` is read here and by me-client's
|
|
5
|
+
``src/__tests__/integrator-vectors.test.ts``. Both SDKs must accept every
|
|
6
|
+
valid vector and refuse every invalid one, so a Python backend and a Node
|
|
7
|
+
backend can't drift apart. Regenerate with
|
|
8
|
+
``node me-client/scripts/gen-integrator-vectors.mjs``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
import pytest
|
|
19
|
+
import respx
|
|
20
|
+
|
|
21
|
+
import orangecheck.integrator as integrator
|
|
22
|
+
from orangecheck import (
|
|
23
|
+
InvalidSession,
|
|
24
|
+
InvalidWebhook,
|
|
25
|
+
sign_event,
|
|
26
|
+
verify_session,
|
|
27
|
+
verify_webhook,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
VECTORS_PATH = Path(__file__).resolve().parents[2] / "me-client" / "test-vectors" / "integrator.json"
|
|
31
|
+
V: dict[str, Any] = json.loads(VECTORS_PATH.read_text())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.fixture(autouse=True)
|
|
35
|
+
def fixed_clock(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
36
|
+
monkeypatch.setattr(integrator.time, "time", lambda: float(V["now"]))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.fixture
|
|
40
|
+
def fresh_key_sets(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
41
|
+
monkeypatch.setattr(integrator, "_session_keys", integrator._KeySet(integrator.SESSION_JWKS_URL))
|
|
42
|
+
monkeypatch.setattr(integrator, "_webhook_keys", integrator._KeySet(integrator.WEBHOOK_JWKS_URL))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ─── sign-in tokens ──────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.mark.parametrize("v", V["session"]["valid"], ids=lambda v: v["name"])
|
|
49
|
+
def test_session_accepts(v: dict[str, Any]) -> None:
|
|
50
|
+
s = verify_session(v["token"], v["audience"], jwks=V["session"]["jwks"])
|
|
51
|
+
for k, want in v["claims"].items():
|
|
52
|
+
assert s.claims[k] == want
|
|
53
|
+
assert s.did_oc == v["claims"]["did_oc"]
|
|
54
|
+
assert s.sub == v["claims"]["sub"]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.mark.parametrize("v", V["session"]["invalid"], ids=lambda v: v["name"])
|
|
58
|
+
def test_session_refuses(v: dict[str, Any]) -> None:
|
|
59
|
+
with pytest.raises(InvalidSession) as err:
|
|
60
|
+
verify_session(v["token"], v["audience"], jwks=V["session"]["jwks"])
|
|
61
|
+
assert err.value.reason == v["reason"]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@respx.mock
|
|
65
|
+
def test_session_fetches_the_ochk_io_key_set_once(fresh_key_sets: None) -> None:
|
|
66
|
+
route = respx.get("https://ochk.io/.well-known/jwks.json").mock(
|
|
67
|
+
return_value=httpx.Response(200, json=V["session"]["jwks"])
|
|
68
|
+
)
|
|
69
|
+
v = V["session"]["valid"][0]
|
|
70
|
+
verify_session(v["token"], v["audience"])
|
|
71
|
+
verify_session(v["token"], v["audience"])
|
|
72
|
+
assert route.call_count == 1
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@respx.mock
|
|
76
|
+
def test_session_keeps_a_key_set_through_an_outage(fresh_key_sets: None) -> None:
|
|
77
|
+
route = respx.get("https://ochk.io/.well-known/jwks.json").mock(
|
|
78
|
+
side_effect=[httpx.Response(200, json=V["session"]["jwks"]), httpx.Response(503)]
|
|
79
|
+
)
|
|
80
|
+
v = V["session"]["valid"][0]
|
|
81
|
+
verify_session(v["token"], v["audience"])
|
|
82
|
+
# The hour is up, the refetch fails, and the keys already held still verify.
|
|
83
|
+
integrator._session_keys._fetched_at = 0.0
|
|
84
|
+
assert verify_session(v["token"], v["audience"]).did_oc == v["claims"]["did_oc"]
|
|
85
|
+
assert route.call_count == 2
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@respx.mock
|
|
89
|
+
def test_session_without_any_key_set_is_refused(fresh_key_sets: None) -> None:
|
|
90
|
+
respx.get("https://ochk.io/.well-known/jwks.json").mock(return_value=httpx.Response(503))
|
|
91
|
+
v = V["session"]["valid"][0]
|
|
92
|
+
with pytest.raises(InvalidSession) as err:
|
|
93
|
+
verify_session(v["token"], v["audience"])
|
|
94
|
+
assert err.value.reason == "key_set_unavailable"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_session_leeway_admits_a_token_just_expired() -> None:
|
|
98
|
+
expired = next(v for v in V["session"]["invalid"] if v["name"] == "expired")
|
|
99
|
+
s = verify_session(expired["token"], expired["audience"], jwks=V["session"]["jwks"], leeway=5)
|
|
100
|
+
assert s.did_oc
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ─── event signatures ────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@pytest.mark.parametrize("v", V["event"], ids=lambda v: v["name"])
|
|
107
|
+
def test_sign_event_matches_the_node_sdk(v: dict[str, Any]) -> None:
|
|
108
|
+
assert sign_event(v["secret"], v["body"], timestamp=v["timestamp"]) == v["header"]
|
|
109
|
+
assert sign_event(v["secret"], v["body"].encode("utf-8"), timestamp=v["timestamp"]) == v["header"]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@pytest.mark.parametrize("v", V["event"], ids=lambda v: v["name"])
|
|
113
|
+
def test_event_body_is_what_json_dumps_gives_with_compact_separators(v: dict[str, Any]) -> None:
|
|
114
|
+
# The docs build the body with json.dumps(..., separators=(",", ":"),
|
|
115
|
+
# ensure_ascii=False); any serialisation works if you sign what you send.
|
|
116
|
+
assert json.dumps(v["fields"], separators=(",", ":"), ensure_ascii=False) == v["body"]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_sign_event_uses_the_clock_by_default() -> None:
|
|
120
|
+
v = V["event"][0]
|
|
121
|
+
assert sign_event(v["secret"], v["body"]).startswith(f"t={V['now']},v1=")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ─── webhooks ────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@pytest.mark.parametrize("v", V["webhook"]["valid"], ids=lambda v: v["name"])
|
|
128
|
+
def test_webhook_accepts(v: dict[str, Any]) -> None:
|
|
129
|
+
event = verify_webhook(v["headers"], v["body"], jwks=V["webhook"]["jwks"])
|
|
130
|
+
assert event == json.loads(v["body"])
|
|
131
|
+
lower = {k.lower(): val for k, val in v["headers"].items()}
|
|
132
|
+
assert verify_webhook(lower, v["body"].encode("utf-8"), jwks=V["webhook"]["jwks"]) == event
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@pytest.mark.parametrize("v", V["webhook"]["invalid"], ids=lambda v: v["name"])
|
|
136
|
+
def test_webhook_refuses(v: dict[str, Any]) -> None:
|
|
137
|
+
with pytest.raises(InvalidWebhook) as err:
|
|
138
|
+
verify_webhook(v["headers"], v["body"], jwks=V["webhook"]["jwks"])
|
|
139
|
+
assert err.value.reason == v["reason"]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@respx.mock
|
|
143
|
+
def test_webhook_fetches_me_ochk_io_dev_jwks(fresh_key_sets: None) -> None:
|
|
144
|
+
route = respx.get("https://me.ochk.io/api/dev-jwks").mock(
|
|
145
|
+
return_value=httpx.Response(200, json=V["webhook"]["jwks"])
|
|
146
|
+
)
|
|
147
|
+
v = V["webhook"]["valid"][0]
|
|
148
|
+
assert verify_webhook(v["headers"], v["body"])["id"] == "oc-me-tv-1"
|
|
149
|
+
assert route.call_count == 1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|