polaris-sdk-python 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1012 @@
1
+ """polaris-verify -- a server-side SDK to verify Polaris identity credentials.
2
+
3
+ A relying party (a bank, a border kiosk, an online service) drops this into its
4
+ backend to answer one narrow question about a credential a holder presented: is it
5
+ authentic, and is it authoritative right now? It never returns a person's data.
6
+
7
+ Two independent checks, deliberately kept apart:
8
+
9
+ * AUTHENTICITY -- offline, cryptographic, cacheable. Verify the ML-DSA-65
10
+ signature over SHA3-256(token_value) with a standard library (cryptography /
11
+ OpenSSL, and liboqs as a second witness when present), optionally against a
12
+ set of trusted issuer anchor keys. No network, no Polaris code.
13
+ * AUTHORIZATION -- online, fresh. Ask the issuer's /api/v1/verify, authenticating
14
+ as a registered organization with OAuth2 client-credentials, whether the token
15
+ is authoritative now.
16
+
17
+ `accept` requires both. Without a reachable issuer the verdict is `provisional`
18
+ (authentic, status unverified) -- never a full accept. Self-contained: only the
19
+ `cryptography` package plus the standard library.
20
+
21
+ from polaris_verify import PolarisVerifier
22
+ v = PolarisVerifier(issuer_url="https://issuer.example",
23
+ client_id="rp_...", client_secret="...",
24
+ anchors=["<issuer public key hex>"])
25
+ verdict = v.verify_presentation(presentation) # -> Verdict(decision="accept", ...)
26
+
27
+ Conformance: `python -m polaris_verify.conformance` implements the language-agnostic
28
+ verifier CLI the published conformance suite drives (see conformance/SPEC.md).
29
+ """
30
+ import base64
31
+ import dataclasses
32
+ import hashlib
33
+ import json
34
+ import time
35
+ import urllib.request
36
+ from typing import List, Optional
37
+
38
+ __version__ = "0.1.0"
39
+ ALGORITHM = "ML-DSA-65" # the default parameter set
40
+ # P8.8a: the accepted FIPS 204 parameter sets -> the cryptography witness class. ML-DSA-44 is
41
+ # below the floor and is rejected like any unknown algorithm.
42
+ ACCEPTED_ALGORITHMS = {"ML-DSA-65": "MLDSA65PublicKey", "ML-DSA-87": "MLDSA87PublicKey"}
43
+
44
+
45
+ def _accepted(alg):
46
+ """True iff `alg` names an accepted parameter set (total over hostile input)."""
47
+ return isinstance(alg, str) and alg in ACCEPTED_ALGORITHMS
48
+ PLACEHOLDER_LABEL = "DETERMINISTIC-PLACEHOLDER-SHA3-256"
49
+
50
+
51
+ @dataclasses.dataclass
52
+ class AuthenticityVerdict:
53
+ authentic: bool
54
+ issuer_trusted: Optional[bool] # None when no anchors were supplied
55
+ algorithm: Optional[str]
56
+ note: Optional[str] = None
57
+ witnesses: Optional[List[str]] = None
58
+
59
+
60
+ @dataclasses.dataclass
61
+ class Verdict:
62
+ decision: str # "accept" | "reject" | "provisional"
63
+ authentic: bool
64
+ issuer_trusted: Optional[bool]
65
+ currently_authoritative: Optional[bool]
66
+ status: Optional[str] = None
67
+ reasons: Optional[List[str]] = None
68
+
69
+ def as_dict(self):
70
+ return dataclasses.asdict(self)
71
+
72
+
73
+ def _digest(token_value: str) -> bytes:
74
+ # The signer signs SHA3-256(token_value.encode('utf-8')); reconstruct it.
75
+ return hashlib.sha3_256(token_value.encode("utf-8")).digest()
76
+
77
+
78
+ def _verify_cryptography(digest, sig, pk, alg=ALGORITHM):
79
+ try:
80
+ from cryptography.hazmat.primitives.asymmetric import mldsa
81
+ from cryptography.exceptions import InvalidSignature
82
+ except Exception:
83
+ return None
84
+ cls_name = ACCEPTED_ALGORITHMS[alg] if _accepted(alg) else None
85
+ if not cls_name or not hasattr(mldsa, cls_name):
86
+ return None
87
+ try:
88
+ key = getattr(mldsa, cls_name).from_public_bytes(pk)
89
+ except Exception:
90
+ return None
91
+ try:
92
+ key.verify(sig, digest)
93
+ return True
94
+ except InvalidSignature:
95
+ return False
96
+ except Exception:
97
+ return False
98
+
99
+
100
+ def _import_oqs():
101
+ """Import liboqs with its startup chatter kept off stdout.
102
+
103
+ `import oqs` prints "liboqs-python faulthandler is disabled" to STDOUT. This module IS
104
+ the verifier contract's reference implementation -- conformance/SPEC.md says a verifier
105
+ "prints its verdict as JSON on stdout" and names `python -m polaris_verify.conformance`
106
+ as the example -- so a third party whose runner does json.loads(stdout) got a parse
107
+ error on the first character whenever liboqs was installed.
108
+
109
+ Nothing caught it: `run_conformance.py --self` calls this SDK in-process rather than as
110
+ a subprocess, and the CI job that drives a verifier as a subprocess drives the
111
+ TypeScript one, which has no liboqs. Measured by running the documented command.
112
+ """
113
+ import contextlib
114
+ import sys as _sys
115
+ with contextlib.redirect_stdout(_sys.stderr):
116
+ import oqs # type: ignore
117
+ return oqs
118
+
119
+
120
+ def _verify_liboqs(digest, sig, pk, alg=ALGORITHM):
121
+ if not _accepted(alg):
122
+ return False
123
+ try:
124
+ oqs = _import_oqs()
125
+ except Exception:
126
+ return None
127
+ try:
128
+ with oqs.Signature(alg) as v:
129
+ return bool(v.verify(digest, sig, pk))
130
+ except Exception:
131
+ return False
132
+
133
+
134
+ def verify_authenticity(pack: dict, anchors=None) -> AuthenticityVerdict:
135
+ """Verify a Polaris authenticity pack OFFLINE. `anchors` is an optional
136
+ iterable of trusted issuer public keys (hex); when given, issuer_trusted says
137
+ whether the pack's key is one of them."""
138
+ tok = pack.get("token_value")
139
+ alg = pack.get("algorithm")
140
+ if alg != PLACEHOLDER_LABEL and not _accepted(alg):
141
+ return AuthenticityVerdict(False, None, alg, note="unknown or unaccepted signature algorithm: %r" % (alg,))
142
+ sig_hex = pack.get("signature_hex")
143
+ pk_hex = pack.get("public_key_hex")
144
+ if alg == PLACEHOLDER_LABEL or not pk_hex:
145
+ return AuthenticityVerdict(False, None, alg,
146
+ note="placeholder credential -- not authenticatable offline")
147
+ if not tok or not sig_hex:
148
+ return AuthenticityVerdict(False, None, alg, note="pack missing token_value or signature_hex")
149
+ try:
150
+ sig, pk = bytes.fromhex(sig_hex), bytes.fromhex(pk_hex)
151
+ except (ValueError, TypeError):
152
+ return AuthenticityVerdict(False, None, alg, note="signature_hex/public_key_hex are not valid hex")
153
+ digest = _digest(tok)
154
+ primary = _verify_cryptography(digest, sig, pk, alg)
155
+ witness = _verify_liboqs(digest, sig, pk, alg)
156
+ ran = []
157
+ if primary is not None:
158
+ ran.append("cryptography=%s" % ("valid" if primary else "invalid"))
159
+ if witness is not None:
160
+ ran.append("liboqs=%s" % ("valid" if witness else "invalid"))
161
+ if primary is None and witness is None:
162
+ return AuthenticityVerdict(False, None, alg, witnesses=ran,
163
+ note="no ML-DSA-65 verifier available: pip install 'cryptography>=48'")
164
+ if primary is not None and witness is not None and primary != witness:
165
+ return AuthenticityVerdict(False, None, alg, witnesses=ran,
166
+ note="the two witnesses DISAGREE -- treat as invalid")
167
+ ok = primary if primary is not None else witness
168
+ trusted = None
169
+ note = None
170
+ if anchors is not None:
171
+ trusted = pk_hex.lower() in {a.lower() for a in anchors}
172
+ if ok and not trusted:
173
+ note = "signature is genuine but its key is not in the trusted issuer anchors"
174
+ return AuthenticityVerdict(bool(ok), trusted, alg, note=note, witnesses=ran)
175
+
176
+
177
+ # --- Signed statements (P8.1) -------------------------------------------------
178
+ # Every signed artifact except the authenticity pack signs SHA3-256(canonical), where
179
+ # canonical is the sorted-keys compact JSON of its signed fields (see
180
+ # docs/reference/WIRE-SPEC.md). This is the same construction for all of them, so one
181
+ # helper verifies the signature and the per-artifact wrappers add their own rules.
182
+ _STATUS_ASSERTION_KEYS = ["format", "token_value", "status", "issued_at", "expires_at"]
183
+
184
+
185
+ def _canonical(obj: dict, keys) -> bytes:
186
+ return json.dumps({k: obj.get(k) for k in keys}, sort_keys=True, separators=(",", ":")).encode("utf-8")
187
+
188
+
189
+ def _verify_over_digest(digest, sig_hex, pk_hex, alg=ALGORITHM):
190
+ """Dual-witness ML-DSA verify over a digest under `alg` (an accepted parameter set).
191
+ Returns (ok, witnesses, note); ok is None when no verifier is available, the witnesses
192
+ disagree, or the algorithm is not accepted."""
193
+ if not _accepted(alg):
194
+ return None, [], "unknown or unaccepted signature algorithm: %r" % (alg,)
195
+ try:
196
+ sig, pk = bytes.fromhex(sig_hex), bytes.fromhex(pk_hex)
197
+ except (ValueError, TypeError):
198
+ return None, [], "signature_hex/public_key_hex are not valid hex"
199
+ primary = _verify_cryptography(digest, sig, pk, alg)
200
+ witness = _verify_liboqs(digest, sig, pk, alg)
201
+ ran = []
202
+ if primary is not None:
203
+ ran.append("cryptography=%s" % ("valid" if primary else "invalid"))
204
+ if witness is not None:
205
+ ran.append("liboqs=%s" % ("valid" if witness else "invalid"))
206
+ if primary is None and witness is None:
207
+ return None, ran, "no ML-DSA-65 verifier available: pip install 'cryptography>=48'"
208
+ if primary is not None and witness is not None and primary != witness:
209
+ return None, ran, "the two witnesses DISAGREE -- treat as invalid"
210
+ return (primary if primary is not None else witness), ran, None
211
+
212
+
213
+ def _iso_to_epoch(s):
214
+ from datetime import datetime, timezone
215
+ if not isinstance(s, str):
216
+ return None
217
+ if s.endswith("Z"):
218
+ s = s[:-1] + "+00:00"
219
+ try:
220
+ dt = datetime.fromisoformat(s)
221
+ except ValueError:
222
+ return None
223
+ if dt.tzinfo is None:
224
+ dt = dt.replace(tzinfo=timezone.utc)
225
+ return dt.timestamp()
226
+
227
+
228
+ #: Formats whose freshness is a REPLAY WINDOW rather than a validity interval, with the
229
+ #: age bound in seconds. A holder proof carries `issued_at` and no `expires_at`: it is a
230
+ #: presentation made for one verifier at one moment, and the question is not "has it
231
+ #: expired" but "is this the one just made for me, or one replayed from earlier".
232
+ #:
233
+ #: v9.430. Before this the SDK reported `fresh: None` for a holder proof, because
234
+ #: _within_window needs both ends and there is only one. The detached verifier has always
235
+ #: applied a 300-second bound. Two shipped reference verifiers disagreeing about whether a
236
+ #: presentation can be replayed is exactly the divergence the conformance suite exists to
237
+ #: catch, and it went unseen because no published case asked about freshness until v9.430.
238
+ _REPLAY_WINDOW_SECONDS = {"polaris-holder-proof/1": 300}
239
+
240
+ #: A proof may be up to this far ahead of the verifier's clock. Matches the detached
241
+ #: verifier; a skew allowance the two did not share would be the same bug again, smaller.
242
+ _CLOCK_SKEW_SECONDS = 60
243
+
244
+
245
+ def _within_replay_window(obj: dict, now=None):
246
+ """True iff obj was issued no more than its format's window ago, and not implausibly
247
+ far in the future. None if the format has no replay window or the instant is
248
+ unparseable."""
249
+ seconds = _REPLAY_WINDOW_SECONDS.get(obj.get("format"))
250
+ if seconds is None:
251
+ return None
252
+ issued = _iso_to_epoch(obj.get("issued_at"))
253
+ if issued is None:
254
+ return None
255
+ n = _iso_to_epoch(now) if now is not None else time.time()
256
+ if n is None:
257
+ return None
258
+ return (issued <= n + _CLOCK_SKEW_SECONDS) and ((n - issued) <= seconds)
259
+
260
+
261
+ def _within_window(obj: dict, now=None):
262
+ """True iff now is within [issued_at, expires_at). `now` is an ISO-8601 string, or None
263
+ for the current time. None if the window is unparseable."""
264
+ ia, ea = _iso_to_epoch(obj.get("issued_at")), _iso_to_epoch(obj.get("expires_at"))
265
+ if ia is None or ea is None:
266
+ return None
267
+ n = _iso_to_epoch(now) if now is not None else time.time()
268
+ if n is None:
269
+ return None
270
+ return ia <= n < ea
271
+
272
+
273
+ @dataclasses.dataclass
274
+ class StatusAssertionVerdict:
275
+ authentic: bool
276
+ fresh: Optional[bool]
277
+ active: Optional[bool]
278
+ status: Optional[str]
279
+ note: Optional[str] = None
280
+ witnesses: Optional[List[str]] = None
281
+
282
+
283
+ def verify_status_assertion(assertion: dict, now=None) -> StatusAssertionVerdict:
284
+ """Verify a Polaris status assertion OFFLINE (P3.6, wire spec section 3.5): the ML-DSA-65
285
+ signature over SHA3-256(canonical statement of {format, token_value, status, issued_at,
286
+ expires_at}); freshness (now within [issued_at, expires_at)); and whether the status is
287
+ ACTIVE. `now` is an ISO-8601 string or None for the current time. No network, no Polaris
288
+ code. A relying party deciding authorization offline requires authentic AND fresh AND
289
+ active, all bound to the presented credential's token_value."""
290
+ assertion = assertion if isinstance(assertion, dict) else {}
291
+ alg = assertion.get("algorithm")
292
+ status = assertion.get("status")
293
+ if alg == PLACEHOLDER_LABEL or not assertion.get("public_key_hex"):
294
+ return StatusAssertionVerdict(False, None, None, status, "placeholder -- not authenticatable offline")
295
+ if assertion.get("format") != "polaris-status-assertion/1":
296
+ return StatusAssertionVerdict(False, None, None, status, "not a polaris-status-assertion/1")
297
+ digest = hashlib.sha3_256(_canonical(assertion, _STATUS_ASSERTION_KEYS)).digest()
298
+ ok, ran, note = _verify_over_digest(digest, assertion.get("signature_hex"), assertion.get("public_key_hex"),
299
+ assertion.get("algorithm"))
300
+ if ok is None:
301
+ return StatusAssertionVerdict(False, None, None, status, note, ran)
302
+ return StatusAssertionVerdict(bool(ok), _within_window(assertion, now), status == "ACTIVE", status,
303
+ witnesses=ran)
304
+
305
+
306
+ # The signed-field list per artifact format (wire spec section 3). One generic verifier covers
307
+ # every signed statement; the pack (section 3.7) and the status assertion have their own
308
+ # entry points because their verdicts differ.
309
+ _ARTIFACT_KEYS = {
310
+ "polaris-epoch-checkpoint/1": ["format", "authority", "epoch", "prev", "as_of", "issued_at", "expires_at", "algorithm"],
311
+ "polaris-revocation-feed/1": ["format", "authority", "epoch_number", "as_of", "revoked_root_hex", "revoked_count", "revoked_leaves", "issued_at", "expires_at", "algorithm"],
312
+ "polaris-federation-manifest/1": ["format", "authority", "anchors", "attestations", "epoch", "revocation", "issued_at", "expires_at", "algorithm"],
313
+ "polaris-federation-status-bundle/1": ["format", "publisher", "members_root_hex", "member_count", "issued_at", "expires_at", "algorithm"],
314
+ "polaris-transparency-sth/1": ["format", "log_id", "tree_size", "root_hash_hex", "timestamp"],
315
+ "polaris-timestamp/1": ["format", "authority", "digest_hex", "digest_algorithm", "nonce", "issued_at", "algorithm"],
316
+ "polaris-registry/1": ["format", "publisher", "instance", "authorities", "contexts", "trust", "relying_parties", "issued_at", "expires_at", "algorithm"],
317
+ "polaris-exchange-request/1": ["format", "requester", "target", "context_id", "request_hash", "nonce", "issued_at", "algorithm"],
318
+ "polaris-signed-document/1": ["format", "document", "signer", "on_behalf_of", "purpose", "signed_at", "algorithm"],
319
+ "polaris-id-token/1": ["format", "iss", "sub", "aud", "nonce", "context_id", "disclosure_level", "acr", "enrollment", "auth_time", "iat", "exp", "algorithm"],
320
+ "polaris-trust-list/1": ["format", "publisher", "keys", "issued_at", "expires_at", "algorithm"],
321
+ "polaris-exchange-receipt/1": ["format", "requester", "responder", "context_id", "request_hash", "response_hash", "authorized_via", "occurred_at", "algorithm"],
322
+ "polaris-exchange-mint/1": ["format", "requester_public_key_hex", "context_id", "request_hash", "response_hash", "responder_agency_id", "occurred_at"],
323
+ # P9.5: the attesting agency's own signature over a federation trust edge.
324
+ "polaris-trust-attestation/1": ["format", "attesting_agency_id", "attested_agency_id", "attested_public_key_hex", "context_id", "attested_date", "valid_until", "algorithm"],
325
+ # P9.1: the issuer's binding of a holder key, and the holder's own proof of it.
326
+ "polaris-holder-binding/1": ["format", "token_value", "holder_public_key_hex", "holder_algorithm", "bound_at", "status", "issued_at", "expires_at", "algorithm"],
327
+ "polaris-holder-proof/1": ["format", "token_value", "context_id", "verifier_nonce", "issued_at", "algorithm"],
328
+ # P9.8: delegation. Signed by the HOLDER's key and the AGENT's, never the issuer's.
329
+ "polaris-agent-grant/1": ["format", "grant_id", "agent_public_key_hex", "agent_algorithm",
330
+ "actions", "limits", "context_id", "issued_at", "expires_at", "algorithm"],
331
+ "polaris-grant-revocation/1": ["format", "grant_id", "revoked_at", "algorithm"],
332
+ "polaris-agent-proof/1": ["format", "grant_id", "action", "service_nonce", "issued_at", "algorithm"],
333
+ # P9.2: the published anonymity set a holder proves against on their own device.
334
+ "polaris-epoch-leaves/1": ["format", "authority", "epoch_id", "context_id", "merkle_root", "leaf_count", "leaves_root_hex", "issued_at", "expires_at", "algorithm"],
335
+ }
336
+
337
+
338
+ @dataclasses.dataclass
339
+ class ArtifactVerdict:
340
+ authentic: bool
341
+ fresh: Optional[bool]
342
+ note: Optional[str] = None
343
+ witnesses: Optional[List[str]] = None
344
+ #: Whether the key that signed this artifact is one the caller trusts. None when no
345
+ #: anchors were supplied, which is the honest answer to a question nobody asked.
346
+ #:
347
+ #: v9.430. The SDK could not answer this for any windowed artifact: a caller learned
348
+ #: that a trust list, registry, manifest, timestamp, ID token, holder binding or epoch
349
+ #: bundle carried a valid signature, and had no way to learn whose. The detached
350
+ #: verifier has always taken anchors for all seven. Authenticity without trust is the
351
+ #: distinction v9.420 drew for the ID token's audience, one level out: a genuine
352
+ #: signature by a stranger is genuine and worthless.
353
+ issuer_trusted: Optional[bool] = None
354
+
355
+
356
+ def _revoked_root(leaves) -> str:
357
+ if not isinstance(leaves, (list, tuple)):
358
+ leaves = []
359
+ uniq = sorted({str(x).lower() for x in leaves})
360
+ return hashlib.sha3_256("\n".join(uniq).encode("utf-8")).hexdigest()
361
+
362
+
363
+ def _members_root(members) -> str:
364
+ if not isinstance(members, list):
365
+ members = []
366
+ digs = sorted(hashlib.sha3_256(json.dumps(m, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
367
+ for m in members)
368
+ return hashlib.sha3_256("\n".join(digs).encode("utf-8")).hexdigest()
369
+
370
+
371
+ @dataclasses.dataclass
372
+ class IdTokenVerdict:
373
+ authentic: bool
374
+ audience_matches: Optional[bool]
375
+ nonce_matches: Optional[bool]
376
+ fresh: Optional[bool]
377
+ sub: Optional[str]
378
+ acr: Optional[str]
379
+ note: Optional[str] = None
380
+ #: v9.431: whether the agency that signed the token is one this relying party trusts.
381
+ #: None when no anchors were supplied. A genuine token from an agency you have never
382
+ #: heard of is genuine and meaningless, which is the same distinction v9.420 drew
383
+ #: about the audience one level in.
384
+ issuer_trusted: Optional[bool] = None
385
+
386
+
387
+ def verify_id_token(tok: dict, audience=None, nonce=None, now=None,
388
+ anchors=None) -> IdTokenVerdict:
389
+ """Verify a polaris-id-token/1 (P8.4) as a relying party, offline: the issuing agency's
390
+ signature, that it was issued to THIS audience, that it carries the login's nonce, and
391
+ freshness (iat <= now < exp). The subject is a credential hash, never a person."""
392
+ tok = tok if isinstance(tok, dict) else {}
393
+ if tok.get("format") != "polaris-id-token/1":
394
+ return IdTokenVerdict(False, None, None, None, tok.get("sub"), tok.get("acr"), "not a polaris-id-token/1")
395
+ base = verify_signed_artifact(tok, now, anchors=anchors)
396
+ if not base.authentic:
397
+ return IdTokenVerdict(False, None, None, None, tok.get("sub"), tok.get("acr"),
398
+ base.note, base.issuer_trusted)
399
+ ia, ea = _iso_to_epoch(tok.get("iat")), _iso_to_epoch(tok.get("exp"))
400
+ n = _iso_to_epoch(now) if now is not None else time.time()
401
+ fresh = (ia <= n < ea) if (ia is not None and ea is not None and n is not None) else None
402
+ return IdTokenVerdict(True, (tok.get("aud") == audience) if audience is not None else None,
403
+ (tok.get("nonce") == nonce) if nonce is not None else None, fresh,
404
+ tok.get("sub"), tok.get("acr"), None, base.issuer_trusted)
405
+
406
+
407
+ def verify_signed_artifact(obj: dict, now=None, anchors=None) -> ArtifactVerdict:
408
+ """Verify a Polaris signed artifact's AUTHENTICITY offline (P8.1, wire spec section 3): for
409
+ the epoch checkpoint, revocation feed, federation manifest, status bundle, or transparency
410
+ STH, recompute the canonical statement for its `format`, verify the ML-DSA-65 signature over
411
+ its SHA3-256, check freshness for a windowed artifact, and check the artifact's commitment
412
+ (feed/bundle) or self-consistency (manifest). Standalone. Reports authentic + fresh. The
413
+ federation TRUST decision (accepting a foreign credential across authorities) is a separate,
414
+ composite check, not this per-artifact authenticity."""
415
+ obj = obj if isinstance(obj, dict) else {}
416
+ fmt = obj.get("format")
417
+ keys = _ARTIFACT_KEYS.get(fmt)
418
+ if keys is None:
419
+ return ArtifactVerdict(False, None, "unknown or unsupported artifact: %s" % fmt)
420
+ if obj.get("algorithm") == PLACEHOLDER_LABEL or not obj.get("public_key_hex"):
421
+ return ArtifactVerdict(False, None, "placeholder -- not authenticatable offline")
422
+ ok, ran, note = _verify_over_digest(hashlib.sha3_256(_canonical(obj, keys)).digest(),
423
+ obj.get("signature_hex"), obj.get("public_key_hex"), obj.get("algorithm"))
424
+ if ok is None:
425
+ return ArtifactVerdict(False, None, note, ran)
426
+ ok = bool(ok)
427
+ if ok and fmt == "polaris-revocation-feed/1":
428
+ ok = _revoked_root(obj.get("revoked_leaves")) == str(obj.get("revoked_root_hex") or "").lower()
429
+ note = None if ok else "the revocation feed's commitment does not match its leaves"
430
+ elif ok and fmt == "polaris-epoch-leaves/1":
431
+ # P9.2: the leaves ride outside the signed statement, committed to by leaves_root_hex,
432
+ # so a verifier checks the set with SHA3-256 alone and never needs the proving library.
433
+ leaves = obj.get("all_leaves_hex") if isinstance(obj.get("all_leaves_hex"), list) else []
434
+ ok = (_revoked_root(leaves) == str(obj.get("leaves_root_hex") or "").lower()
435
+ and len(leaves) == obj.get("leaf_count"))
436
+ note = None if ok else "the published leaves do not match the committed set"
437
+ elif ok and fmt == "polaris-federation-status-bundle/1":
438
+ ok = _members_root(obj.get("members")) == str(obj.get("members_root_hex") or "").lower()
439
+ note = None if ok else "the status bundle's members_root does not match its members"
440
+ elif ok and fmt == "polaris-federation-manifest/1":
441
+ active = {str(a.get("public_key_hex", "")).lower() for a in (obj.get("anchors") or [])
442
+ if isinstance(a, dict) and (a.get("status") or "active") == "active"}
443
+ ok = str(obj.get("public_key_hex") or "").lower() in active
444
+ note = None if ok else "the manifest is not signed by one of its own active anchors"
445
+ elif ok and fmt == "polaris-registry/1":
446
+ pub = obj.get("publisher") if isinstance(obj.get("publisher"), dict) else {}
447
+ listed = [str(a.get("public_key_hex") or "").lower() for a in (obj.get("authorities") or [])
448
+ if isinstance(a, dict) and a.get("agency_id") == pub.get("agency_id")
449
+ and (a.get("status") or "active") == "active"]
450
+ ok = str(obj.get("public_key_hex") or "").lower() in listed
451
+ note = None if ok else "the registry is not signed by the key it lists for its own publisher"
452
+ elif ok and fmt == "polaris-trust-list/1":
453
+ pub = obj.get("publisher") if isinstance(obj.get("publisher"), dict) else {}
454
+ active = [str(k.get("public_key_hex") or "").lower() for k in (obj.get("keys") or [])
455
+ if isinstance(k, dict) and k.get("agency_id") == pub.get("agency_id") and k.get("status") == "active"]
456
+ ok = str(obj.get("public_key_hex") or "").lower() in active
457
+ note = None if ok else "the trust list is not signed by a key it lists as active for its own publisher"
458
+ # A replay-windowed format answers freshness the other way round; _within_window
459
+ # needs both ends of an interval and such an artifact has only its issuance.
460
+ fresh = _within_replay_window(obj, now)
461
+ if fresh is None:
462
+ fresh = _within_window(obj, now)
463
+ # Same rule as the detached verifier: the key that signed it, lowercased, is in the
464
+ # anchor set. None when the caller supplied none.
465
+ trusted = None
466
+ if anchors is not None:
467
+ try:
468
+ trusted = str(obj.get("public_key_hex", "")).lower() in {
469
+ str(a).lower() for a in anchors}
470
+ except TypeError:
471
+ trusted = False
472
+ return ArtifactVerdict(ok, fresh, note, ran, trusted)
473
+
474
+
475
+ # --- P9.6: timestamp anchor verification (was P8.5c) --------------------------------
476
+ # Long-term validation asks whether a signature was valid at the instant it was made.
477
+ # A timestamp alone does not settle it: whoever holds the timestamp authority's key can
478
+ # mint a backdated one. An ANCHORED timestamp is different. Its digest is an entry in an
479
+ # append-only log whose head is published and cosigned by witnesses, so a forgery has to
480
+ # be absent from every witnessed head of its claimed era. Until now only the detached
481
+ # verifier could check that, which put the strongest form of long-term validation behind
482
+ # Polaris's own tooling. These four functions put it in the SDK an outsider installs.
483
+ _TIMESTAMP_LOG_ID = "polaris-timestamp-log"
484
+ _STH_FORMAT = "polaris-transparency-sth/1"
485
+ _COSIGNATURE_FORMAT = "polaris-transparency-cosignature/1"
486
+ _COSIGNATURE_KEYS = ["format", "log_id", "tree_size", "root_hash_hex"]
487
+
488
+
489
+ def timestamp_hash(ts: dict) -> str:
490
+ """A timestamp's entry in the timestamp transparency log: the SHA3-256 hex of the same
491
+ canonical statement its signature covers."""
492
+ keys = _ARTIFACT_KEYS["polaris-timestamp/1"]
493
+ return hashlib.sha3_256(_canonical(ts if isinstance(ts, dict) else {}, keys)).hexdigest()
494
+
495
+
496
+ def _leaf_hash(entry_hex: str) -> bytes:
497
+ """RFC 6962 leaf hash, SHA3-256(0x00 || entry), the entry taken as its UTF-8 bytes."""
498
+ return hashlib.sha3_256(b"\x00" + str(entry_hex).encode("utf-8")).digest()
499
+
500
+
501
+ def _node_hash(left: bytes, right: bytes) -> bytes:
502
+ """RFC 6962 interior node hash, SHA3-256(0x01 || left || right)."""
503
+ return hashlib.sha3_256(b"\x01" + left + right).digest()
504
+
505
+
506
+ def verify_inclusion(idx: int, tree_size: int, leaf: bytes, root: bytes, proof) -> bool:
507
+ """RFC 6962 section 2.1.1: is `leaf` the entry at `idx` in a tree of `tree_size` whose
508
+ head is `root`? Total on hostile input: a malformed path is False, never an exception."""
509
+ try:
510
+ idx, tree_size = int(idx), int(tree_size)
511
+ except (TypeError, ValueError):
512
+ return False
513
+ if idx < 0 or idx >= tree_size:
514
+ return False
515
+ fn, sn, r = idx, tree_size - 1, leaf
516
+ for pnode in proof:
517
+ if sn == 0 or not isinstance(pnode, (bytes, bytearray)):
518
+ return False
519
+ if (fn & 1) or (fn == sn):
520
+ r = _node_hash(bytes(pnode), r)
521
+ if not (fn & 1):
522
+ while fn != 0 and not (fn & 1):
523
+ fn >>= 1
524
+ sn >>= 1
525
+ else:
526
+ r = _node_hash(r, bytes(pnode))
527
+ fn >>= 1
528
+ sn >>= 1
529
+ return sn == 0 and r == root
530
+
531
+
532
+ def verify_cosignature(cosig: dict, witness_key=None) -> ArtifactVerdict:
533
+ """Verify a witness cosignature over a log head: the ML-DSA signature over the SHA3-256
534
+ of the canonical (format, log_id, tree_size, root_hash_hex), and with `witness_key` that
535
+ it came from the expected witness. `fresh` is None: a cosignature carries no window."""
536
+ c = cosig if isinstance(cosig, dict) else {}
537
+ if c.get("format") != _COSIGNATURE_FORMAT:
538
+ return ArtifactVerdict(False, None, "not a %s" % _COSIGNATURE_FORMAT)
539
+ if c.get("algorithm") == PLACEHOLDER_LABEL or not c.get("public_key_hex"):
540
+ return ArtifactVerdict(False, None, "placeholder cosignature -- not authenticatable offline")
541
+ ok, ran, note = _verify_over_digest(hashlib.sha3_256(_canonical(c, _COSIGNATURE_KEYS)).digest(),
542
+ c.get("signature_hex"), c.get("public_key_hex"), c.get("algorithm"))
543
+ if ok is None:
544
+ return ArtifactVerdict(False, None, note, ran)
545
+ if ok and witness_key is not None and str(c.get("public_key_hex") or "").lower() != str(witness_key).lower():
546
+ return ArtifactVerdict(False, None, "the cosignature is not from the expected witness", ran)
547
+ return ArtifactVerdict(bool(ok), None, None if ok else "cosignature signature is invalid", ran)
548
+
549
+
550
+ @dataclasses.dataclass
551
+ class AnchorVerdict:
552
+ anchored: bool
553
+ sth_authentic: bool
554
+ witnessed: Optional[bool]
555
+ cosigner_count: int
556
+ timestamp_hash: Optional[str]
557
+ index: Optional[int]
558
+ tree_size: Optional[int]
559
+ note: Optional[str] = None
560
+
561
+
562
+ def verify_timestamp_anchor(ts: dict, log_key=None, trusted_witnesses=None, threshold: int = 1) -> AnchorVerdict:
563
+ """Verify OFFLINE that a timestamp is ANCHORED in the timestamp transparency log: its
564
+ unsigned `anchor` carries an inclusion proof and a Signed Tree Head, the proof is for this
565
+ timestamp's own hash, the head is an authentic head of that log (with `log_key`, signed by
566
+ the expected authority), and the proof reconstructs the head. With `trusted_witnesses`, the
567
+ head must also be cosigned by `threshold` DISTINCT trusted witnesses: a stolen authority key
568
+ can sign a fresh head over a fabricated log, but it cannot make a witness have cosigned that
569
+ head at the claimed time. No network, no Polaris code. Total on hostile input."""
570
+ v = AnchorVerdict(False, False, None, 0, None, None, None, None)
571
+ if not isinstance(ts, dict):
572
+ v.note = "timestamp must be an object"
573
+ return v
574
+ anchor = ts.get("anchor")
575
+ if not isinstance(anchor, dict):
576
+ v.note = "the timestamp carries no anchor (unanchored: the authority kept no record of it)"
577
+ return v
578
+ proof, sth = anchor.get("proof"), anchor.get("sth")
579
+ if not isinstance(proof, dict) or not isinstance(sth, dict):
580
+ v.note = "anchor.proof and anchor.sth must be objects"
581
+ return v
582
+ v.timestamp_hash = timestamp_hash(ts)
583
+ if str(proof.get("entry_hex") or "").lower() != v.timestamp_hash:
584
+ v.note = "the proof is not for this timestamp"
585
+ return v
586
+ sv = verify_signed_artifact(sth)
587
+ v.sth_authentic = bool(sv.authentic)
588
+ if sth.get("log_id") != _TIMESTAMP_LOG_ID or proof.get("log_id") not in (None, _TIMESTAMP_LOG_ID):
589
+ v.note = "the head is not a %s head" % _TIMESTAMP_LOG_ID
590
+ return v
591
+ try:
592
+ idx, size = int(proof.get("index")), int(proof.get("tree_size"))
593
+ root = bytes.fromhex(str(sth.get("root_hash_hex")))
594
+ path = [bytes.fromhex(str(x)) for x in (proof.get("proof_hex") or [])]
595
+ except (TypeError, ValueError):
596
+ v.note = "malformed proof"
597
+ return v
598
+ v.index, v.tree_size = idx, size
599
+ if size != sth.get("tree_size") or \
600
+ str(proof.get("root_hash_hex") or "").lower() != str(sth.get("root_hash_hex") or "").lower():
601
+ v.note = "the proof and the head describe different trees"
602
+ return v
603
+ if not v.sth_authentic:
604
+ v.note = sv.note or "the head is not authentic"
605
+ return v
606
+ if log_key is not None and str(sth.get("public_key_hex") or "").lower() != str(log_key).lower():
607
+ v.note = "the head is not signed by the expected log key"
608
+ return v
609
+ v.anchored = verify_inclusion(idx, size, _leaf_hash(v.timestamp_hash), root, path)
610
+ if not v.anchored:
611
+ v.note = "the inclusion proof does not reconstruct the head"
612
+ return v
613
+ if trusted_witnesses is not None:
614
+ trusted = {str(t).lower() for t in trusted_witnesses}
615
+ seen = set()
616
+ for c in (anchor.get("cosignatures") or []):
617
+ if not isinstance(c, dict) or not verify_cosignature(c).authentic:
618
+ continue
619
+ if (c.get("log_id") == sth.get("log_id") and c.get("tree_size") == sth.get("tree_size")
620
+ and str(c.get("root_hash_hex") or "").lower() == str(sth.get("root_hash_hex") or "").lower()):
621
+ w = str(c.get("public_key_hex") or "").lower()
622
+ if w in trusted:
623
+ seen.add(w)
624
+ v.cosigner_count = len(seen)
625
+ v.witnessed = v.cosigner_count >= int(threshold or 1)
626
+ if not v.witnessed:
627
+ v.note = "only %d trusted witness cosignature(s) over this head, need %d" % (v.cosigner_count, threshold)
628
+ return v
629
+
630
+
631
+ @dataclasses.dataclass
632
+ class CrossAuthorityVerdict:
633
+ decision: str # "accept" | "reject"
634
+ authentic: bool
635
+ issuer_trusted: bool
636
+ via: Optional[str] = None
637
+ reason: Optional[str] = None
638
+ # P9.5: was the trust edge signed by the agency that made it, or is it an unsigned
639
+ # legacy row the manifest's signature carries on an operator's behalf? None when no
640
+ # edge was found. A relying party that requires signatures passes
641
+ # require_signed_attestation; one that merely wants to know reads this.
642
+ attestation_signed: Optional[bool] = None
643
+
644
+
645
+ @dataclasses.dataclass
646
+ class HolderVerdict:
647
+ proved: bool
648
+ binding_authentic: Optional[bool]
649
+ bound_to_credential: Optional[bool]
650
+ binding_fresh: Optional[bool]
651
+ proof_authentic: Optional[bool]
652
+ key_matches_binding: Optional[bool]
653
+ nonce_matches: Optional[bool]
654
+ note: Optional[str] = None
655
+
656
+
657
+ def verify_holder(credential: dict, binding: dict, proof: dict, expected_nonce=None,
658
+ expected_context=None, now=None, max_age_seconds: int = 300) -> HolderVerdict:
659
+ """Decide the holder key chain offline (P9.1): issuer anchor -> binding -> holder key -> proof.
660
+
661
+ Polaris was issuer-centric until v9.349: a holder held a credential, not a key pair, so
662
+ presenting the file was the whole of the proof. A holder proof answers a different
663
+ question, whether the party presenting it holds the key the ISSUER bound to that
664
+ credential. The proof is signed over the credential, the context, the verifier's nonce
665
+ and the instant, and deliberately NOT over the presented code, so a coerced presentation
666
+ stays byte-indistinguishable from a consenting one."""
667
+ v = HolderVerdict(False, None, None, None, None, None, None)
668
+ b = binding if isinstance(binding, dict) else {}
669
+ pr = proof if isinstance(proof, dict) else {}
670
+ if b.get("format") != "polaris-holder-binding/1" or pr.get("format") != "polaris-holder-proof/1":
671
+ v.note = "a holder chain needs a polaris-holder-binding/1 and a polaris-holder-proof/1"
672
+ return v
673
+ bv = verify_signed_artifact(b, now=now)
674
+ v.binding_authentic, v.binding_fresh = bv.authentic, bv.fresh
675
+ cred = credential if isinstance(credential, dict) else {}
676
+ v.bound_to_credential = (str(b.get("token_value")) == str(cred.get("token_value"))
677
+ and str(b.get("public_key_hex") or "").lower()
678
+ == str(cred.get("public_key_hex") or "").lower())
679
+ keys = _ARTIFACT_KEYS["polaris-holder-proof/1"]
680
+ ok, ran, note = _verify_over_digest(hashlib.sha3_256(_canonical(pr, keys)).digest(),
681
+ pr.get("signature_hex"), pr.get("public_key_hex"), pr.get("algorithm"))
682
+ v.proof_authentic = None if ok is None else bool(ok)
683
+ v.key_matches_binding = (str(pr.get("public_key_hex") or "").lower()
684
+ == str(b.get("holder_public_key_hex") or "").lower()
685
+ and (b.get("status") or "active") == "active")
686
+ if expected_nonce is not None:
687
+ v.nonce_matches = (str(pr.get("verifier_nonce")) == str(expected_nonce))
688
+ ctx_ok = expected_context is None or pr.get("context_id") == expected_context
689
+ issued = _iso_to_epoch(pr.get("issued_at"))
690
+ ref = _iso_to_epoch(now) if now else __import__("time").time()
691
+ fresh = issued is not None and ref is not None and issued <= ref + 60 and (ref - issued) <= max_age_seconds
692
+ v.proved = bool(v.binding_authentic and v.binding_fresh and v.bound_to_credential
693
+ and v.proof_authentic and v.key_matches_binding
694
+ and v.nonce_matches is not False and ctx_ok and fresh)
695
+ if not v.proved:
696
+ v.note = "the holder proof does not chain to a fresh issuer-signed binding for this credential"
697
+ return v
698
+
699
+
700
+ def verify_attestation(att: dict, attesting_agency_id=None, expected_key=None) -> ArtifactVerdict:
701
+ """Verify that a federation attestation carries the ATTESTING agency's own signature over
702
+ the attested key, the context and the window (P9.5).
703
+
704
+ Before v9.348 an attestation was a row an operator recorded, and the manifest that
705
+ published it signed whatever the table held, so a row inserted straight into a database
706
+ was indistinguishable from one made through the ceremony. An unsigned attestation is not
707
+ a failure but legacy: `authentic` is False with a note, and the caller decides whether to
708
+ require a signature. `fresh` is None; an attestation's window is its `valid_until`, which
709
+ the trust decision reads."""
710
+ a = att if isinstance(att, dict) else {}
711
+ if not a.get("signature_hex") and not a.get("public_key_hex"):
712
+ return ArtifactVerdict(False, None, "unsigned legacy attestation (recorded before v9.348)")
713
+ if a.get("format") != "polaris-trust-attestation/1":
714
+ return ArtifactVerdict(False, None, "not a polaris-trust-attestation/1")
715
+ if a.get("algorithm") == PLACEHOLDER_LABEL:
716
+ return ArtifactVerdict(False, None, "placeholder attestation signature -- not authenticatable offline")
717
+ keys = _ARTIFACT_KEYS["polaris-trust-attestation/1"]
718
+ ok, ran, note = _verify_over_digest(hashlib.sha3_256(_canonical(a, keys)).digest(),
719
+ a.get("signature_hex"), a.get("public_key_hex"), a.get("algorithm"))
720
+ if ok is None:
721
+ return ArtifactVerdict(False, None, note, ran)
722
+ if ok and attesting_agency_id is not None and a.get("attesting_agency_id") != attesting_agency_id:
723
+ return ArtifactVerdict(False, None, "the attestation names a different attesting agency "
724
+ "than the manifest that published it", ran)
725
+ if ok and expected_key is not None and \
726
+ str(a.get("attested_public_key_hex") or "").lower() != str(expected_key).lower():
727
+ return ArtifactVerdict(False, None, "the attestation is signed over a different attested key", ran)
728
+ return ArtifactVerdict(bool(ok), None, None if ok else "the attestation signature is invalid", ran)
729
+
730
+
731
+ def verify_cross_authority(pack: dict, context_id, manifests, trusted_anchors=None,
732
+ revocation_feed=None, now=None,
733
+ require_signed_attestation: bool = False) -> CrossAuthorityVerdict:
734
+ """Decide a FOREIGN credential across authorities OFFLINE (P8.1, wire spec section 4).
735
+ Accept iff: the authenticity pack is genuine; some federation manifest the relying party
736
+ trusts (authentic, fresh, and signed by a trusted anchor) attests the credential's signing
737
+ key in the presented context, non-transitively; and, if a revocation feed is supplied, the
738
+ credential is not revoked (the feed authentic, fresh, and bound to the issuer's key).
739
+ Standalone, no network."""
740
+ pack = pack if isinstance(pack, dict) else {}
741
+ a = verify_authenticity(pack)
742
+ if not a.authentic:
743
+ return CrossAuthorityVerdict("reject", False, False, reason="credential is not authentic")
744
+ token_key = str(pack.get("public_key_hex") or "").lower()
745
+ trusted = {t.lower() for t in trusted_anchors} if trusted_anchors is not None else None
746
+ via = None
747
+ signed_edge = None
748
+ for m in (manifests or []):
749
+ m = m if isinstance(m, dict) else {}
750
+ mv = verify_signed_artifact(m, now=now) # manifest: signature + self-consistency + freshness
751
+ if not (mv.authentic and mv.fresh):
752
+ continue
753
+ active = {str(x.get("public_key_hex", "")).lower() for x in (m.get("anchors") or [])
754
+ if isinstance(x, dict) and (x.get("status") or "active") == "active"}
755
+ if trusted is not None and not (active & trusted):
756
+ continue # the relying party does not trust this manifest's authority
757
+ for att in (m.get("attestations") or []):
758
+ if not isinstance(att, dict):
759
+ continue
760
+ if (str(att.get("attested_public_key_hex") or "").lower() == token_key
761
+ and (context_id is None or att.get("context_id") == context_id)):
762
+ # P9.5: is the edge signed by the agency that made it, or is it the
763
+ # operator's word carried by the manifest's signature?
764
+ auth = m.get("authority") if isinstance(m.get("authority"), dict) else {}
765
+ av = verify_attestation(att, attesting_agency_id=auth.get("agency_id"), expected_key=token_key)
766
+ unsigned = not att.get("signature_hex") and not att.get("public_key_hex")
767
+ if not unsigned and not av.authentic:
768
+ continue # a present-but-bad signature is worse than none: refuse the edge
769
+ if require_signed_attestation and unsigned:
770
+ continue
771
+ signed_edge = not unsigned
772
+ via = m.get("authority")
773
+ break
774
+ if via is not None:
775
+ break
776
+ if via is None:
777
+ return CrossAuthorityVerdict("reject", True, False,
778
+ reason="no trusted authority attests to this credential's issuer in this context")
779
+ via_str = via if isinstance(via, str) else None
780
+ if revocation_feed is not None:
781
+ rv = verify_signed_artifact(revocation_feed if isinstance(revocation_feed, dict) else {}, now=now)
782
+ bound = str((revocation_feed or {}).get("public_key_hex") or "").lower() == token_key
783
+ if not (rv.authentic and rv.fresh and bound):
784
+ return CrossAuthorityVerdict("reject", True, True, via_str,
785
+ "the issuer's revocation feed is not authentic, fresh, and bound to the issuer key",
786
+ signed_edge)
787
+ leaf = hashlib.sha3_256(str(pack.get("token_value") or "").encode("utf-8")).hexdigest()
788
+ if leaf in {str(x).lower() for x in (revocation_feed.get("revoked_leaves") or [])}:
789
+ return CrossAuthorityVerdict("reject", True, True, via_str,
790
+ "credential is revoked in the issuer's published feed", signed_edge)
791
+ return CrossAuthorityVerdict("accept", True, True, via_str, None, signed_edge)
792
+
793
+
794
+ class PolarisVerifier:
795
+ def __init__(self, issuer_url=None, client_id=None, client_secret=None,
796
+ anchors=None, timeout=30):
797
+ self.issuer_url = issuer_url.rstrip("/") if issuer_url else None
798
+ self.client_id = client_id
799
+ self.client_secret = client_secret
800
+ self.anchors = list(anchors) if anchors is not None else None
801
+ self.timeout = timeout
802
+ self._bearer = None
803
+ self._bearer_exp = 0.0
804
+
805
+ # --- OAuth2 client-credentials (cached, refreshed on expiry) --------------
806
+ def _access_token(self):
807
+ if self._bearer and time.time() < self._bearer_exp - 5:
808
+ return self._bearer
809
+ creds = base64.b64encode(("%s:%s" % (self.client_id, self.client_secret)).encode()).decode()
810
+ req = urllib.request.Request(
811
+ "%s/api/v1/oauth/token" % self.issuer_url, data=b"grant_type=client_credentials",
812
+ headers={"Authorization": "Basic " + creds,
813
+ "Content-Type": "application/x-www-form-urlencoded"})
814
+ with urllib.request.urlopen(req, timeout=self.timeout) as r:
815
+ body = json.loads(r.read())
816
+ self._bearer = body["access_token"]
817
+ self._bearer_exp = time.time() + int(body.get("expires_in", 300))
818
+ return self._bearer
819
+
820
+ def _online_status(self, cred):
821
+ body = json.dumps({"token_value": cred.get("token_value"),
822
+ "signature_hex": cred.get("signature_hex")}).encode()
823
+ req = urllib.request.Request(
824
+ "%s/api/v1/verify" % self.issuer_url, data=body,
825
+ headers={"Authorization": "Bearer " + self._access_token(),
826
+ "Content-Type": "application/json"})
827
+ with urllib.request.urlopen(req, timeout=self.timeout) as r:
828
+ return json.loads(r.read())
829
+
830
+ def verify_presentation(self, presentation) -> Verdict:
831
+ """Decide accept / reject / provisional for a holder's presentation (a
832
+ wallet presentation object, or a bare authenticity pack)."""
833
+ cred = presentation.get("credential") if isinstance(presentation, dict) and "credential" in presentation \
834
+ else presentation
835
+ cred = cred or {}
836
+ a = verify_authenticity(cred, self.anchors)
837
+ reasons = []
838
+ if not a.authentic:
839
+ reasons.append(a.note or "not authentic")
840
+ return Verdict("reject", False, a.issuer_trusted, None, reasons=reasons)
841
+ if a.issuer_trusted is False:
842
+ reasons.append(a.note or "issuer not trusted")
843
+ return Verdict("reject", True, False, None, reasons=reasons)
844
+ if not self.issuer_url:
845
+ reasons.append("status not checked (offline) -- authenticity only, not a full accept")
846
+ return Verdict("provisional", True, a.issuer_trusted, None, reasons=reasons)
847
+ try:
848
+ status = self._online_status(cred)
849
+ except Exception as e:
850
+ reasons.append("status check failed: %s" % e)
851
+ return Verdict("reject", True, a.issuer_trusted, None, reasons=reasons)
852
+ current = bool(status.get("currently_authoritative"))
853
+ if not current:
854
+ reasons.append("not currently authoritative (revoked/inactive): status=%s" % status.get("status"))
855
+ return Verdict("accept" if current else "reject", True, a.issuer_trusted, current,
856
+ status=status.get("status"), reasons=reasons or None)
857
+
858
+
859
+ # ---------------------------------------------------------------------------
860
+ # P9.3 — the scoped nullifier, for a relying party that enforces one person once.
861
+ #
862
+ # This SDK cannot verify a Plonky2 proof: that needs the polaris_zk crate, and a
863
+ # verifier without it must ABSTAIN rather than guess, which is what the detached
864
+ # verifier does. What an SDK integrator DOES need is the one rule that is easy to
865
+ # get wrong by hand, so it lives here rather than in prose someone may not read.
866
+ # ---------------------------------------------------------------------------
867
+
868
+ def nullifiers_link(a, b) -> bool:
869
+ """Do two nullifiers name the same person, in the same scope and epoch?
870
+
871
+ Exact match on lowercase hex, and nothing else. Three warnings, because each
872
+ is a mistake a relying party can make while believing it is being careful:
873
+
874
+ 1. Never compare PREFIXES. A nullifier is a Poseidon hash; a shared prefix
875
+ means nothing, and treating it as a partial match would refuse strangers.
876
+ 2. Never compare ACROSS SCOPES. Two verifiers' nullifiers for one person are
877
+ uncorrelated by construction. Comparing them cannot return information,
878
+ and a `False` from such a comparison must not be read as "different
879
+ person": the honest answer is that you cannot tell, which is the whole
880
+ point of the scope.
881
+ 3. Never carry a ledger ACROSS EPOCHS. The nullifier rotates each epoch so
882
+ that membership does not become a permanent identifier. A ledger that
883
+ outlives its epoch quietly turns the privacy feature into a lifelong one.
884
+
885
+ So: key your ledger by (your scope, epoch), keep only the nullifiers, and
886
+ compare with this.
887
+ """
888
+ if not isinstance(a, str) or not isinstance(b, str):
889
+ return False
890
+ return a.strip().lower() == b.strip().lower()
891
+
892
+
893
+ # ---------------------------------------------------------------------------
894
+ # P9.4 — the pairwise handle, for a relying party keying its own records.
895
+ # ---------------------------------------------------------------------------
896
+
897
+ _PAIRWISE_TAG = "polaris-pairwise/1"
898
+
899
+
900
+ def pairwise_handle(holder_public_key_hex, verifier_scope):
901
+ """The per-verifier handle to key your records by, instead of the token value.
902
+
903
+ `SHA3-256("polaris-pairwise/1|" || holder_public_key_hex || "|" || verifier_scope)`.
904
+ Recompute it yourself from the holder binding you verified; never take the holder's
905
+ word for it.
906
+
907
+ What it buys: your stored records cannot be matched against another verifier's. That
908
+ is the realistic threat, because stored values are what get pooled, sold, subpoenaed
909
+ and breached.
910
+
911
+ What it does not buy: a plain presentation still SHOWS you a stable token value,
912
+ issuer signature and holder key. Two verifiers who deliberately keep the raw material
913
+ can still correlate. Withholding it needs the zero-knowledge path, where the scoped
914
+ nullifier is the handle. `verify_presentation` in the detached verifier reports which
915
+ of the two applies, under `correlation`; do not assume the stronger one.
916
+
917
+ Returns None on unusable input rather than the hash of an empty string, which would
918
+ collide every holder into one record.
919
+ """
920
+ import hashlib as _h
921
+ if not isinstance(holder_public_key_hex, str) or not holder_public_key_hex.strip():
922
+ return None
923
+ if verifier_scope is None or not str(verifier_scope).strip():
924
+ return None
925
+ material = "%s|%s|%s" % (_PAIRWISE_TAG, holder_public_key_hex.strip().lower(),
926
+ str(verifier_scope).strip())
927
+ return _h.sha3_256(material.encode("utf-8")).hexdigest()
928
+
929
+
930
+ def handles_link(a, b) -> bool:
931
+ """Do two pairwise handles name the same holder at the same verifier?
932
+
933
+ Exact hex, and only within one scope. Comparing handles across verifiers is
934
+ meaningless: the values are uncorrelated by construction, so a False cannot be read as
935
+ "different person". Across scopes the honest answer is that you cannot tell.
936
+ """
937
+ if not isinstance(a, str) or not isinstance(b, str):
938
+ return False
939
+ return a.strip().lower() == b.strip().lower()
940
+
941
+
942
+ # ---------------------------------------------------------------------------
943
+ # P9.8 — the delegated agent grant.
944
+ #
945
+ # A service that an agent acts against needs to decide the chain offline. These are the
946
+ # canonical statements; verification is the same two-witness ML-DSA check this SDK already
947
+ # does for every other signed artifact.
948
+ # ---------------------------------------------------------------------------
949
+
950
+ _AGENT_GRANT_KEYS = ["format", "grant_id", "agent_public_key_hex", "agent_algorithm", "actions",
951
+ "limits", "context_id", "issued_at", "expires_at", "algorithm"]
952
+ _GRANT_REVOCATION_KEYS = ["format", "grant_id", "revoked_at", "algorithm"]
953
+ _AGENT_PROOF_KEYS = ["format", "grant_id", "action", "service_nonce", "issued_at", "algorithm"]
954
+
955
+
956
+ def grant_covers(grant, action) -> bool:
957
+ """Does this grant's signed `actions` list cover the action being requested?
958
+
959
+ An absent or empty list grants NOTHING. A verifier that read it as unrestricted would
960
+ turn a grant back into the unbounded credential hand-over that grants exist to replace,
961
+ and the mistake would be invisible because everything would work.
962
+ """
963
+ if not isinstance(grant, dict):
964
+ return False
965
+ actions = grant.get("actions")
966
+ if not isinstance(actions, (list, tuple)) or not actions:
967
+ return False
968
+ return str(action) in [str(a) for a in actions]
969
+
970
+
971
+ def grant_within_limits(grant, uses_so_far: int = 0, amount=None):
972
+ """Are the grant's stated limits still satisfied? Returns (ok, note).
973
+
974
+ Unknown limit keys are REFUSED, not ignored. A grant that says `max_transfers: 3` to a
975
+ service that has never heard of `max_transfers` must not be treated as unlimited; that
976
+ is how a bounded grant silently becomes an unbounded one.
977
+ """
978
+ if not isinstance(grant, dict) or not isinstance(grant.get("limits"), dict):
979
+ limits = {}
980
+ else:
981
+ limits = grant["limits"]
982
+ unknown = sorted(set(limits) - {"max_uses", "max_amount"})
983
+ if unknown:
984
+ return False, ("the grant carries limits this verifier does not understand (%s); refusing "
985
+ "rather than ignoring them" % ", ".join(unknown))
986
+ try:
987
+ if limits.get("max_uses") is not None and int(uses_so_far) >= int(limits["max_uses"]):
988
+ return False, "the grant's use limit (%s) is exhausted" % limits["max_uses"]
989
+ if limits.get("max_amount") is not None and amount is not None \
990
+ and float(amount) > float(limits["max_amount"]):
991
+ return False, "the requested amount exceeds the grant's limit (%s)" % limits["max_amount"]
992
+ except (TypeError, ValueError):
993
+ return False, "a limit or the requested amount is not a number"
994
+ return True, None
995
+
996
+
997
+ def revocation_ends_grant(revocation, grant) -> bool:
998
+ """Does this revocation end THIS grant, and was it signed by the right key?
999
+
1000
+ Signature verification is the caller's usual `verify_signed_artifact` step; this is the
1001
+ binding check that must accompany it. Anyone may publish bytes claiming to revoke a
1002
+ grant, but only the holder who signed the grant may end it, so the revocation's signing
1003
+ key must equal the grant's.
1004
+ """
1005
+ if not isinstance(revocation, dict) or not isinstance(grant, dict):
1006
+ return False
1007
+ if revocation.get("format") != "polaris-grant-revocation/1":
1008
+ return False
1009
+ if str(revocation.get("grant_id") or "") != str(grant.get("grant_id") or ""):
1010
+ return False
1011
+ return str(revocation.get("public_key_hex") or "").lower() == \
1012
+ str(grant.get("public_key_hex") or "").lower()