orangecheck 2.2.0__tar.gz → 2.3.1__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.
Files changed (65) hide show
  1. {orangecheck-2.2.0 → orangecheck-2.3.1}/CHANGELOG.md +43 -0
  2. {orangecheck-2.2.0 → orangecheck-2.3.1}/PKG-INFO +32 -3
  3. {orangecheck-2.2.0 → orangecheck-2.3.1}/README.md +29 -1
  4. {orangecheck-2.2.0 → orangecheck-2.3.1}/orangecheck/__init__.py +21 -1
  5. {orangecheck-2.2.0 → orangecheck-2.3.1}/orangecheck/canonical.py +19 -10
  6. orangecheck-2.3.1/orangecheck/integrator.py +327 -0
  7. {orangecheck-2.2.0 → orangecheck-2.3.1}/orangecheck/verify_sig.py +11 -2
  8. {orangecheck-2.2.0 → orangecheck-2.3.1}/pyproject.toml +4 -2
  9. orangecheck-2.3.1/tests/fixtures/bip322-official-basic.json +137 -0
  10. orangecheck-2.3.1/tests/fixtures/bip322-official-generated.json +551 -0
  11. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/pledge/test_vectors.py +2 -5
  12. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/pledge/test_wrap.py +5 -1
  13. orangecheck-2.3.1/tests/test_bip322_official.py +52 -0
  14. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/test_conformance.py +16 -0
  15. orangecheck-2.3.1/tests/test_integrator_vectors.py +149 -0
  16. {orangecheck-2.2.0 → orangecheck-2.3.1}/.gitignore +0 -0
  17. {orangecheck-2.2.0 → orangecheck-2.3.1}/orangecheck/__main__.py +0 -0
  18. {orangecheck-2.2.0 → orangecheck-2.3.1}/orangecheck/bond.py +0 -0
  19. {orangecheck-2.2.0 → orangecheck-2.3.1}/orangecheck/client.py +0 -0
  20. {orangecheck-2.2.0 → orangecheck-2.3.1}/orangecheck/errors.py +0 -0
  21. {orangecheck-2.2.0 → orangecheck-2.3.1}/orangecheck/pledge/__init__.py +0 -0
  22. {orangecheck-2.2.0 → orangecheck-2.3.1}/orangecheck/pledge/bond.py +0 -0
  23. {orangecheck-2.2.0 → orangecheck-2.3.1}/orangecheck/pledge/canonical.py +0 -0
  24. {orangecheck-2.2.0 → orangecheck-2.3.1}/orangecheck/pledge/delegation.py +0 -0
  25. {orangecheck-2.2.0 → orangecheck-2.3.1}/orangecheck/pledge/envelopes.py +0 -0
  26. {orangecheck-2.2.0 → orangecheck-2.3.1}/orangecheck/pledge/resolution.py +0 -0
  27. {orangecheck-2.2.0 → orangecheck-2.3.1}/orangecheck/pledge/state.py +0 -0
  28. {orangecheck-2.2.0 → orangecheck-2.3.1}/orangecheck/py.typed +0 -0
  29. {orangecheck-2.2.0 → orangecheck-2.3.1}/orangecheck/top_level.py +0 -0
  30. {orangecheck-2.2.0 → orangecheck-2.3.1}/orangecheck/types.py +0 -0
  31. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/pledge/__init__.py +0 -0
  32. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/pledge/test_authority.py +0 -0
  33. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/pledge/test_delegation.py +0 -0
  34. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/test_bond_address.py +0 -0
  35. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/test_client.py +0 -0
  36. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/test_client_failure_modes.py +0 -0
  37. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/vectors/index.json +0 -0
  38. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/vectors/tv01.json +0 -0
  39. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/vectors/tv02.json +0 -0
  40. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/vectors/tv03.json +0 -0
  41. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/vectors/tv04.json +0 -0
  42. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/vectors/tv05.json +0 -0
  43. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/vectors/tv06.json +0 -0
  44. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/vectors/tv07.json +0 -0
  45. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/vectors/tv08.json +0 -0
  46. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/vectors/tv09.json +0 -0
  47. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/vectors/tv10.json +0 -0
  48. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/vectors/tv11.json +0 -0
  49. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/vectors/tv12.json +0 -0
  50. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/vectors/tv13.json +0 -0
  51. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/vectors/tv14.json +0 -0
  52. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/vectors/tv15.json +0 -0
  53. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/vectors/tv16.json +0 -0
  54. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/vectors/tv17.json +0 -0
  55. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/vectors/tv18.json +0 -0
  56. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/vectors/tv19.json +0 -0
  57. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/vectors/tv20.json +0 -0
  58. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/vectors/tv21.json +0 -0
  59. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/vectors/tv22.json +0 -0
  60. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/vectors/tv23.json +0 -0
  61. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/vectors/tv24.json +0 -0
  62. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/vectors/tv25.json +0 -0
  63. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/vectors/tv26.json +0 -0
  64. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/vectors/tv27.json +0 -0
  65. {orangecheck-2.2.0 → orangecheck-2.3.1}/tests/vectors/tv28.json +0 -0
@@ -4,6 +4,49 @@ 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.1] — 2026-10-21
8
+
9
+ ### Fixed — signatures from wallets that follow the finalized BIP-322
10
+
11
+ - `verify_bip322_signature` accepts a simple signature with the `smp` prefix
12
+ the finalized BIP-322 requires of signers, as well as without it. Before,
13
+ every prefixed signature returned `False`. A `ful` or `pof` signature is not
14
+ supported and returns `False`, as in `@orangecheck/bip322` 1.1.
15
+ - Tests run the BIP's official vectors (bitcoin/bips `d77863fb9e9b`), the same
16
+ files the TypeScript package uses.
17
+
18
+ ## [2.3.0] — 2026-10-20
19
+
20
+ For a site that signs people in with ochk.app and runs a Python backend.
21
+
22
+ ### Added
23
+
24
+ - `verify_session(token, audience)` verifies a token from the sign-in popup
25
+ against `https://ochk.io/.well-known/jwks.json` (cached for an hour, kept
26
+ through an outage) and returns a `Session` with `did_oc`, `sub`, `jti`,
27
+ `exp` and any `oc_bond`. The audience is the exact origin of the page that
28
+ opened the popup; a token for any other origin, or with no `aud`, raises
29
+ `InvalidSession` with a one-word `reason`.
30
+ - `sign_event(secret, body)` builds the `X-OC-Signature` header for
31
+ `/api/integrator/event`: HMAC-SHA256 keyed by the secret's text over
32
+ `f"{t}.{body}"`.
33
+ - `verify_webhook(headers, body)` checks a delivery's Ed25519 signature
34
+ against `https://me.ochk.io/api/dev-jwks` and returns the parsed event, or
35
+ raises `InvalidWebhook`.
36
+ - These are held to `me-client/test-vectors/integrator.json`, the same vectors
37
+ `@orangecheck/me-client` is tested against.
38
+
39
+ ### Changed
40
+
41
+ - `cryptography` (41 or later) is now a dependency, for Ed25519.
42
+
43
+ ### Fixed
44
+
45
+ - `build_canonical_message` and `format_identities` accept the
46
+ `IdentityBinding` exported as `orangecheck.IdentityBinding`. They took only
47
+ the canonical module's own class or a dict, so the documented example raised
48
+ `TypeError`.
49
+
7
50
  ## [2.2.0] — 2026-10-16
8
51
 
9
52
  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.2.0
4
- Summary: Python SDK for OrangeCheck — proof of Bitcoin stake + the OC Pledge primitive (commitments) for the open web.
3
+ Version: 2.3.1
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 is CC-BY-4.0.
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 is CC-BY-4.0.
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.2.0"
136
+ __version__ = "2.3.1"
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[IdentityBinding | Mapping[str, str]]) -> str:
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 either ``IdentityBinding`` instances or plain dicts with
70
- ``protocol`` / ``identifier`` keys (for JSON interop).
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
- b = (
77
- item
78
- if isinstance(item, IdentityBinding)
79
- else IdentityBinding(protocol=item["protocol"], identifier=item["identifier"])
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[IdentityBinding | Mapping[str, str]] = (),
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
@@ -11,6 +11,10 @@ convention.
11
11
  when ``signature`` is a valid BIP-322 simple signature over ``message``
12
12
  for ``address``, ``False`` otherwise. Never raises for cryptographic
13
13
  failures — only for a missing optional dependency (at import time).
14
+
15
+ The finalized BIP-322 prefixes a simple signature with ``smp``; it is
16
+ accepted with or without the prefix, as ``@orangecheck/bip322`` 1.1 does.
17
+ A ``ful`` or ``pof`` signature is not supported and returns ``False``.
14
18
  """
15
19
 
16
20
  from __future__ import annotations
@@ -38,14 +42,19 @@ def verify_bip322_signature(address: str, message: str, signature: str) -> bool:
38
42
  attestations this is the canonical message produced by
39
43
  ``build_canonical_message``.
40
44
  signature:
41
- Base64-encoded BIP-322 "simple" signature. Hex is NOT accepted —
42
- normalize to base64 before calling.
45
+ Base64-encoded BIP-322 "simple" signature, with or without the
46
+ finalized BIP's ``smp`` prefix. Hex is NOT accepted — normalize to
47
+ base64 before calling.
43
48
 
44
49
  Returns
45
50
  -------
46
51
  True if the signature verifies; False otherwise. Never raises for
47
52
  invalid / malformed signatures.
48
53
  """
54
+ if signature.startswith(("ful", "pof")):
55
+ return False
56
+ if signature.startswith("smp"):
57
+ signature = signature[3:]
49
58
  try:
50
59
  _bip322.verify_simple_encoded(address, message, signature)
51
60
  except _bip322.VerificationError:
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "orangecheck"
7
- version = "2.2.0"
8
- description = "Python SDK for OrangeCheck — proof of Bitcoin stake + the OC Pledge primitive (commitments) for the open web."
7
+ version = "2.3.1"
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]