palonexus 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.
agentdid/__init__.py ADDED
@@ -0,0 +1,73 @@
1
+ """agentdid — AgentDID + Verifiable Credentials cryptographic foundation.
2
+
3
+ CONTRACTS §12. Imported by the IDP, the agents, and runbooks-api.
4
+
5
+ from agentdid import (
6
+ generate_keypair, did_for, did_key_for, build_did_document, Resolver,
7
+ issue_vc, verify_vc, build_vp, verify_vp, verify_delegation_chain,
8
+ make_challenge, respond_challenge, verify_challenge, state_commitment,
9
+ enforce_capability, is_revoked,
10
+ )
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from .capability import enforce_capability, resource_matches, state_commitment
15
+ from .challenge import make_challenge, respond_challenge, verify_challenge
16
+ from .delegation import verify_delegation_chain
17
+ from .did import DEFAULT_HOST, ROOT_DID, build_did_document, did_for, did_key_for, kid_for
18
+ from .keys import (
19
+ b58decode,
20
+ b58encode,
21
+ generate_keypair,
22
+ multibase_to_pub,
23
+ multibase_to_raw,
24
+ priv_from_b64,
25
+ pub_to_multibase,
26
+ raw_to_multibase,
27
+ )
28
+ from .resolver import Resolver, did_web_to_url
29
+ from .revocation import is_revoked
30
+ from .vc import issue_vc, verify_vc
31
+ from .vp import build_vp, verify_vp
32
+
33
+ __all__ = [
34
+ # keys
35
+ "generate_keypair",
36
+ "pub_to_multibase",
37
+ "multibase_to_pub",
38
+ "multibase_to_raw",
39
+ "raw_to_multibase",
40
+ "priv_from_b64",
41
+ "b58encode",
42
+ "b58decode",
43
+ # did
44
+ "did_for",
45
+ "did_key_for",
46
+ "kid_for",
47
+ "build_did_document",
48
+ "ROOT_DID",
49
+ "DEFAULT_HOST",
50
+ # resolver
51
+ "Resolver",
52
+ "did_web_to_url",
53
+ # vc
54
+ "issue_vc",
55
+ "verify_vc",
56
+ # vp
57
+ "build_vp",
58
+ "verify_vp",
59
+ # delegation
60
+ "verify_delegation_chain",
61
+ # challenge / state
62
+ "make_challenge",
63
+ "respond_challenge",
64
+ "verify_challenge",
65
+ "state_commitment",
66
+ # capability
67
+ "enforce_capability",
68
+ "resource_matches",
69
+ # revocation
70
+ "is_revoked",
71
+ ]
72
+
73
+ __version__ = "0.1.0"
agentdid/capability.py ADDED
@@ -0,0 +1,95 @@
1
+ """Capability enforcement + state commitment (CONTRACTS §12.2, §12.3, §6 plan).
2
+
3
+ Capability shape (§12.3)::
4
+
5
+ { "action": "runbook:read",
6
+ "resource": "runbooks-api:/runbooks/*",
7
+ "constraints": { "notBefore", "notAfter", "maxCalls": 5,
8
+ "execContext": { "ticketSource": "incy" } } }
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ import time
15
+ from datetime import datetime, timezone
16
+
17
+
18
+ def state_commitment(state: dict) -> str:
19
+ """SHA-256 hex over canonical JSON (sorted keys, no whitespace)."""
20
+ canonical = json.dumps(state, sort_keys=True, separators=(",", ":"))
21
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
22
+
23
+
24
+ # --- time helpers ------------------------------------------------------------
25
+ def _to_epoch(value) -> float | None:
26
+ if value is None:
27
+ return None
28
+ if isinstance(value, (int, float)):
29
+ return float(value)
30
+ s = str(value)
31
+ # ISO 8601, accept trailing Z
32
+ if s.endswith("Z"):
33
+ s = s[:-1] + "+00:00"
34
+ try:
35
+ dt = datetime.fromisoformat(s)
36
+ if dt.tzinfo is None:
37
+ dt = dt.replace(tzinfo=timezone.utc)
38
+ return dt.timestamp()
39
+ except ValueError:
40
+ return None
41
+
42
+
43
+ # --- glob resource match -----------------------------------------------------
44
+ def resource_matches(pattern: str, resource: str) -> bool:
45
+ """Match ``resource`` against ``pattern``. Supports a trailing ``/*`` wildcard
46
+ meaning "this prefix and anything below it". Exact match otherwise.
47
+ """
48
+ if pattern == resource:
49
+ return True
50
+ if pattern.endswith("/*"):
51
+ prefix = pattern[:-2] # drop '/*'
52
+ # match the prefix itself or prefix + '/...'
53
+ return resource == prefix or resource.startswith(prefix + "/")
54
+ if pattern.endswith("*"): # generic trailing wildcard
55
+ return resource.startswith(pattern[:-1])
56
+ return False
57
+
58
+
59
+ def _is_subset(sub: dict, sup: dict) -> bool:
60
+ """Every key/value in ``sub`` must be present and equal in ``sup``."""
61
+ for k, v in sub.items():
62
+ if k not in sup or sup[k] != v:
63
+ return False
64
+ return True
65
+
66
+
67
+ def enforce_capability(capability: dict, *, action: str, resource: str, context: dict) -> bool:
68
+ """Enforce a capability against a concrete request.
69
+
70
+ Checks:
71
+ - action equals the capability action
72
+ - resource glob-matches the capability resource (trailing ``/*`` supported)
73
+ - current time within ``constraints.notBefore`` / ``notAfter`` (if set)
74
+ - ``constraints.execContext`` is a subset of ``context``
75
+
76
+ NOTE: ``maxCalls`` is a stateful rate cap and is NOT enforced here — the
77
+ caller (verifier) must track call counts and enforce it. This function is
78
+ pure and stateless.
79
+ """
80
+ if capability.get("action") != action:
81
+ return False
82
+ if not resource_matches(capability.get("resource", ""), resource):
83
+ return False
84
+ constraints = capability.get("constraints") or {}
85
+ now = time.time()
86
+ nb = _to_epoch(constraints.get("notBefore"))
87
+ na = _to_epoch(constraints.get("notAfter"))
88
+ if nb is not None and now < nb:
89
+ return False
90
+ if na is not None and now > na:
91
+ return False
92
+ exec_ctx = constraints.get("execContext") or {}
93
+ if exec_ctx and not _is_subset(exec_ctx, context or {}):
94
+ return False
95
+ return True
agentdid/challenge.py ADDED
@@ -0,0 +1,93 @@
1
+ """Challenge-response for live execution-state verification (CONTRACTS §12.2; plan §5).
2
+
3
+ Flow:
4
+ verifier -> make_challenge(required_state) -> { nonce, required_state }
5
+ holder -> respond_challenge(...) -> { state, state_commitment, sig, kid }
6
+ verifier -> verify_challenge(...) -> bool
7
+
8
+ The signature is EdDSA over the ASCII string ``f"{nonce}.{state_commitment}"``.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import os
14
+
15
+ from cryptography.exceptions import InvalidSignature
16
+
17
+ from .capability import _is_subset, state_commitment
18
+ from .did import kid_for
19
+ from .keys import priv_from_b64
20
+
21
+
22
+ def make_challenge(required_state: list) -> dict:
23
+ """Create a challenge with a fresh nonce and the required state field names."""
24
+ nonce = os.urandom(16).hex()
25
+ return {"nonce": nonce, "required_state": list(required_state)}
26
+
27
+
28
+ def _signing_input(nonce: str, commitment: str) -> bytes:
29
+ return f"{nonce}.{commitment}".encode("utf-8")
30
+
31
+
32
+ def respond_challenge(holder_priv_b64: str, holder_did: str, nonce: str, state: dict) -> dict:
33
+ """Sign a challenge response proving the holder's current execution state."""
34
+ commitment = state_commitment(state)
35
+ priv = priv_from_b64(holder_priv_b64)
36
+ sig = priv.sign(_signing_input(nonce, commitment))
37
+ return {
38
+ "state": state,
39
+ "state_commitment": commitment,
40
+ "sig": base64.b64encode(sig).decode("ascii"),
41
+ "kid": kid_for(holder_did),
42
+ }
43
+
44
+
45
+ def verify_challenge(resp: dict, holder_did: str, resolver, *, nonce: str, constraints: dict) -> bool:
46
+ """Verify a challenge response.
47
+
48
+ Checks:
49
+ - signature over ``nonce.state_commitment`` via the holder's DID key,
50
+ - the recomputed commitment of ``resp["state"]`` matches ``state_commitment``,
51
+ - the state satisfies ``constraints``:
52
+ * ``constraints["execContext"]`` is a subset of the state (if present),
53
+ * every name in ``constraints["required_state"]`` (or
54
+ ``constraints["required"]``) is present and non-empty in the state.
55
+ Returns True only if everything holds.
56
+ """
57
+ try:
58
+ state = resp["state"]
59
+ claimed_commitment = resp["state_commitment"]
60
+ sig = base64.b64decode(resp["sig"])
61
+ kid = resp.get("kid") or kid_for(holder_did)
62
+ except (KeyError, TypeError, ValueError):
63
+ return False
64
+
65
+ # kid must belong to the asserted holder.
66
+ if kid.split("#")[0] != holder_did:
67
+ return False
68
+
69
+ # Recompute and compare the commitment (defeats tampered state).
70
+ if state_commitment(state) != claimed_commitment:
71
+ return False
72
+
73
+ # Verify the signature against the holder's DID key.
74
+ try:
75
+ pub = resolver.public_key(kid)
76
+ pub.verify(sig, _signing_input(nonce, claimed_commitment))
77
+ except (InvalidSignature, Exception):
78
+ return False
79
+
80
+ # execContext subset check.
81
+ exec_ctx = (constraints or {}).get("execContext") or {}
82
+ if exec_ctx and not _is_subset(exec_ctx, state):
83
+ return False
84
+
85
+ # required fields present and non-empty.
86
+ required = (constraints or {}).get("required_state") or (constraints or {}).get("required") or []
87
+ for field in required:
88
+ if field not in state:
89
+ return False
90
+ v = state[field]
91
+ if v is None or v == "" or v == [] or v == {}:
92
+ return False
93
+ return True
agentdid/delegation.py ADDED
@@ -0,0 +1,123 @@
1
+ """Delegation-chain verification (CONTRACTS §12.2, §12.6; plan §6).
2
+
3
+ A leaf delegation VC embeds its parent VC JWT under ``vc.parent``. We walk the
4
+ chain leaf -> ... -> root, verifying:
5
+
6
+ - each link's signature via its issuer DID key (resolved from the registry),
7
+ - the chain terminates at a ``CapabilityCredential`` whose ``iss == root_did``,
8
+ - each child capability is a subset (⊆) of its parent capability:
9
+ * same action,
10
+ * child resource matches / is narrower than the parent resource,
11
+ * child time window within the parent window,
12
+ * child execContext ⊇ parent execContext (more constraints = narrower),
13
+ - the leaf capability authorizes the requested ``action`` / ``resource``.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from .capability import _to_epoch, resource_matches
18
+ from .vc import verify_vc
19
+
20
+
21
+ def _window(cap: dict) -> tuple[float | None, float | None]:
22
+ c = cap.get("constraints") or {}
23
+ return _to_epoch(c.get("notBefore")), _to_epoch(c.get("notAfter"))
24
+
25
+
26
+ def _exec_ctx(cap: dict) -> dict:
27
+ return (cap.get("constraints") or {}).get("execContext") or {}
28
+
29
+
30
+ def _capability_subset(child: dict, parent: dict) -> bool:
31
+ """True if ``child`` capability is ⊆ ``parent`` capability."""
32
+ if child.get("action") != parent.get("action"):
33
+ return False
34
+ # child resource must be matched/covered by the parent resource pattern.
35
+ if not resource_matches(parent.get("resource", ""), child.get("resource", "")):
36
+ return False
37
+ # time window: child must be within parent's window.
38
+ c_nb, c_na = _window(child)
39
+ p_nb, p_na = _window(parent)
40
+ if p_nb is not None:
41
+ if c_nb is None or c_nb < p_nb:
42
+ return False
43
+ if p_na is not None:
44
+ if c_na is None or c_na > p_na:
45
+ return False
46
+ # execContext: child must carry at least every constraint the parent has
47
+ # (child execContext ⊇ parent execContext), with matching values.
48
+ p_ctx = _exec_ctx(parent)
49
+ c_ctx = _exec_ctx(child)
50
+ for k, v in p_ctx.items():
51
+ if k not in c_ctx or c_ctx[k] != v:
52
+ return False
53
+ return True
54
+
55
+
56
+ def _cap_of(claims: dict) -> dict | None:
57
+ return (claims.get("vc", {}).get("credentialSubject", {}) or {}).get("capability")
58
+
59
+
60
+ def _types_of(claims: dict) -> list:
61
+ return claims.get("vc", {}).get("type", []) or []
62
+
63
+
64
+ def verify_delegation_chain(
65
+ leaf_vc_jwt: str,
66
+ resolver,
67
+ *,
68
+ root_did: str,
69
+ action: str,
70
+ resource: str,
71
+ status_url: str | None = None,
72
+ max_depth: int = 16,
73
+ ) -> bool:
74
+ """Return True iff the delegation chain is fully valid (see module docstring)."""
75
+ try:
76
+ leaf_claims = verify_vc(leaf_vc_jwt, resolver, status_url=status_url)
77
+ except Exception:
78
+ return False
79
+
80
+ leaf_cap = _cap_of(leaf_claims)
81
+ if not leaf_cap:
82
+ return False
83
+ # The leaf must actually authorize the requested action/resource.
84
+ if leaf_cap.get("action") != action:
85
+ return False
86
+ if not resource_matches(leaf_cap.get("resource", ""), resource):
87
+ return False
88
+
89
+ current_jwt = leaf_vc_jwt
90
+ current_claims = leaf_claims
91
+ depth = 0
92
+ while True:
93
+ depth += 1
94
+ if depth > max_depth:
95
+ return False
96
+ parent_jwt = current_claims.get("vc", {}).get("parent")
97
+ if parent_jwt is None:
98
+ # No parent: this must itself be the root CapabilityCredential
99
+ # issued by root_did.
100
+ if current_claims.get("iss") != root_did:
101
+ return False
102
+ if "CapabilityCredential" not in _types_of(current_claims):
103
+ return False
104
+ return True
105
+ if not isinstance(parent_jwt, str):
106
+ # Phase 1 requires the embedded parent JWT (not a bare jti ref).
107
+ return False
108
+ try:
109
+ parent_claims = verify_vc(parent_jwt, resolver, status_url=status_url)
110
+ except Exception:
111
+ return False
112
+ child_cap = _cap_of(current_claims)
113
+ parent_cap = _cap_of(parent_claims)
114
+ if not child_cap or not parent_cap:
115
+ return False
116
+ if not _capability_subset(child_cap, parent_cap):
117
+ return False
118
+ # The child's issuer must be the subject the parent was delegated to,
119
+ # i.e. only the holder of the parent VC may sub-delegate.
120
+ if current_claims.get("iss") != parent_claims.get("sub"):
121
+ return False
122
+ current_jwt = parent_jwt
123
+ current_claims = parent_claims
agentdid/did.py ADDED
@@ -0,0 +1,93 @@
1
+ """DID derivation + DID Document construction (CONTRACTS §12.1).
2
+
3
+ Two methods are supported:
4
+
5
+ - ``did:web`` — the **issuer/root** DID (network-resolved, rotatable). Built with
6
+ :func:`did_for` / :func:`build_did_document`.
7
+ - ``did:key`` — **agent** DIDs (self-certifying, resolved locally; the DID *is* the
8
+ Ed25519 public key). Built with :func:`did_key_for`. The verificationMethod id is
9
+ ``<did>#<multibase>`` (did:key convention — the fragment repeats the multibase).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ DEFAULT_HOST = "agent-idp.agent-idp.svc"
14
+
15
+ # The platform/issuer root DID (controller of all agent DIDs).
16
+ ROOT_DID = "did:web:" + DEFAULT_HOST
17
+
18
+
19
+ def did_for(name: str, host: str = DEFAULT_HOST) -> str:
20
+ """Return the agent DID ``did:web:<host>:agents:<name>``."""
21
+ return f"did:web:{host}:agents:{name}"
22
+
23
+
24
+ def did_key_for(pub_multibase: str) -> str:
25
+ """Return the ``did:key`` for an Ed25519 public key (CONTRACTS §12.1).
26
+
27
+ Our ``pub_multibase`` is already the multibase (0xed01-prefixed) Ed25519
28
+ public key, which is exactly the ``did:key`` identifier, so the DID is simply
29
+ ``"did:key:" + pub_multibase``.
30
+ """
31
+ if not pub_multibase or pub_multibase[0] != "z":
32
+ raise ValueError("pub_multibase must be a base58btc multibase ('z' prefix)")
33
+ return "did:key:" + pub_multibase
34
+
35
+
36
+ def kid_for(did: str) -> str:
37
+ """Return the default verificationMethod id (kid) for a DID.
38
+
39
+ - ``did:key:z…`` → ``did:key:z…#z…`` (the fragment repeats the multibase,
40
+ per the did:key convention).
41
+ - everything else (``did:web``) → ``<did>#key-1``.
42
+
43
+ Used by ``issue_vc``/``build_vp``/``respond_challenge`` so the kid in a JWT
44
+ header (or challenge response) always resolves via ``Resolver.public_key``.
45
+ """
46
+ if did.startswith("did:key:"):
47
+ multibase = did[len("did:key:"):]
48
+ return f"{did}#{multibase}"
49
+ return f"{did}#key-1"
50
+
51
+
52
+ def build_did_document(
53
+ did: str,
54
+ pub_multibase: str,
55
+ service_endpoint: str,
56
+ capabilities: list,
57
+ ) -> dict:
58
+ """Build a W3C DID Document per CONTRACTS §12.1.
59
+
60
+ - verificationMethod id = ``<did>#key-1`` (Ed25519VerificationKey2020)
61
+ - authentication + assertionMethod reference that key
62
+ - one ``AgentService`` service entry
63
+ - ``metadata.capabilities`` carries the declared capability actions
64
+ """
65
+ key_id = f"{did}#key-1"
66
+ # controller = the issuer/root, unless this DID *is* the root.
67
+ controller = ROOT_DID if did != ROOT_DID else did
68
+ return {
69
+ "@context": [
70
+ "https://www.w3.org/ns/did/v1",
71
+ "https://w3id.org/security/suites/ed25519-2020/v1",
72
+ ],
73
+ "id": did,
74
+ "controller": controller,
75
+ "verificationMethod": [
76
+ {
77
+ "id": key_id,
78
+ "type": "Ed25519VerificationKey2020",
79
+ "controller": did,
80
+ "publicKeyMultibase": pub_multibase,
81
+ }
82
+ ],
83
+ "authentication": [key_id],
84
+ "assertionMethod": [key_id],
85
+ "service": [
86
+ {
87
+ "id": f"{did}#agent",
88
+ "type": "AgentService",
89
+ "serviceEndpoint": service_endpoint,
90
+ }
91
+ ],
92
+ "metadata": {"capabilities": list(capabilities)},
93
+ }
agentdid/keys.py ADDED
@@ -0,0 +1,127 @@
1
+ """Ed25519 keypairs + multibase/multicodec encoding (CONTRACTS §12.2).
2
+
3
+ Public keys are published in DID Documents as ``publicKeyMultibase`` using the
4
+ W3C ``Ed25519VerificationKey2020`` convention:
5
+
6
+ multibase(base58btc, 0xed01 || raw_ed25519_public_key_bytes)
7
+
8
+ i.e. the 32-byte raw public key is prefixed with the ``ed25519-pub`` multicodec
9
+ varint (0xed 0x01) and the whole thing is base58btc-encoded with a leading ``z``.
10
+ Private keys are returned as plain base64 of the 32-byte raw seed.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+
16
+ from cryptography.hazmat.primitives import serialization
17
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
18
+ Ed25519PrivateKey,
19
+ Ed25519PublicKey,
20
+ )
21
+
22
+ # multicodec prefix for an ed25519 public key: 0xed 0x01 (unsigned varint).
23
+ _ED25519_PUB_MULTICODEC = b"\xed\x01"
24
+
25
+ # --- base58btc (Bitcoin alphabet) -------------------------------------------
26
+ _B58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
27
+ _B58_INDEX = {c: i for i, c in enumerate(_B58_ALPHABET)}
28
+
29
+
30
+ def b58encode(data: bytes) -> str:
31
+ n = int.from_bytes(data, "big")
32
+ out = ""
33
+ while n > 0:
34
+ n, rem = divmod(n, 58)
35
+ out = _B58_ALPHABET[rem] + out
36
+ # preserve leading zero bytes as leading '1's
37
+ pad = 0
38
+ for b in data:
39
+ if b == 0:
40
+ pad += 1
41
+ else:
42
+ break
43
+ return ("1" * pad) + out
44
+
45
+
46
+ def b58decode(s: str) -> bytes:
47
+ n = 0
48
+ for ch in s:
49
+ if ch not in _B58_INDEX:
50
+ raise ValueError(f"invalid base58 character: {ch!r}")
51
+ n = n * 58 + _B58_INDEX[ch]
52
+ full = n.to_bytes((n.bit_length() + 7) // 8, "big") if n else b""
53
+ pad = 0
54
+ for ch in s:
55
+ if ch == "1":
56
+ pad += 1
57
+ else:
58
+ break
59
+ return b"\x00" * pad + full
60
+
61
+
62
+ # --- keypair generation ------------------------------------------------------
63
+ def generate_keypair() -> tuple[str, str]:
64
+ """Generate an Ed25519 keypair.
65
+
66
+ Returns ``(priv_b64, pub_multibase)`` where ``priv_b64`` is base64 of the
67
+ 32-byte raw private seed and ``pub_multibase`` is the ``z…`` multibase form.
68
+ """
69
+ priv = Ed25519PrivateKey.generate()
70
+ raw_priv = priv.private_bytes(
71
+ encoding=serialization.Encoding.Raw,
72
+ format=serialization.PrivateFormat.Raw,
73
+ encryption_algorithm=serialization.NoEncryption(),
74
+ )
75
+ pub_multibase = pub_to_multibase(priv.public_key())
76
+ return base64.b64encode(raw_priv).decode("ascii"), pub_multibase
77
+
78
+
79
+ # --- private key <-> b64 -----------------------------------------------------
80
+ def priv_from_b64(priv_b64: str) -> Ed25519PrivateKey:
81
+ raw = base64.b64decode(priv_b64)
82
+ return Ed25519PrivateKey.from_private_bytes(raw)
83
+
84
+
85
+ def priv_to_b64(priv: Ed25519PrivateKey) -> str:
86
+ raw = priv.private_bytes(
87
+ encoding=serialization.Encoding.Raw,
88
+ format=serialization.PrivateFormat.Raw,
89
+ encryption_algorithm=serialization.NoEncryption(),
90
+ )
91
+ return base64.b64encode(raw).decode("ascii")
92
+
93
+
94
+ # --- public key <-> multibase ------------------------------------------------
95
+ def pub_to_multibase(pub: Ed25519PublicKey) -> str:
96
+ raw = pub.public_bytes(
97
+ encoding=serialization.Encoding.Raw,
98
+ format=serialization.PublicFormat.Raw,
99
+ )
100
+ return "z" + b58encode(_ED25519_PUB_MULTICODEC + raw)
101
+
102
+
103
+ def multibase_to_pub(pub_multibase: str) -> Ed25519PublicKey:
104
+ if not pub_multibase or pub_multibase[0] != "z":
105
+ raise ValueError("publicKeyMultibase must be base58btc ('z' prefix)")
106
+ decoded = b58decode(pub_multibase[1:])
107
+ if not decoded.startswith(_ED25519_PUB_MULTICODEC):
108
+ raise ValueError("not an ed25519-pub multicodec key (expected 0xed01 prefix)")
109
+ raw = decoded[len(_ED25519_PUB_MULTICODEC):]
110
+ if len(raw) != 32:
111
+ raise ValueError(f"expected 32-byte ed25519 key, got {len(raw)}")
112
+ return Ed25519PublicKey.from_public_bytes(raw)
113
+
114
+
115
+ def multibase_to_raw(pub_multibase: str) -> bytes:
116
+ """Return the raw 32-byte ed25519 public key from a publicKeyMultibase."""
117
+ pub = multibase_to_pub(pub_multibase)
118
+ return pub.public_bytes(
119
+ encoding=serialization.Encoding.Raw,
120
+ format=serialization.PublicFormat.Raw,
121
+ )
122
+
123
+
124
+ def raw_to_multibase(raw: bytes) -> str:
125
+ if len(raw) != 32:
126
+ raise ValueError(f"expected 32-byte ed25519 key, got {len(raw)}")
127
+ return "z" + b58encode(_ED25519_PUB_MULTICODEC + raw)