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 +45 -0
- ffn_verify/_egress.py +167 -0
- ffn_verify/_js.py +135 -0
- ffn_verify/_version.py +1 -0
- ffn_verify/binding.py +68 -0
- ffn_verify/business.py +60 -0
- ffn_verify/credential.py +100 -0
- ffn_verify/delegation.py +152 -0
- ffn_verify/did.py +297 -0
- ffn_verify/dns.py +73 -0
- ffn_verify/domain.py +70 -0
- ffn_verify/fetch.py +296 -0
- ffn_verify/jose.py +153 -0
- ffn_verify/networks.py +48 -0
- ffn_verify/pin.py +94 -0
- ffn_verify/presentation.py +115 -0
- ffn_verify/py.typed +0 -0
- ffn_verify/sdjwt.py +272 -0
- ffn_verify/status.py +173 -0
- ffn_verify/url.py +337 -0
- ffn_verify/verify.py +631 -0
- ffn_verify-0.1.0.dist-info/METADATA +138 -0
- ffn_verify-0.1.0.dist-info/RECORD +27 -0
- ffn_verify-0.1.0.dist-info/WHEEL +5 -0
- ffn_verify-0.1.0.dist-info/licenses/LICENSE +202 -0
- ffn_verify-0.1.0.dist-info/licenses/NOTICE +5 -0
- ffn_verify-0.1.0.dist-info/top_level.txt +1 -0
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
|
ffn_verify/credential.py
ADDED
|
@@ -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
|