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 +73 -0
- agentdid/capability.py +95 -0
- agentdid/challenge.py +93 -0
- agentdid/delegation.py +123 -0
- agentdid/did.py +93 -0
- agentdid/keys.py +127 -0
- agentdid/resolver.py +189 -0
- agentdid/revocation.py +80 -0
- agentdid/vc.py +104 -0
- agentdid/vp.py +61 -0
- idp_sdk/__init__.py +6 -0
- idp_sdk/client.py +163 -0
- idp_sdk/py.typed +0 -0
- idp_sdk/verify.py +70 -0
- palonexus/__init__.py +119 -0
- palonexus/_contracts.py +60 -0
- palonexus/_extras.py +30 -0
- palonexus/_issuer.py +108 -0
- palonexus/client.py +865 -0
- palonexus/context.py +170 -0
- palonexus/crypto.py +50 -0
- palonexus/deepagents.py +203 -0
- palonexus/errors.py +111 -0
- palonexus/idp.py +19 -0
- palonexus/langchain.py +304 -0
- palonexus/langgraph.py +194 -0
- palonexus/models.py +219 -0
- palonexus/py.typed +0 -0
- palonexus/pytest_plugin.py +56 -0
- palonexus/reference.py +36 -0
- palonexus/testing.py +816 -0
- palonexus-0.1.0.dist-info/METADATA +119 -0
- palonexus-0.1.0.dist-info/RECORD +35 -0
- palonexus-0.1.0.dist-info/WHEEL +4 -0
- palonexus-0.1.0.dist-info/entry_points.txt +2 -0
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)
|