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.
- polaris_sdk_python-0.1.0.dist-info/METADATA +82 -0
- polaris_sdk_python-0.1.0.dist-info/RECORD +8 -0
- polaris_sdk_python-0.1.0.dist-info/WHEEL +5 -0
- polaris_sdk_python-0.1.0.dist-info/licenses/LICENSE +201 -0
- polaris_sdk_python-0.1.0.dist-info/licenses/NOTICE +85 -0
- polaris_sdk_python-0.1.0.dist-info/top_level.txt +1 -0
- polaris_verify/__init__.py +1012 -0
- polaris_verify/conformance.py +104 -0
|
@@ -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()
|