openvc-core 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.
- openvc/__init__.py +5 -0
- openvc/did/__init__.py +1 -0
- openvc/did/base.py +103 -0
- openvc/did/did_key.py +98 -0
- openvc/did/did_web.py +54 -0
- openvc/fetch.py +143 -0
- openvc/keys.py +218 -0
- openvc/multibase.py +65 -0
- openvc/proof/__init__.py +1 -0
- openvc/proof/contexts/__init__.py +54 -0
- openvc/proof/contexts/credentials-v2.json +340 -0
- openvc/proof/data_integrity.py +220 -0
- openvc/proof/vc_jwt.py +287 -0
- openvc/py.typed +0 -0
- openvc/status/__init__.py +33 -0
- openvc/status/bitstring.py +69 -0
- openvc/status/status_list.py +128 -0
- openvc_core-0.1.0.dist-info/METADATA +192 -0
- openvc_core-0.1.0.dist-info/RECORD +30 -0
- openvc_core-0.1.0.dist-info/WHEEL +5 -0
- openvc_core-0.1.0.dist-info/licenses/COPYING +674 -0
- openvc_core-0.1.0.dist-info/licenses/COPYING.LESSER +165 -0
- openvc_core-0.1.0.dist-info/top_level.txt +2 -0
- openvc_ebsi/__init__.py +1 -0
- openvc_ebsi/http.py +219 -0
- openvc_ebsi/models.py +43 -0
- openvc_ebsi/py.typed +0 -0
- openvc_ebsi/trust.py +197 -0
- openvc_ebsi/verify.py +194 -0
- openvc_ebsi/versioning.py +228 -0
openvc/__init__.py
ADDED
openvc/did/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""openvc.did — DID resolution (key, web, + ebsi plugin)."""
|
openvc/did/base.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""
|
|
2
|
+
openvc.did.base — core DID resolution primitives.
|
|
3
|
+
|
|
4
|
+
Home of the generic types every DID method shares: VerificationMethod, DidDocument,
|
|
5
|
+
the DidResolver protocol, a shared W3C DID-document parser, and a registry that
|
|
6
|
+
dispatches a DID to the backend that supports it. No network, no method specifics.
|
|
7
|
+
|
|
8
|
+
(These types used to live in the EBSI plugin; they are generic to did:key,
|
|
9
|
+
did:web and did:ebsi alike, so they belong in the core.)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any, Protocol, runtime_checkable
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# --------------------------------------------------------------------------- #
|
|
19
|
+
# Model
|
|
20
|
+
# --------------------------------------------------------------------------- #
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class VerificationMethod:
|
|
24
|
+
id: str # e.g. did:...#key-1
|
|
25
|
+
type: str
|
|
26
|
+
controller: str
|
|
27
|
+
public_key_jwk: dict[str, Any]
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def kid(self) -> str:
|
|
31
|
+
return self.id.split("#", 1)[-1]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class DidDocument:
|
|
36
|
+
id: str
|
|
37
|
+
verification_methods: list[VerificationMethod]
|
|
38
|
+
raw: dict[str, Any]
|
|
39
|
+
|
|
40
|
+
def key_by_kid(self, kid: str | None) -> VerificationMethod | None:
|
|
41
|
+
"""Match on the full verificationMethod id or its fragment (a JWS `kid`
|
|
42
|
+
may carry either). If kid is None, fall back to the sole key if unique."""
|
|
43
|
+
if kid is None:
|
|
44
|
+
return self.verification_methods[0] if len(self.verification_methods) == 1 else None
|
|
45
|
+
fragment = kid.split("#", 1)[-1]
|
|
46
|
+
return next(
|
|
47
|
+
(vm for vm in self.verification_methods if vm.id == kid or vm.kid == fragment),
|
|
48
|
+
None,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# --------------------------------------------------------------------------- #
|
|
53
|
+
# Errors + protocol
|
|
54
|
+
# --------------------------------------------------------------------------- #
|
|
55
|
+
|
|
56
|
+
class DidError(Exception): ...
|
|
57
|
+
class UnsupportedDidMethod(DidError): ...
|
|
58
|
+
class DidResolutionError(DidError): ...
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@runtime_checkable
|
|
62
|
+
class DidResolver(Protocol):
|
|
63
|
+
def supports(self, did: str) -> bool: ...
|
|
64
|
+
def resolve(self, did: str) -> DidDocument: ...
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# --------------------------------------------------------------------------- #
|
|
68
|
+
# Shared W3C DID-document parser (used by did:web and the EBSI DID Registry)
|
|
69
|
+
# --------------------------------------------------------------------------- #
|
|
70
|
+
|
|
71
|
+
def parse_did_document(raw: dict[str, Any]) -> DidDocument:
|
|
72
|
+
"""Parse a W3C DID document. Tolerates a `didDocument` wrapper or a bare doc
|
|
73
|
+
(the EBSI DID Registry returns it bare, as application/did+ld+json)."""
|
|
74
|
+
doc = raw.get("didDocument", raw)
|
|
75
|
+
vms = [
|
|
76
|
+
VerificationMethod(
|
|
77
|
+
id=vm["id"],
|
|
78
|
+
type=vm.get("type", ""),
|
|
79
|
+
controller=vm.get("controller", doc.get("id", "")),
|
|
80
|
+
public_key_jwk=vm["publicKeyJwk"],
|
|
81
|
+
)
|
|
82
|
+
for vm in doc.get("verificationMethod", [])
|
|
83
|
+
if "publicKeyJwk" in vm
|
|
84
|
+
]
|
|
85
|
+
return DidDocument(id=doc.get("id", ""), verification_methods=vms, raw=doc)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# --------------------------------------------------------------------------- #
|
|
89
|
+
# Registry — dispatch a DID to the first backend that supports it
|
|
90
|
+
# --------------------------------------------------------------------------- #
|
|
91
|
+
|
|
92
|
+
class DidResolverRegistry:
|
|
93
|
+
def __init__(self, resolvers: list[DidResolver] | None = None) -> None:
|
|
94
|
+
self._resolvers: list[DidResolver] = list(resolvers or [])
|
|
95
|
+
|
|
96
|
+
def register(self, resolver: DidResolver) -> None:
|
|
97
|
+
self._resolvers.append(resolver)
|
|
98
|
+
|
|
99
|
+
def resolve(self, did: str) -> DidDocument:
|
|
100
|
+
for r in self._resolvers:
|
|
101
|
+
if r.supports(did):
|
|
102
|
+
return r.resolve(did)
|
|
103
|
+
raise UnsupportedDidMethod(f"no resolver for {did!r}")
|
openvc/did/did_key.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
openvc.did.did_key — offline resolver for the did:key method (Ed25519, P-256).
|
|
3
|
+
|
|
4
|
+
did:key is self-contained: the public key is encoded in the identifier itself, so
|
|
5
|
+
resolution is pure decoding — no network. Format:
|
|
6
|
+
|
|
7
|
+
did:key:z<base58btc( <multicodec-varint> || <raw-public-key> )>
|
|
8
|
+
|
|
9
|
+
Supported multicodecs:
|
|
10
|
+
0xed Ed25519 public key -> OKP / Ed25519 JWK
|
|
11
|
+
0x1200 P-256 public key -> EC / P-256 JWK (from the compressed point)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
|
|
18
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
19
|
+
|
|
20
|
+
from .base import DidDocument, DidResolutionError, VerificationMethod
|
|
21
|
+
|
|
22
|
+
# base58btc (Bitcoin) alphabet
|
|
23
|
+
_B58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
|
24
|
+
_B58_INDEX = {c: i for i, c in enumerate(_B58_ALPHABET)}
|
|
25
|
+
|
|
26
|
+
# multicodec codes (unsigned-varint encoded in the key bytes)
|
|
27
|
+
_MC_ED25519_PUB = 0xED
|
|
28
|
+
_MC_P256_PUB = 0x1200
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _b58decode(s: str) -> bytes:
|
|
32
|
+
num = 0
|
|
33
|
+
for ch in s:
|
|
34
|
+
try:
|
|
35
|
+
num = num * 58 + _B58_INDEX[ch]
|
|
36
|
+
except KeyError:
|
|
37
|
+
raise DidResolutionError(f"invalid base58 character {ch!r}") from None
|
|
38
|
+
body = num.to_bytes((num.bit_length() + 7) // 8, "big") if num else b""
|
|
39
|
+
n_leading = len(s) - len(s.lstrip("1")) # each leading '1' == 0x00
|
|
40
|
+
return b"\x00" * n_leading + body
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _read_varint(data: bytes) -> tuple[int, int]:
|
|
44
|
+
result = shift = 0
|
|
45
|
+
for i, byte in enumerate(data):
|
|
46
|
+
result |= (byte & 0x7F) << shift
|
|
47
|
+
if not byte & 0x80:
|
|
48
|
+
return result, i + 1
|
|
49
|
+
shift += 7
|
|
50
|
+
raise DidResolutionError("truncated multicodec varint")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _b64url(b: bytes) -> str:
|
|
54
|
+
return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class DidKeyResolver:
|
|
58
|
+
def supports(self, did: str) -> bool:
|
|
59
|
+
return did.startswith("did:key:z")
|
|
60
|
+
|
|
61
|
+
def resolve(self, did: str) -> DidDocument:
|
|
62
|
+
multibase = did[len("did:key:"):]
|
|
63
|
+
if not multibase.startswith("z"):
|
|
64
|
+
raise DidResolutionError("did:key must use base58btc ('z') multibase")
|
|
65
|
+
|
|
66
|
+
raw = _b58decode(multibase[1:])
|
|
67
|
+
code, offset = _read_varint(raw)
|
|
68
|
+
key_bytes = raw[offset:]
|
|
69
|
+
|
|
70
|
+
if code == _MC_ED25519_PUB:
|
|
71
|
+
jwk = {"kty": "OKP", "crv": "Ed25519", "x": _b64url(key_bytes)}
|
|
72
|
+
elif code == _MC_P256_PUB:
|
|
73
|
+
pub = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), key_bytes)
|
|
74
|
+
nums = pub.public_numbers()
|
|
75
|
+
jwk = {
|
|
76
|
+
"kty": "EC", "crv": "P-256",
|
|
77
|
+
"x": _b64url(nums.x.to_bytes(32, "big")),
|
|
78
|
+
"y": _b64url(nums.y.to_bytes(32, "big")),
|
|
79
|
+
}
|
|
80
|
+
else:
|
|
81
|
+
raise DidResolutionError(f"unsupported did:key multicodec 0x{code:x}")
|
|
82
|
+
|
|
83
|
+
# For did:key the verification method fragment IS the multibase value.
|
|
84
|
+
vm_id = f"{did}#{multibase}"
|
|
85
|
+
vm = VerificationMethod(
|
|
86
|
+
id=vm_id, type="JsonWebKey2020", controller=did, public_key_jwk=jwk
|
|
87
|
+
)
|
|
88
|
+
raw_doc = {
|
|
89
|
+
"@context": ["https://www.w3.org/ns/did/v1"],
|
|
90
|
+
"id": did,
|
|
91
|
+
"verificationMethod": [
|
|
92
|
+
{"id": vm_id, "type": "JsonWebKey2020", "controller": did,
|
|
93
|
+
"publicKeyJwk": jwk}
|
|
94
|
+
],
|
|
95
|
+
"authentication": [vm_id],
|
|
96
|
+
"assertionMethod": [vm_id],
|
|
97
|
+
}
|
|
98
|
+
return DidDocument(id=did, verification_methods=[vm], raw=raw_doc)
|
openvc/did/did_web.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
openvc.did.did_web — resolver for the did:web method.
|
|
3
|
+
|
|
4
|
+
did:web maps a DID to an https URL and fetches the DID document from the issuer's
|
|
5
|
+
own domain:
|
|
6
|
+
|
|
7
|
+
did:web:example.edu -> https://example.edu/.well-known/did.json
|
|
8
|
+
did:web:example.edu:issuers:physics -> https://example.edu/issuers/physics/did.json
|
|
9
|
+
did:web:example.edu%3A3000 -> https://example.edu:3000/.well-known/did.json
|
|
10
|
+
|
|
11
|
+
SSRF note
|
|
12
|
+
---------
|
|
13
|
+
did:web is *intentionally* cross-host — the whole point is to resolve a controller's
|
|
14
|
+
own domain — so a fixed host allow-list does NOT apply here (unlike the EBSI client).
|
|
15
|
+
Pass a general-purpose `fetch` for this resolver, ideally one that still enforces
|
|
16
|
+
https and blocks private/link-local address ranges. Do NOT reuse the EBSI client,
|
|
17
|
+
whose allow-list would reject every legitimate did:web host.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any, Callable
|
|
23
|
+
from urllib.parse import unquote
|
|
24
|
+
|
|
25
|
+
from .base import DidDocument, DidResolutionError, parse_did_document
|
|
26
|
+
|
|
27
|
+
Fetch = Callable[[str], dict[str, Any]]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DidWebResolver:
|
|
31
|
+
def __init__(self, fetch: Fetch) -> None:
|
|
32
|
+
self._fetch = fetch
|
|
33
|
+
|
|
34
|
+
def supports(self, did: str) -> bool:
|
|
35
|
+
return did.startswith("did:web:")
|
|
36
|
+
|
|
37
|
+
def resolve(self, did: str) -> DidDocument:
|
|
38
|
+
raw = self._fetch(self._did_to_url(did))
|
|
39
|
+
doc = parse_did_document(raw)
|
|
40
|
+
if doc.id and doc.id != did: # basic integrity check
|
|
41
|
+
raise DidResolutionError(f"document id {doc.id!r} != requested {did!r}")
|
|
42
|
+
return doc
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def _did_to_url(did: str) -> str:
|
|
46
|
+
msi = did[len("did:web:"):]
|
|
47
|
+
if not msi:
|
|
48
|
+
raise DidResolutionError("empty did:web identifier")
|
|
49
|
+
parts = msi.split(":") # ':' separates path segments
|
|
50
|
+
host = unquote(parts[0]) # %3A -> ':' for an explicit port
|
|
51
|
+
segments = [unquote(p) for p in parts[1:]]
|
|
52
|
+
if segments:
|
|
53
|
+
return f"https://{host}/" + "/".join(segments) + "/did.json"
|
|
54
|
+
return f"https://{host}/.well-known/did.json"
|
openvc/fetch.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""
|
|
2
|
+
openvc.fetch — an SSRF-guarded, stdlib-only https JSON fetch for did:web.
|
|
3
|
+
|
|
4
|
+
did:web is intentionally cross-host (it resolves a controller's own domain), so
|
|
5
|
+
it cannot use a host allow-list like the EBSI client. This fetch instead blocks
|
|
6
|
+
the *dangerous* targets: it requires https, resolves the host and refuses any
|
|
7
|
+
private / loopback / link-local / reserved / multicast address (the cloud
|
|
8
|
+
metadata endpoint 169.254.169.254 is link-local, so it is covered), and refuses
|
|
9
|
+
HTTP redirects — a common SSRF pivot.
|
|
10
|
+
|
|
11
|
+
DNS-rebinding is closed, not just documented: the connection is **pinned to the
|
|
12
|
+
validated IP** (we resolve, validate every resolved address, then open the TCP
|
|
13
|
+
socket to that exact IP) while TLS SNI, certificate validation, and the Host
|
|
14
|
+
header still use the hostname. So an attacker who flips DNS between the check and
|
|
15
|
+
the connect cannot redirect us to an internal address — we never re-resolve.
|
|
16
|
+
|
|
17
|
+
Dependency-light on purpose: pure stdlib (http.client + ssl + socket +
|
|
18
|
+
ipaddress). Pass ``https_json_fetch`` wherever a ``Fetch`` is expected, or use
|
|
19
|
+
``default_did_web_resolver()``.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import http.client
|
|
24
|
+
import ipaddress
|
|
25
|
+
import json
|
|
26
|
+
import socket
|
|
27
|
+
import ssl
|
|
28
|
+
from typing import Any
|
|
29
|
+
from urllib.parse import urlparse
|
|
30
|
+
|
|
31
|
+
from .did.base import DidResolutionError
|
|
32
|
+
from .did.did_web import DidWebResolver
|
|
33
|
+
|
|
34
|
+
DEFAULT_TIMEOUT_S = 10.0
|
|
35
|
+
MAX_RESPONSE_BYTES = 1_048_576 # 1 MiB — DID documents are small; bound memory.
|
|
36
|
+
_REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class UnsafeUrlError(DidResolutionError):
|
|
40
|
+
"""The URL or a resolved address is not allowed (scheme, host, or IP range)."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _ip_is_forbidden(ip_str: str) -> bool:
|
|
44
|
+
"""True for any non-globally-routable / SSRF-sensitive address."""
|
|
45
|
+
ip = ipaddress.ip_address(ip_str)
|
|
46
|
+
return (
|
|
47
|
+
ip.is_private or ip.is_loopback or ip.is_link_local
|
|
48
|
+
or ip.is_reserved or ip.is_multicast or ip.is_unspecified
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _resolve_public_ips(host: str, port: int) -> list[str]:
|
|
53
|
+
"""Resolve *host* and return its addresses, raising if ANY is SSRF-sensitive
|
|
54
|
+
(fail closed: one bad address rejects the host)."""
|
|
55
|
+
try:
|
|
56
|
+
infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
|
|
57
|
+
except socket.gaierror as exc:
|
|
58
|
+
raise UnsafeUrlError(f"cannot resolve host {host!r}: {exc}") from exc
|
|
59
|
+
ips = [str(info[4][0]) for info in infos]
|
|
60
|
+
if not ips:
|
|
61
|
+
raise UnsafeUrlError(f"host {host!r} did not resolve")
|
|
62
|
+
for addr in ips:
|
|
63
|
+
if _ip_is_forbidden(addr):
|
|
64
|
+
raise UnsafeUrlError(
|
|
65
|
+
f"host {host!r} resolves to blocked address {addr}")
|
|
66
|
+
return ips
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _https_get(
|
|
70
|
+
hostname: str, ip: str, port: int, target: str, *,
|
|
71
|
+
timeout: float, max_bytes: int,
|
|
72
|
+
) -> tuple[int, bytes]:
|
|
73
|
+
"""GET *target* over https, TCP-pinned to *ip* but with TLS SNI, certificate
|
|
74
|
+
validation and Host header for *hostname*. Returns (status, body). Reads at
|
|
75
|
+
most ``max_bytes + 1`` so the caller can detect oversize."""
|
|
76
|
+
context = ssl.create_default_context()
|
|
77
|
+
conn = http.client.HTTPSConnection(hostname, port, timeout=timeout, context=context)
|
|
78
|
+
|
|
79
|
+
def _connect_to_validated_ip(address: Any, timeout: Any = None,
|
|
80
|
+
source_address: Any = None) -> socket.socket:
|
|
81
|
+
# Ignore the (hostname, port) address http.client would use; connect to
|
|
82
|
+
# the IP we validated. wrap_socket still uses hostname for SNI + cert.
|
|
83
|
+
return socket.create_connection((ip, port), timeout=timeout,
|
|
84
|
+
source_address=source_address)
|
|
85
|
+
|
|
86
|
+
setattr(conn, "_create_connection", _connect_to_validated_ip)
|
|
87
|
+
try:
|
|
88
|
+
conn.request("GET", target, headers={
|
|
89
|
+
"Accept": "application/did+ld+json, application/json"})
|
|
90
|
+
resp = conn.getresponse()
|
|
91
|
+
return resp.status, resp.read(max_bytes + 1)
|
|
92
|
+
except (OSError, http.client.HTTPException) as exc:
|
|
93
|
+
raise DidResolutionError(f"fetch failed for https://{hostname}{target}: {exc}") from exc
|
|
94
|
+
finally:
|
|
95
|
+
conn.close()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def https_json_fetch(
|
|
99
|
+
url: str,
|
|
100
|
+
*,
|
|
101
|
+
timeout_s: float = DEFAULT_TIMEOUT_S,
|
|
102
|
+
max_bytes: int = MAX_RESPONSE_BYTES,
|
|
103
|
+
) -> dict[str, Any]:
|
|
104
|
+
"""Fetch *url* as JSON with the SSRF guards above. Returns the parsed object.
|
|
105
|
+
|
|
106
|
+
Raises :class:`UnsafeUrlError` for a disallowed scheme/host/address or a
|
|
107
|
+
redirect (a subclass of :class:`~openvc.did.base.DidResolutionError`), and
|
|
108
|
+
``DidResolutionError`` for transport / oversize / non-JSON failures.
|
|
109
|
+
"""
|
|
110
|
+
parsed = urlparse(url)
|
|
111
|
+
if parsed.scheme != "https":
|
|
112
|
+
raise UnsafeUrlError(f"only https is allowed, got scheme {parsed.scheme!r}")
|
|
113
|
+
if not parsed.hostname:
|
|
114
|
+
raise UnsafeUrlError("URL has no host")
|
|
115
|
+
port = parsed.port or 443
|
|
116
|
+
ip = _resolve_public_ips(parsed.hostname, port)[0]
|
|
117
|
+
|
|
118
|
+
target = parsed.path or "/"
|
|
119
|
+
if parsed.query:
|
|
120
|
+
target += "?" + parsed.query
|
|
121
|
+
|
|
122
|
+
status, raw = _https_get(parsed.hostname, ip, port, target,
|
|
123
|
+
timeout=timeout_s, max_bytes=max_bytes)
|
|
124
|
+
if status in _REDIRECT_STATUSES:
|
|
125
|
+
raise UnsafeUrlError(f"redirect ({status}) not followed (SSRF guard)")
|
|
126
|
+
if status != 200:
|
|
127
|
+
raise DidResolutionError(f"unexpected status {status} for {url!r}")
|
|
128
|
+
if len(raw) > max_bytes:
|
|
129
|
+
raise DidResolutionError(f"response exceeds {max_bytes} bytes")
|
|
130
|
+
try:
|
|
131
|
+
data = json.loads(raw)
|
|
132
|
+
except (ValueError, json.JSONDecodeError) as exc:
|
|
133
|
+
raise DidResolutionError(f"response is not JSON: {exc}") from exc
|
|
134
|
+
if not isinstance(data, dict):
|
|
135
|
+
raise DidResolutionError("DID document must be a JSON object")
|
|
136
|
+
return data
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def default_did_web_resolver() -> DidWebResolver:
|
|
140
|
+
"""A :class:`~openvc.did.did_web.DidWebResolver` wired to the SSRF-guarded
|
|
141
|
+
fetch — the batteries-included way to resolve did:web offline of any HTTP
|
|
142
|
+
client dependency."""
|
|
143
|
+
return DidWebResolver(https_json_fetch)
|
openvc/keys.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""
|
|
2
|
+
openvc.keys — key backends implementing the SigningKey protocol.
|
|
3
|
+
|
|
4
|
+
Two software backends are provided:
|
|
5
|
+
|
|
6
|
+
* Ed25519SigningKey -> alg "EdDSA" (default for general OB 3.0 issuance)
|
|
7
|
+
* P256SigningKey -> alg "ES256" (P-256, required for EBSI/EUDI)
|
|
8
|
+
|
|
9
|
+
Both produce **JWS-compatible** signatures, which is the subtle part:
|
|
10
|
+
|
|
11
|
+
* Ed25519 : cryptography already returns the raw 64-byte signature — no change.
|
|
12
|
+
* ES256 : cryptography returns a DER-encoded ECDSA signature. JOSE requires the
|
|
13
|
+
fixed-length raw form R||S (32 + 32 = 64 bytes). We convert on sign
|
|
14
|
+
and back to DER on verify. Getting this wrong is the classic reason a
|
|
15
|
+
locally-produced ES256 token fails to verify in another stack.
|
|
16
|
+
|
|
17
|
+
HSM / Vault backends: anything implementing SigningKey (alg, kid, sign returning
|
|
18
|
+
R||S for ES256) is a drop-in replacement — e.g. a PKCS#11 or Vault Transit backend
|
|
19
|
+
whose `sign` performs the operation without the private key ever entering the
|
|
20
|
+
process. These software classes are for dev, tests, and low-assurance issuance.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import base64
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from cryptography.exceptions import InvalidSignature
|
|
29
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
30
|
+
from cryptography.hazmat.primitives.asymmetric import ec, ed25519
|
|
31
|
+
from cryptography.hazmat.primitives.asymmetric.utils import (
|
|
32
|
+
decode_dss_signature,
|
|
33
|
+
encode_dss_signature,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
P256_COORD_BYTES = 32 # a P-256 coordinate / scalar is 32 bytes
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class KeyBackendError(Exception): ...
|
|
40
|
+
class InvalidKey(KeyBackendError): ...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# --------------------------------------------------------------------------- #
|
|
44
|
+
# base64url + integer helpers
|
|
45
|
+
# --------------------------------------------------------------------------- #
|
|
46
|
+
|
|
47
|
+
def _b64url_encode(data: bytes) -> str:
|
|
48
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _b64url_decode(segment: str) -> bytes:
|
|
52
|
+
return base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _int_to_fixed(n: int, length: int) -> bytes:
|
|
56
|
+
"""Big-endian fixed-length encoding (preserves leading zeros — the whole
|
|
57
|
+
point of JOSE's fixed-width R and S)."""
|
|
58
|
+
return n.to_bytes(length, "big")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# --------------------------------------------------------------------------- #
|
|
62
|
+
# Ed25519 (EdDSA)
|
|
63
|
+
# --------------------------------------------------------------------------- #
|
|
64
|
+
|
|
65
|
+
class Ed25519SigningKey:
|
|
66
|
+
alg = "EdDSA"
|
|
67
|
+
|
|
68
|
+
def __init__(self, private_key: ed25519.Ed25519PrivateKey, kid: str) -> None:
|
|
69
|
+
self._sk = private_key
|
|
70
|
+
self._kid = kid
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def kid(self) -> str:
|
|
74
|
+
return self._kid
|
|
75
|
+
|
|
76
|
+
def sign(self, signing_input: bytes) -> bytes:
|
|
77
|
+
# cryptography returns the raw 64-byte signature; JOSE-ready as-is.
|
|
78
|
+
return self._sk.sign(signing_input)
|
|
79
|
+
|
|
80
|
+
def public_jwk(self) -> dict[str, Any]:
|
|
81
|
+
raw = self._sk.public_key().public_bytes(
|
|
82
|
+
serialization.Encoding.Raw, serialization.PublicFormat.Raw
|
|
83
|
+
)
|
|
84
|
+
return {"kty": "OKP", "crv": "Ed25519", "x": _b64url_encode(raw)}
|
|
85
|
+
|
|
86
|
+
def public_key_raw(self) -> bytes:
|
|
87
|
+
"""Raw 32-byte public key (used by the did:key encoder, multicodec 0xed01)."""
|
|
88
|
+
return self._sk.public_key().public_bytes(
|
|
89
|
+
serialization.Encoding.Raw, serialization.PublicFormat.Raw
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# -- constructors ------------------------------------------------------ #
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def generate(cls, kid: str) -> "Ed25519SigningKey":
|
|
96
|
+
return cls(ed25519.Ed25519PrivateKey.generate(), kid)
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def from_jwk(cls, jwk: dict[str, Any], kid: str) -> "Ed25519SigningKey":
|
|
100
|
+
if jwk.get("kty") != "OKP" or jwk.get("crv") != "Ed25519" or "d" not in jwk:
|
|
101
|
+
raise InvalidKey("not an Ed25519 private JWK")
|
|
102
|
+
sk = ed25519.Ed25519PrivateKey.from_private_bytes(_b64url_decode(jwk["d"]))
|
|
103
|
+
return cls(sk, kid)
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def from_pem(cls, pem: bytes, kid: str, password: bytes | None = None) -> "Ed25519SigningKey":
|
|
107
|
+
sk = serialization.load_pem_private_key(pem, password=password)
|
|
108
|
+
if not isinstance(sk, ed25519.Ed25519PrivateKey):
|
|
109
|
+
raise InvalidKey("PEM is not an Ed25519 private key")
|
|
110
|
+
return cls(sk, kid)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# --------------------------------------------------------------------------- #
|
|
114
|
+
# P-256 (ES256)
|
|
115
|
+
# --------------------------------------------------------------------------- #
|
|
116
|
+
|
|
117
|
+
class P256SigningKey:
|
|
118
|
+
alg = "ES256"
|
|
119
|
+
|
|
120
|
+
def __init__(self, private_key: ec.EllipticCurvePrivateKey, kid: str) -> None:
|
|
121
|
+
if not isinstance(private_key.curve, ec.SECP256R1):
|
|
122
|
+
raise InvalidKey("ES256 requires a P-256 (secp256r1) key")
|
|
123
|
+
self._sk = private_key
|
|
124
|
+
self._kid = kid
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def kid(self) -> str:
|
|
128
|
+
return self._kid
|
|
129
|
+
|
|
130
|
+
def sign(self, signing_input: bytes) -> bytes:
|
|
131
|
+
der = self._sk.sign(signing_input, ec.ECDSA(hashes.SHA256()))
|
|
132
|
+
r, s = decode_dss_signature(der) # DER -> (int, int)
|
|
133
|
+
return _int_to_fixed(r, P256_COORD_BYTES) + _int_to_fixed(s, P256_COORD_BYTES)
|
|
134
|
+
|
|
135
|
+
def public_jwk(self) -> dict[str, Any]:
|
|
136
|
+
nums = self._sk.public_key().public_numbers()
|
|
137
|
+
return {
|
|
138
|
+
"kty": "EC",
|
|
139
|
+
"crv": "P-256",
|
|
140
|
+
"x": _b64url_encode(_int_to_fixed(nums.x, P256_COORD_BYTES)),
|
|
141
|
+
"y": _b64url_encode(_int_to_fixed(nums.y, P256_COORD_BYTES)),
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
def public_key_raw(self, *, compressed: bool = True) -> bytes:
|
|
145
|
+
"""SEC1 point (compressed by default) — used by the did:key encoder
|
|
146
|
+
(multicodec 0x1200 for P-256)."""
|
|
147
|
+
fmt = (serialization.PublicFormat.CompressedPoint if compressed
|
|
148
|
+
else serialization.PublicFormat.UncompressedPoint)
|
|
149
|
+
return self._sk.public_key().public_bytes(serialization.Encoding.X962, fmt)
|
|
150
|
+
|
|
151
|
+
# -- constructors ------------------------------------------------------ #
|
|
152
|
+
|
|
153
|
+
@classmethod
|
|
154
|
+
def generate(cls, kid: str) -> "P256SigningKey":
|
|
155
|
+
return cls(ec.generate_private_key(ec.SECP256R1()), kid)
|
|
156
|
+
|
|
157
|
+
@classmethod
|
|
158
|
+
def from_jwk(cls, jwk: dict[str, Any], kid: str) -> "P256SigningKey":
|
|
159
|
+
if jwk.get("kty") != "EC" or jwk.get("crv") != "P-256" or "d" not in jwk:
|
|
160
|
+
raise InvalidKey("not a P-256 private JWK")
|
|
161
|
+
d = int.from_bytes(_b64url_decode(jwk["d"]), "big")
|
|
162
|
+
return cls(ec.derive_private_key(d, ec.SECP256R1()), kid)
|
|
163
|
+
|
|
164
|
+
@classmethod
|
|
165
|
+
def from_pem(cls, pem: bytes, kid: str, password: bytes | None = None) -> "P256SigningKey":
|
|
166
|
+
sk = serialization.load_pem_private_key(pem, password=password)
|
|
167
|
+
if not isinstance(sk, ec.EllipticCurvePrivateKey):
|
|
168
|
+
raise InvalidKey("PEM is not an EC private key")
|
|
169
|
+
return cls(sk, kid)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# --------------------------------------------------------------------------- #
|
|
173
|
+
# Dependency-light verification (for did:key self-contained verify + tests)
|
|
174
|
+
# --------------------------------------------------------------------------- #
|
|
175
|
+
|
|
176
|
+
def verify_signature(
|
|
177
|
+
*, alg: str, public_jwk: dict[str, Any], signing_input: bytes, signature: bytes
|
|
178
|
+
) -> bool:
|
|
179
|
+
"""Verify a JOSE signature directly from a public JWK, without PyJWT.
|
|
180
|
+
|
|
181
|
+
Handy for did:key (where the key is inside the DID) and for round-trip tests.
|
|
182
|
+
The VC verification path uses the proof suite instead; this is complementary.
|
|
183
|
+
"""
|
|
184
|
+
try:
|
|
185
|
+
if alg == "EdDSA":
|
|
186
|
+
raw = _b64url_decode(public_jwk["x"])
|
|
187
|
+
ed25519.Ed25519PublicKey.from_public_bytes(raw).verify(signature, signing_input)
|
|
188
|
+
return True
|
|
189
|
+
if alg == "ES256":
|
|
190
|
+
if len(signature) != 2 * P256_COORD_BYTES:
|
|
191
|
+
raise InvalidKey("ES256 signature must be 64-byte R||S")
|
|
192
|
+
r = int.from_bytes(signature[:P256_COORD_BYTES], "big")
|
|
193
|
+
s = int.from_bytes(signature[P256_COORD_BYTES:], "big")
|
|
194
|
+
der = encode_dss_signature(r, s) # R||S -> DER for verify
|
|
195
|
+
pub = ec.EllipticCurvePublicNumbers(
|
|
196
|
+
int.from_bytes(_b64url_decode(public_jwk["x"]), "big"),
|
|
197
|
+
int.from_bytes(_b64url_decode(public_jwk["y"]), "big"),
|
|
198
|
+
ec.SECP256R1(),
|
|
199
|
+
).public_key()
|
|
200
|
+
pub.verify(der, signing_input, ec.ECDSA(hashes.SHA256()))
|
|
201
|
+
return True
|
|
202
|
+
raise InvalidKey(f"unsupported alg {alg!r}")
|
|
203
|
+
except InvalidSignature:
|
|
204
|
+
return False
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# --------------------------------------------------------------------------- #
|
|
208
|
+
# Convenience factory
|
|
209
|
+
# --------------------------------------------------------------------------- #
|
|
210
|
+
|
|
211
|
+
def signing_key_from_jwk(jwk: dict[str, Any], kid: str):
|
|
212
|
+
"""Dispatch to the right backend from a private JWK."""
|
|
213
|
+
kty, crv = jwk.get("kty"), jwk.get("crv")
|
|
214
|
+
if kty == "OKP" and crv == "Ed25519":
|
|
215
|
+
return Ed25519SigningKey.from_jwk(jwk, kid)
|
|
216
|
+
if kty == "EC" and crv == "P-256":
|
|
217
|
+
return P256SigningKey.from_jwk(jwk, kid)
|
|
218
|
+
raise InvalidKey(f"unsupported key type kty={kty!r} crv={crv!r}")
|