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 ADDED
@@ -0,0 +1,5 @@
1
+ """openvc — generic Verifiable Credentials core."""
2
+
3
+ # Single source of truth for the version (pyproject reads it by AST — keep it a
4
+ # plain string literal). The /release skill bumps this line.
5
+ __version__ = "0.1.0"
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}")