ffn-verify 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.
ffn_verify/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ """FFN Verified Business public verifier.
2
+
3
+ verify_business(domain): registry record, both signatures, status, domain binding
4
+ and delegation, no wallet needed. A port of @fofonet/verify-business; both replay
5
+ the same test vectors (ffn-pass/vectors/business/v1.json).
6
+
7
+ Data that mirrors the TS JSON (registry anchors, observed facts, presentation
8
+ input, every result) uses the TS camelCase keys; Python names are snake_case.
9
+ """
10
+
11
+ from ._version import __version__
12
+ from .credential import CredentialError, verify_credential
13
+ from .delegation import verify_delegation_chain
14
+ from .did import DidResolver
15
+ from .dns import doh_resolver, static_txt
16
+ from .domain import IdentifierError, normalize_domain, normalize_phone
17
+ from .fetch import urllib_fetch
18
+ from .networks import FFN_BUSINESS_REGISTRY
19
+ from .pin import PinnedRegistryResolver, key_thumbprint, pin_registry_keys
20
+ from .presentation import verify_presentation
21
+ from .status import StatusChecker
22
+ from .verify import VerificationError, lookup_business, verify_business
23
+
24
+ __all__ = [
25
+ "__version__",
26
+ "FFN_BUSINESS_REGISTRY",
27
+ "CredentialError",
28
+ "DidResolver",
29
+ "IdentifierError",
30
+ "PinnedRegistryResolver",
31
+ "StatusChecker",
32
+ "VerificationError",
33
+ "doh_resolver",
34
+ "key_thumbprint",
35
+ "lookup_business",
36
+ "normalize_domain",
37
+ "normalize_phone",
38
+ "pin_registry_keys",
39
+ "static_txt",
40
+ "urllib_fetch",
41
+ "verify_business",
42
+ "verify_credential",
43
+ "verify_delegation_chain",
44
+ "verify_presentation",
45
+ ]
ffn_verify/_egress.py ADDED
@@ -0,0 +1,167 @@
1
+ """Which URLs the verifier fetches on someone else's say-so.
2
+
3
+ A status list URI and a did:web come from credentials and records, so a hostile one
4
+ could point the verifier at the network it runs in. Before any request to such a URL:
5
+
6
+ * it must be ``https:``;
7
+ * its host must not be an IP literal in a private, loopback, link-local, CGNAT,
8
+ multicast, unspecified or reserved range (IPv4 or IPv6, IPv4-mapped included), nor
9
+ ``localhost``, ``*.localhost``, ``*.local``, ``*.internal`` or a single-label name
10
+ (``metadata``, ``printer``), which resolves through the local search domains.
11
+
12
+ This checks the URL, not where its name resolves: a public name whose DNS answers with
13
+ a private address (DNS rebinding) passes. Run the verifier behind an egress proxy
14
+ that refuses private destinations if that matters where it runs.
15
+
16
+ The host is read with the WHATWG parser (as the TS verifier and browsers read it).
17
+ Python's own HTTP stack splits URLs differently in a few places (``\\``, ``@`` and
18
+ non-ASCII in the authority), so a URL whose authority holds any of those is refused
19
+ rather than fetched under two readings.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import ipaddress
25
+ import re
26
+
27
+ from .url import URL, URLError
28
+
29
+
30
+ class RefusedURL(ValueError):
31
+ """The URL is not one the verifier fetches. No request was made."""
32
+
33
+
34
+ _V4_REFUSED = tuple(ipaddress.IPv4Network(n) for n in (
35
+ "0.0.0.0/8", # "this network", unspecified
36
+ "10.0.0.0/8", # private
37
+ "100.64.0.0/10", # CGNAT shared address space
38
+ "127.0.0.0/8", # loopback
39
+ "169.254.0.0/16", # link-local
40
+ "172.16.0.0/12", # private
41
+ "192.0.0.0/24", # IETF protocol assignments
42
+ "192.0.2.0/24", # documentation
43
+ "192.88.99.0/24", # 6to4 relay anycast (deprecated)
44
+ "192.168.0.0/16", # private
45
+ "198.18.0.0/15", # benchmarking
46
+ "198.51.100.0/24", # documentation
47
+ "203.0.113.0/24", # documentation
48
+ "224.0.0.0/4", # multicast
49
+ "240.0.0.0/4", # reserved, and the broadcast address
50
+ ))
51
+ # IPv6: only global unicast (2000::/3) is ever public; inside it, these are not.
52
+ _V6_GLOBAL_UNICAST = ipaddress.IPv6Network("2000::/3")
53
+ _V6_REFUSED_IN_GLOBAL = tuple(ipaddress.IPv6Network(n) for n in (
54
+ "2001::/23", # IETF protocol assignments (Teredo, benchmarking, ORCHID)
55
+ "2001:db8::/32", # documentation
56
+ "3fff::/20", # documentation
57
+ "2002::/16", # 6to4
58
+ ))
59
+ _V6_NAT64 = ipaddress.IPv6Network("64:ff9b::/96") # well-known prefix: the low 32 bits are an IPv4 address
60
+
61
+ _LOCAL_NAMES = ("localhost",)
62
+ _LOCAL_SUFFIXES = (".localhost", ".local", ".internal")
63
+
64
+
65
+ def refused_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
66
+ """Is this address one the verifier never fetches from?"""
67
+ if isinstance(ip, ipaddress.IPv4Address):
68
+ return any(ip in n for n in _V4_REFUSED)
69
+ if ip.ipv4_mapped is not None:
70
+ return refused_ip(ip.ipv4_mapped)
71
+ if ip in _V6_NAT64:
72
+ return refused_ip(ipaddress.IPv4Address(int(ip) & 0xFFFF_FFFF))
73
+ if ip not in _V6_GLOBAL_UNICAST:
74
+ return True # unspecified, loopback, mapped/compatible, ULA, link-local, multicast, reserved
75
+ return any(ip in n for n in _V6_REFUSED_IN_GLOBAL)
76
+
77
+
78
+ def _ip_literal(hostname: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
79
+ """The address a WHATWG-parsed hostname names, or None for a domain name."""
80
+ if hostname.startswith("[") and hostname.endswith("]"):
81
+ return ipaddress.IPv6Address(hostname[1:-1])
82
+ if re.fullmatch(r"[0-9]+(\.[0-9]+){3}", hostname):
83
+ return ipaddress.IPv4Address(hostname) # the parser has already normalized every IPv4 form
84
+ return None
85
+
86
+
87
+ def _name(hostname: str) -> str:
88
+ return hostname.lower().rstrip(".")
89
+
90
+
91
+ def refused_host(hostname: str) -> bool:
92
+ """A WHATWG ``URL.hostname`` the verifier never fetches from."""
93
+ ip = _ip_literal(hostname)
94
+ if ip is not None:
95
+ return refused_ip(ip)
96
+ name = _name(hostname)
97
+ if name in _LOCAL_NAMES or name.endswith(_LOCAL_SUFFIXES):
98
+ return True
99
+ # A single label (metadata, printer) resolves through the local search domains,
100
+ # i.e. inside the network the verifier runs in.
101
+ return "." not in name
102
+
103
+
104
+ def is_loopback_host(hostname: str) -> bool:
105
+ """127.0.0.0/8, ::1 (or IPv4-mapped loopback), ``localhost`` or ``*.localhost``:
106
+ where plain http is allowed, for a registry under local test."""
107
+ try:
108
+ ip = _ip_literal(hostname)
109
+ except ValueError:
110
+ return False
111
+ if ip is None:
112
+ name = _name(hostname)
113
+ return name == "localhost" or name.endswith(".localhost")
114
+ if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
115
+ ip = ip.ipv4_mapped
116
+ return ip.is_loopback
117
+
118
+
119
+ _UNSAFE_IN_AUTHORITY = re.compile(r"[^\x21-\x7e]|[@\\]")
120
+
121
+
122
+ def check_public_https(url: str, what: str = "URL") -> URL:
123
+ """Raise RefusedURL unless ``url`` is an https URL on a public host (see the module doc)."""
124
+ if not isinstance(url, str) or not url.startswith("https://"):
125
+ raise RefusedURL(f"{what} must be https")
126
+ if re.search(r"[\x00-\x20\x7f]", url):
127
+ raise RefusedURL(f"{what} is not a plain URL")
128
+ _check_authority(url, what)
129
+ try:
130
+ parsed = URL(url)
131
+ except (URLError, ValueError):
132
+ raise RefusedURL(f"{what} is not a valid URL") from None
133
+ try:
134
+ refused = refused_host(parsed.hostname)
135
+ except ValueError:
136
+ refused = True
137
+ if refused:
138
+ raise RefusedURL(f"{what} points at a private or local host")
139
+ return parsed
140
+
141
+
142
+ def _check_authority(url: str, what: str) -> None:
143
+ """The authority as written, before any parser normalizes it (up to the first / ? or
144
+ #): empty, or holding @, \\, whitespace, a control character or non-ASCII, it could
145
+ be read two ways."""
146
+ m = re.match(r"[A-Za-z][A-Za-z0-9+.\-]*://([^/?#]*)", url)
147
+ if not m or not m.group(1) or _UNSAFE_IN_AUTHORITY.search(m.group(1)):
148
+ raise RefusedURL(f"{what} is not a plain URL")
149
+
150
+
151
+ def check_registry_origin(origin: str) -> URL:
152
+ """The registry origin a caller pins: https, or http on a loopback host
153
+ (``localhost`` included) for local tests. The caller chooses it, so unlike the
154
+ hosts a record or presentation names, a single-label name is allowed, as in
155
+ door-core's net.ts."""
156
+ try:
157
+ if not isinstance(origin, str):
158
+ raise TypeError
159
+ _check_authority(origin, "the registry origin")
160
+ parsed = URL(origin)
161
+ except (URLError, ValueError, TypeError):
162
+ raise ValueError(f"the registry origin is not a plain URL: {origin!r}") from None
163
+ if parsed.scheme == "https" and parsed.hostname:
164
+ return parsed
165
+ if parsed.scheme == "http" and is_loopback_host(parsed.hostname):
166
+ return parsed
167
+ raise ValueError("the registry origin must be https (plain http only on a loopback host)")
ffn_verify/_js.py ADDED
@@ -0,0 +1,135 @@
1
+ """The few JavaScript semantics the TypeScript verifier leans on.
2
+
3
+ The verifier reads JSON from untrusted hosts. Where the TS code relies on a JS
4
+ behaviour that Python doesn't share (truthiness of ``{}`` and ``[]``,
5
+ ``String(x)`` in a template literal, ``obj.key`` on ``null`` throwing), these
6
+ helpers reproduce it, so odd input fails or passes the same way in both.
7
+
8
+ JSON ``null`` and a missing key both read as ``None`` here. Where the TS code
9
+ tells them apart (``x !== undefined``), the caller checks ``key in obj``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import math
15
+ from datetime import datetime, timezone
16
+ from typing import Any
17
+
18
+ # JS WhiteSpace and LineTerminator: what String.prototype.trim() strips and \s matches.
19
+ JS_WHITESPACE = (
20
+ "\t\n\v\f\r           "
21
+ "   

   "
22
+ )
23
+ JS_WS_CLASS = "[" + "".join(f"\\u{ord(c):04x}" for c in JS_WHITESPACE) + "]"
24
+
25
+
26
+ def trim(s: str) -> str:
27
+ return s.strip(JS_WHITESPACE)
28
+
29
+
30
+ def is_num(v: Any) -> bool:
31
+ """typeof v === 'number' (JSON never yields NaN or Infinity)."""
32
+ return isinstance(v, (int, float)) and not isinstance(v, bool)
33
+
34
+
35
+ def truthy(v: Any) -> bool:
36
+ if v is None or v is False:
37
+ return False
38
+ if is_num(v):
39
+ return v != 0 and not (isinstance(v, float) and math.isnan(v))
40
+ if isinstance(v, str):
41
+ return v != ""
42
+ return True # objects and arrays, even empty ones
43
+
44
+
45
+ def prop(obj: Any, key: str) -> Any:
46
+ """obj.key: throws on null/undefined, undefined on anything that isn't an object."""
47
+ if obj is None:
48
+ raise TypeError(f"Cannot read properties of null (reading '{key}')")
49
+ return obj.get(key) if isinstance(obj, dict) else None
50
+
51
+
52
+ def opt(obj: Any, key: str) -> Any:
53
+ """obj?.key"""
54
+ return None if obj is None else prop(obj, key)
55
+
56
+
57
+ def array(v: Any, what: str = "value") -> list:
58
+ """What .map/.filter/.some on a non-array would throw for."""
59
+ if not isinstance(v, list):
60
+ raise TypeError(f"{what} is not an array")
61
+ return v
62
+
63
+
64
+ def iterable(v: Any, what: str = "value") -> list:
65
+ """for...of and spread: arrays, and strings by code point."""
66
+ if isinstance(v, list):
67
+ return v
68
+ if isinstance(v, str):
69
+ return list(v)
70
+ raise TypeError(f"{what} is not iterable")
71
+
72
+
73
+ def same(a: Any, b: Any) -> bool:
74
+ """SameValueZero for JSON values (no bool/number mixing, 2 == 2.0)."""
75
+ if is_num(a) and is_num(b):
76
+ return a == b
77
+ return type(a) is type(b) and a == b
78
+
79
+
80
+ def includes(container: Any, item: Any) -> bool:
81
+ """Array.prototype.includes, or String.prototype.includes when it's a string."""
82
+ if isinstance(container, list):
83
+ return any(same(x, item) for x in container)
84
+ if isinstance(container, str):
85
+ return js_str(item) in container
86
+ raise TypeError("includes is not a function")
87
+
88
+
89
+ def js_str(v: Any) -> str:
90
+ """String(v), as a template literal renders it."""
91
+ if v is None:
92
+ return "null"
93
+ if v is True:
94
+ return "true"
95
+ if v is False:
96
+ return "false"
97
+ if isinstance(v, str):
98
+ return v
99
+ if isinstance(v, int):
100
+ return str(v)
101
+ if isinstance(v, float):
102
+ if v.is_integer() and abs(v) < 1e21:
103
+ return str(int(v))
104
+ return repr(v)
105
+ if isinstance(v, list):
106
+ return ",".join("" if x is None else js_str(x) for x in v)
107
+ return "[object Object]"
108
+
109
+
110
+ def template(obj: Any, key: str) -> str:
111
+ """`${obj.key}`: a missing member renders as "undefined", null as "null"."""
112
+ prop(obj, key) # null throws
113
+ return js_str(obj[key]) if isinstance(obj, dict) and key in obj else "undefined"
114
+
115
+
116
+ def nullish(v: Any, default: Any) -> Any:
117
+ """v ?? default"""
118
+ return default if v is None else v
119
+
120
+
121
+ def join(values: list, sep: str) -> str:
122
+ """Array.prototype.join: null and undefined become empty strings."""
123
+ return sep.join("" if v is None else js_str(v) for v in values)
124
+
125
+
126
+ def utf16_key(v: Any) -> bytes:
127
+ """Sort key matching Array.prototype.sort()'s default (UTF-16 code units of String(v))."""
128
+ return js_str(v).encode("utf-16-be", "surrogatepass")
129
+
130
+
131
+ def iso(seconds: float) -> str:
132
+ """new Date(seconds * 1000).toISOString()"""
133
+ ms = int(seconds * 1000) # Date truncates toward zero
134
+ dt = datetime.fromtimestamp(ms // 1000, tz=timezone.utc)
135
+ return dt.strftime("%Y-%m-%dT%H:%M:%S") + f".{ms % 1000:03d}Z"
ffn_verify/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
ffn_verify/binding.py ADDED
@@ -0,0 +1,68 @@
1
+ """Domain binding records, as door-core's binding.ts.
2
+
3
+ The registry lists the business's official domains, and each domain points back:
4
+
5
+ https://<domain>/.well-known/did.json the business DID document, with a service
6
+ entry naming its registry record
7
+ _ffn.<domain> TXT "v=ffn1; did=<did>; registry=<registry host>"
8
+
9
+ A lookalike domain fails both directions. Verifiers trust the registry they already
10
+ know, never what the page says.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from typing import Any
17
+
18
+ from ._js import JS_WS_CLASS, prop, trim
19
+
20
+ FFN_SERVICE_TYPE = "FfnVerifiedBusiness"
21
+ FFN_TXT_PREFIX = "_ffn"
22
+
23
+
24
+ def registry_entries_of(doc: dict) -> list[str]:
25
+ """Registry record URLs the DID document points at (service entries of type FfnVerifiedBusiness).
26
+ A ``service`` that isn't an array, and entries that aren't objects, point nowhere."""
27
+ out: list[str] = []
28
+ services = doc.get("service")
29
+ if not isinstance(services, list):
30
+ return out
31
+ for s in services:
32
+ if not isinstance(s, dict):
33
+ continue
34
+ types = prop(s, "type")
35
+ types = types if isinstance(types, list) else [types]
36
+ if FFN_SERVICE_TYPE not in [t for t in types if isinstance(t, str)]:
37
+ continue
38
+ ends = prop(s, "serviceEndpoint")
39
+ for e in ends if isinstance(ends, list) else [ends]:
40
+ if isinstance(e, str):
41
+ out.append(e)
42
+ return out
43
+
44
+
45
+ def ffn_txt_name(domain: str) -> str:
46
+ return f"{FFN_TXT_PREFIX}.{domain}"
47
+
48
+
49
+ _SPLIT_QUOTES = re.compile('"' + JS_WS_CLASS + '*"')
50
+
51
+
52
+ def unquote_txt(value: str) -> str:
53
+ # DNS answers may arrive quoted or split into 255-byte strings.
54
+ return re.sub(r'^"|"\Z', "", _SPLIT_QUOTES.sub("", value))
55
+
56
+
57
+ def parse_ffn_txt(value: Any) -> dict | None:
58
+ """{v, did, registry} from a _ffn TXT value, or None when it isn't one."""
59
+ if not isinstance(value, str):
60
+ raise TypeError("value.replace is not a function")
61
+ fields = {}
62
+ for part in unquote_txt(value).split(";"):
63
+ kv = trim(part).split("=")
64
+ if len(kv) >= 2:
65
+ fields[trim(kv[0])] = trim("=".join(kv[1:]))
66
+ if fields.get("v") != "ffn1" or not fields.get("did") or not fields.get("registry"):
67
+ return None
68
+ return {"v": "ffn1", "did": fields["did"], "registry": fields["registry"]}
ffn_verify/business.py ADDED
@@ -0,0 +1,60 @@
1
+ """FFN Verified Business credential kinds, levels and labels, as door-core's business.ts.
2
+
3
+ A business holds several narrow credentials rather than one big one, so each
4
+ expires and is revoked on its own schedule. The business signs its own profile and
5
+ its delegations; SPT signs only the attestations.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ VCT_BASE = "https://pass.fofo.network/vct"
11
+
12
+ BUSINESS_KINDS = {
13
+ "businessProfile": f"{VCT_BASE}/business-profile/v1",
14
+ "delegation": f"{VCT_BASE}/delegation/v1",
15
+ "businessIdentity": f"{VCT_BASE}/business-identity/v1",
16
+ "officialChannels": f"{VCT_BASE}/official-channels/v1",
17
+ "license": f"{VCT_BASE}/license/v1",
18
+ "insurance": f"{VCT_BASE}/insurance/v1",
19
+ "representative": f"{VCT_BASE}/representative/v1",
20
+ }
21
+
22
+ ATTESTATION_KINDS = ("businessIdentity", "officialChannels", "license", "insurance", "representative")
23
+
24
+ KIND_LABELS = {
25
+ "businessProfile": "Business profile",
26
+ "delegation": "Delegation",
27
+ "businessIdentity": "Business identity",
28
+ "officialChannels": "Official channels",
29
+ "license": "License",
30
+ "insurance": "Insurance",
31
+ "representative": "Verified representative",
32
+ }
33
+
34
+ LEVEL_NAMES = {0: "Not verified", 1: "Registered", 2: "Licensed and insured", 3: "Owner verified"}
35
+
36
+ # What each level needs, cumulatively.
37
+ LEVEL_REQUIREMENTS = {
38
+ 1: ("businessIdentity", "officialChannels"),
39
+ 2: ("businessIdentity", "officialChannels", "license", "insurance"),
40
+ 3: ("businessIdentity", "officialChannels", "license", "insurance", "representative"),
41
+ }
42
+
43
+ # The delegation every listing carries: the business lets the registry publish and serve its record.
44
+ REGISTRY_SCOPES = ("registry:publish", "registry:serve")
45
+
46
+ BUSINESS_RECORD_TYP = "ffn-business-record+jwt"
47
+
48
+
49
+ def kind_for_vct(vct: str) -> str | None:
50
+ for kind, value in BUSINESS_KINDS.items():
51
+ if value == vct:
52
+ return kind
53
+ return None
54
+
55
+
56
+ def level_for(valid: set[str]) -> int:
57
+ for level in (3, 2, 1):
58
+ if all(k in valid for k in LEVEL_REQUIREMENTS[level]):
59
+ return level
60
+ return 0
@@ -0,0 +1,100 @@
1
+ """Verifying an SD-JWT VC outside the OpenID4VC flows, as door-core's credential.ts:
2
+ a business's own profile, SPT's attestations for the registry, an agent presenting
3
+ a delegation over plain HTTP. No wallet or trust registry needed to check one.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ import time
10
+ from typing import Any
11
+
12
+ from . import sdjwt
13
+ from ._js import is_num, opt, prop, truthy
14
+ from .did import DidResolver
15
+ from .jose import b64u_encode, decode_jwt, header_kid, jwk_thumbprint, sha256
16
+
17
+
18
+ class CredentialError(Exception):
19
+ def __init__(self, message: str, code: str = "invalid_credential"):
20
+ super().__init__(message)
21
+ self.code = code
22
+ self.message = message
23
+
24
+
25
+ def issuer_jwt_of(compact: str) -> str:
26
+ """Issuer-signed part of a compact SD-JWT (everything before the first '~')."""
27
+ return compact.split("~")[0]
28
+
29
+
30
+ def credential_hash(compact: str) -> str:
31
+ """Base64url SHA-256 of the issuer-signed JWT: how a child delegation names its parent."""
32
+ return b64u_encode(sha256(issuer_jwt_of(compact)))
33
+
34
+
35
+ def same_key(a: Any, b: Any) -> bool:
36
+ """Same key? Thumbprints, so member order and extra members don't matter."""
37
+ return jwk_thumbprint(a) == jwk_thumbprint(b)
38
+
39
+
40
+ def verify_credential(compact: str, *, resolver: DidResolver, now: float | None = None, skew_seconds: float = 60,
41
+ key_binding: dict | None = None) -> dict:
42
+ """Verify the issuer signature (key from the issuer's DID document), every disclosure,
43
+ the time claims and, when ``key_binding`` ({nonce, audience, maxAgeSeconds}) is given,
44
+ the holder's key binding. Status is separate (see StatusChecker) because callers treat
45
+ an unreachable status list differently.
46
+
47
+ Returns {header, claims, iss, vct, cnfJwk, issuerKey, kb?}. Raises CredentialError.
48
+ """
49
+ try:
50
+ header, payload, _, _ = decode_jwt(issuer_jwt_of(compact))
51
+ except Exception:
52
+ raise CredentialError("malformed credential") from None
53
+ iss, vct = prop(payload, "iss"), prop(payload, "vct")
54
+ if not isinstance(iss, str) or not isinstance(vct, str):
55
+ raise CredentialError("credential has no issuer or type")
56
+ if prop(header, "alg") != "ES256":
57
+ raise CredentialError("unsupported alg")
58
+ cnf_jwk = opt(prop(payload, "cnf"), "jwk")
59
+ if not truthy(cnf_jwk):
60
+ raise CredentialError("credential is not key-bound")
61
+
62
+ try:
63
+ issuer_key = resolver.assertion_key(iss, header_kid(header))
64
+ except Exception as err:
65
+ raise CredentialError(f"issuer key not found: {err}", "untrusted_issuer") from None
66
+
67
+ now = int(time.time()) if now is None else now
68
+ skew = skew_seconds
69
+ kb_options = None
70
+ if key_binding is not None:
71
+ max_age = key_binding.get("maxAgeSeconds")
72
+ kb_options = {**key_binding, "maxAgeSeconds": 300 if max_age is None else max_age}
73
+ try:
74
+ claims, kb_payload = sdjwt.verify(compact, issuer_key=issuer_key, cnf_jwk=cnf_jwk, now=now, skew=skew,
75
+ key_binding=kb_options)
76
+ except Exception as err:
77
+ message = str(err)
78
+ code = "expired" if re.search(r"\bis expired\b", message, re.ASCII) else "invalid_credential"
79
+ raise CredentialError(f"credential failed verification: {message}", code) from None
80
+ # Belt and braces on time: the library's checks read the disclosed claims.
81
+ exp, nbf = payload.get("exp"), payload.get("nbf")
82
+ if is_num(exp) and now - skew > exp:
83
+ raise CredentialError("credential expired", "expired")
84
+ if is_num(nbf) and now + skew < nbf:
85
+ raise CredentialError("credential not yet valid")
86
+
87
+ result = {"header": header, "claims": claims, "iss": iss, "vct": vct, "cnfJwk": cnf_jwk, "issuerKey": issuer_key}
88
+ if kb_options is not None:
89
+ if kb_payload is None:
90
+ raise CredentialError("key binding missing")
91
+ if kb_payload.get("aud") != kb_options.get("audience"):
92
+ raise CredentialError("key binding audience mismatch")
93
+ if kb_payload.get("nonce") != kb_options.get("nonce"):
94
+ raise CredentialError("key binding nonce mismatch")
95
+ iat = kb_payload.get("iat")
96
+ age = now - (0 if iat is None else iat)
97
+ if age > kb_options["maxAgeSeconds"] + skew or age < -skew:
98
+ raise CredentialError("key binding is stale")
99
+ result["kb"] = {"aud": kb_payload["aud"], "nonce": kb_payload["nonce"], "iat": iat}
100
+ return result