aroha 1.0.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.
- aroha/__init__.py +33 -0
- aroha/bridges/__init__.py +5 -0
- aroha/bridges/autogen_bridge.py +122 -0
- aroha/bridges/crewai_bridge.py +87 -0
- aroha/bridges/langchain_bridge.py +114 -0
- aroha/client.py +38 -0
- aroha/crypto.py +143 -0
- aroha/csn.py +529 -0
- aroha/envelope.py +88 -0
- aroha/identity.py +195 -0
- aroha/mandate.py +262 -0
- aroha/messages.py +207 -0
- aroha/orchestrator.py +323 -0
- aroha/reputation.py +432 -0
- aroha/test_mandate.py +226 -0
- aroha/transport.py +168 -0
- aroha/web_did.py +394 -0
- aroha-1.0.0.dist-info/METADATA +31 -0
- aroha-1.0.0.dist-info/RECORD +20 -0
- aroha-1.0.0.dist-info/WHEEL +4 -0
aroha/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""
|
|
2
|
+
aroha — Aroha Protocol, Python SDK
|
|
3
|
+
|
|
4
|
+
aroha.identity → Layer 1 (DID, Ed25519, Verifiable Credentials)
|
|
5
|
+
aroha.crypto → Signing, verification, encryption
|
|
6
|
+
aroha.messages → Layer 3 (envelope builder/validator, all message types)
|
|
7
|
+
aroha.transport → Layer 0 (HTTP + WebSocket server/client)
|
|
8
|
+
aroha.orchestrator→ Layer 4 (saga engine, agent selector)
|
|
9
|
+
aroha.csn → Capability Specification Notation
|
|
10
|
+
aroha.mandate → Spending mandates / RBAC
|
|
11
|
+
aroha.reputation → Stake-weighted reputation engine
|
|
12
|
+
|
|
13
|
+
Framework bridges (optional — install extras):
|
|
14
|
+
aroha.bridges.langchain (pip install aroha[langchain])
|
|
15
|
+
aroha.bridges.crewai (pip install aroha[crewai])
|
|
16
|
+
aroha.bridges.autogen (pip install aroha[autogen])
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from aroha.identity import generate_did, AgentKeyPair, DIDDocument
|
|
20
|
+
from aroha.messages import build_envelope, validate_envelope, ArohaEnvelope
|
|
21
|
+
from aroha.transport import ArohaServer, ArohaClient
|
|
22
|
+
|
|
23
|
+
__version__ = "1.0.0"
|
|
24
|
+
__all__ = [
|
|
25
|
+
"generate_did",
|
|
26
|
+
"AgentKeyPair",
|
|
27
|
+
"DIDDocument",
|
|
28
|
+
"build_envelope",
|
|
29
|
+
"validate_envelope",
|
|
30
|
+
"ArohaEnvelope",
|
|
31
|
+
"ArohaServer",
|
|
32
|
+
"ArohaClient",
|
|
33
|
+
]
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Aroha ↔ AutoGen Bridge
|
|
3
|
+
|
|
4
|
+
Wraps Aroha capabilities as AutoGen FunctionTool objects so AutoGen agents
|
|
5
|
+
(including AssistantAgent, ConversableAgent) can call Aroha capabilities as
|
|
6
|
+
part of their conversations.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
from aroha.bridges.autogen_bridge import aroha_tools_for_autogen
|
|
10
|
+
from autogen import AssistantAgent, UserProxyAgent
|
|
11
|
+
|
|
12
|
+
tools = aroha_tools_for_autogen(
|
|
13
|
+
agents=discovered_agents,
|
|
14
|
+
capability_ids=["search-flights", "book-flight"],
|
|
15
|
+
orchestrator_did=identity.did,
|
|
16
|
+
private_key=identity.private_key,
|
|
17
|
+
client=ArohaClient(),
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
assistant = AssistantAgent("assistant", llm_config={"tools": tools})
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import json
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from aroha.messages import build_envelope, new_correlation_id
|
|
30
|
+
from aroha.transport import ArohaClient
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
from autogen import FunctionTool
|
|
34
|
+
except ImportError:
|
|
35
|
+
raise ImportError("Install AutoGen support: pip install aroha[autogen]")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def aroha_tools_for_autogen(
|
|
39
|
+
agents: list[dict[str, Any]], # [{"did", "endpoint", "manifest"}]
|
|
40
|
+
capability_ids: list[str],
|
|
41
|
+
orchestrator_did: str,
|
|
42
|
+
private_key: Any, # Ed25519PrivateKey
|
|
43
|
+
client: ArohaClient,
|
|
44
|
+
) -> list[FunctionTool]:
|
|
45
|
+
"""
|
|
46
|
+
Convert discovered Aroha agent capabilities into AutoGen FunctionTool objects.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
agents: List of discovered Aroha agents
|
|
50
|
+
capability_ids: Which capabilities to expose as tools
|
|
51
|
+
orchestrator_did: DID of the orchestrating agent
|
|
52
|
+
private_key: Ed25519PrivateKey of the orchestrator
|
|
53
|
+
client: ArohaClient instance
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
List of FunctionTool objects ready to pass as ``llm_config["tools"]``
|
|
57
|
+
"""
|
|
58
|
+
tools: list[FunctionTool] = []
|
|
59
|
+
correlation_id = new_correlation_id()
|
|
60
|
+
|
|
61
|
+
for agent in agents:
|
|
62
|
+
manifest = agent.get("manifest", {})
|
|
63
|
+
for cap in manifest.get("capabilities", []):
|
|
64
|
+
if cap["id"] not in capability_ids:
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
# Capture loop variables for closure
|
|
68
|
+
cap_id = cap["id"]
|
|
69
|
+
agent_did = agent["did"]
|
|
70
|
+
agent_endpoint = agent["endpoint"]
|
|
71
|
+
description = cap.get("description", f"Aroha capability: {cap_id}")
|
|
72
|
+
|
|
73
|
+
def make_func(
|
|
74
|
+
_cap_id: str,
|
|
75
|
+
_agent_did: str,
|
|
76
|
+
_endpoint: str,
|
|
77
|
+
_corr_id: str,
|
|
78
|
+
_priv: Any,
|
|
79
|
+
_client: ArohaClient,
|
|
80
|
+
):
|
|
81
|
+
async def _call_capability(**kwargs: Any) -> str:
|
|
82
|
+
envelope = build_envelope(
|
|
83
|
+
"ArohaRequest",
|
|
84
|
+
orchestrator_did,
|
|
85
|
+
_agent_did,
|
|
86
|
+
{"capability": _cap_id, "params": kwargs},
|
|
87
|
+
_corr_id,
|
|
88
|
+
_priv,
|
|
89
|
+
)
|
|
90
|
+
response = await _client.send(_endpoint, envelope)
|
|
91
|
+
if response is None:
|
|
92
|
+
return "Request accepted (async)"
|
|
93
|
+
if response.get("type") == "ArohaError":
|
|
94
|
+
return f"Error: {response['body'].get('message')}"
|
|
95
|
+
return json.dumps(response["body"].get("result", {}), indent=2)
|
|
96
|
+
|
|
97
|
+
def call_capability(**kwargs: Any) -> str:
|
|
98
|
+
"""Synchronous wrapper for AutoGen's function calling."""
|
|
99
|
+
return asyncio.run(_call_capability(**kwargs))
|
|
100
|
+
|
|
101
|
+
call_capability.__name__ = "aroha_" + _cap_id.replace("-", "_")
|
|
102
|
+
call_capability.__doc__ = description
|
|
103
|
+
return call_capability
|
|
104
|
+
|
|
105
|
+
func = make_func(
|
|
106
|
+
cap_id, agent_did, agent_endpoint,
|
|
107
|
+
correlation_id, private_key, client,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Build a minimal JSON Schema for the input params from the manifest
|
|
111
|
+
input_schema = cap.get("inputSchema", {"type": "object", "properties": {}})
|
|
112
|
+
|
|
113
|
+
tool = FunctionTool(
|
|
114
|
+
func,
|
|
115
|
+
description=description,
|
|
116
|
+
name="aroha_" + cap_id.replace("-", "_"),
|
|
117
|
+
input_schema=input_schema,
|
|
118
|
+
)
|
|
119
|
+
tools.append(tool)
|
|
120
|
+
|
|
121
|
+
return tools
|
|
122
|
+
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Aroha ↔ CrewAI Bridge
|
|
3
|
+
|
|
4
|
+
Wraps a Aroha provider agent as a CrewAI Tool so CrewAI agents
|
|
5
|
+
can call Aroha capabilities as part of their crew workflows.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from aroha.bridges.crewai_bridge import ArohaCrewTool
|
|
9
|
+
from crewai import Agent, Task, Crew
|
|
10
|
+
|
|
11
|
+
flight_tool = ArohaCrewTool(
|
|
12
|
+
capability_id="search-flights",
|
|
13
|
+
agent_did="did:aroha:expedia-flights-v2",
|
|
14
|
+
agent_endpoint="https://agents.expedia.com/aroha/v1",
|
|
15
|
+
orchestrator_did=identity.did,
|
|
16
|
+
private_key=identity.private_key,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
researcher = Agent(role="Travel Researcher", tools=[flight_tool], ...)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import json
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from aroha.messages import build_envelope, new_correlation_id
|
|
29
|
+
from aroha.transport import ArohaClient
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
from crewai.tools import BaseTool as CrewBaseTool
|
|
33
|
+
except ImportError:
|
|
34
|
+
raise ImportError("Install CrewAI support: pip install aroha[crewai]")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ArohaCrewTool(CrewBaseTool):
|
|
38
|
+
"""CrewAI tool backed by a Aroha capability."""
|
|
39
|
+
|
|
40
|
+
name: str
|
|
41
|
+
description: str
|
|
42
|
+
agent_did: str
|
|
43
|
+
agent_endpoint: str
|
|
44
|
+
capability_id: str
|
|
45
|
+
orchestrator_did: str
|
|
46
|
+
_private_key: Any
|
|
47
|
+
_client: ArohaClient
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
capability_id: str,
|
|
52
|
+
agent_did: str,
|
|
53
|
+
agent_endpoint: str,
|
|
54
|
+
orchestrator_did: str,
|
|
55
|
+
private_key: Any,
|
|
56
|
+
description: str = "",
|
|
57
|
+
) -> None:
|
|
58
|
+
super().__init__(
|
|
59
|
+
name=f"aroha_{capability_id.replace('-', '_')}",
|
|
60
|
+
description=description or f"Aroha capability: {capability_id}",
|
|
61
|
+
)
|
|
62
|
+
self.capability_id = capability_id
|
|
63
|
+
self.agent_did = agent_did
|
|
64
|
+
self.agent_endpoint = agent_endpoint
|
|
65
|
+
self.orchestrator_did = orchestrator_did
|
|
66
|
+
self._private_key = private_key
|
|
67
|
+
self._client = ArohaClient()
|
|
68
|
+
|
|
69
|
+
def _run(self, **kwargs: Any) -> str:
|
|
70
|
+
return asyncio.run(self._execute(kwargs))
|
|
71
|
+
|
|
72
|
+
async def _execute(self, params: dict[str, Any]) -> str:
|
|
73
|
+
envelope = build_envelope(
|
|
74
|
+
"ArohaRequest",
|
|
75
|
+
self.orchestrator_did,
|
|
76
|
+
self.agent_did,
|
|
77
|
+
{"capability": self.capability_id, "params": params},
|
|
78
|
+
new_correlation_id(),
|
|
79
|
+
self._private_key,
|
|
80
|
+
)
|
|
81
|
+
response = await self._client.send(self.agent_endpoint, envelope)
|
|
82
|
+
if response is None:
|
|
83
|
+
return "Request accepted (async)"
|
|
84
|
+
if response.get("type") == "ArohaError":
|
|
85
|
+
return f"Error: {response['body'].get('message')}"
|
|
86
|
+
return json.dumps(response["body"].get("result", {}), indent=2)
|
|
87
|
+
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Aroha ↔ LangChain Bridge
|
|
3
|
+
|
|
4
|
+
Exposes Aroha capabilities as LangChain Tools so any LangChain/LangGraph
|
|
5
|
+
agent can call them without knowing about the Aroha protocol.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from aroha.bridges.langchain_bridge import aroha_tools_for_agent
|
|
9
|
+
|
|
10
|
+
tools = await aroha_tools_for_agent(
|
|
11
|
+
discovery=discovery,
|
|
12
|
+
capability_ids=["search-flights", "book-flight"],
|
|
13
|
+
orchestrator_did=identity.did,
|
|
14
|
+
private_key=identity.private_key,
|
|
15
|
+
client=ArohaClient(),
|
|
16
|
+
)
|
|
17
|
+
agent = create_react_agent(llm, tools)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import json
|
|
24
|
+
import uuid
|
|
25
|
+
from typing import Any, Optional, Type
|
|
26
|
+
|
|
27
|
+
from aroha.messages import build_envelope, new_correlation_id
|
|
28
|
+
from aroha.transport import ArohaClient
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
from langchain_core.tools import BaseTool
|
|
32
|
+
from pydantic import BaseModel, Field
|
|
33
|
+
except ImportError:
|
|
34
|
+
raise ImportError("Install langchain support: pip install aroha[langchain]")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ArohaCapabilityTool(BaseTool):
|
|
38
|
+
"""A LangChain Tool backed by a Aroha capability."""
|
|
39
|
+
|
|
40
|
+
name: str
|
|
41
|
+
description: str
|
|
42
|
+
agent_did: str
|
|
43
|
+
agent_endpoint: str
|
|
44
|
+
capability_id: str
|
|
45
|
+
orchestrator_did: str
|
|
46
|
+
correlation_id: str
|
|
47
|
+
_private_key: Any # Ed25519PrivateKey
|
|
48
|
+
_client: Any # ArohaClient
|
|
49
|
+
|
|
50
|
+
class Config:
|
|
51
|
+
arbitrary_types_allowed = True
|
|
52
|
+
|
|
53
|
+
def _run(self, **kwargs: Any) -> str:
|
|
54
|
+
"""Synchronous wrapper — runs the async send in a new event loop."""
|
|
55
|
+
return asyncio.run(self._arun(**kwargs))
|
|
56
|
+
|
|
57
|
+
async def _arun(self, **kwargs: Any) -> str:
|
|
58
|
+
envelope = build_envelope(
|
|
59
|
+
"ArohaRequest",
|
|
60
|
+
self.orchestrator_did,
|
|
61
|
+
self.agent_did,
|
|
62
|
+
{"capability": self.capability_id, "params": kwargs},
|
|
63
|
+
self.correlation_id,
|
|
64
|
+
self._private_key,
|
|
65
|
+
)
|
|
66
|
+
response = await self._client.send(self.agent_endpoint, envelope)
|
|
67
|
+
if response is None:
|
|
68
|
+
return "Agent accepted request (async — check WebSocket stream)"
|
|
69
|
+
if response.get("type") == "ArohaError":
|
|
70
|
+
return f"Error: {response['body'].get('message')}"
|
|
71
|
+
return json.dumps(response["body"].get("result", {}), indent=2)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def aroha_tools_for_agent(
|
|
75
|
+
agents: list[dict[str, Any]], # [{"did", "endpoint", "manifest"}]
|
|
76
|
+
capability_ids: list[str],
|
|
77
|
+
orchestrator_did: str,
|
|
78
|
+
private_key: Any,
|
|
79
|
+
client: ArohaClient,
|
|
80
|
+
) -> list[ArohaCapabilityTool]:
|
|
81
|
+
"""
|
|
82
|
+
Convert Aroha agent capabilities into LangChain tools.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
agents: List of discovered Aroha agents
|
|
86
|
+
capability_ids: Which capabilities to expose as tools
|
|
87
|
+
orchestrator_did: DID of the orchestrating agent
|
|
88
|
+
private_key: Ed25519PrivateKey of the orchestrator
|
|
89
|
+
client: ArohaClient instance
|
|
90
|
+
"""
|
|
91
|
+
tools = []
|
|
92
|
+
correlation_id = new_correlation_id()
|
|
93
|
+
|
|
94
|
+
for agent in agents:
|
|
95
|
+
manifest = agent.get("manifest", {})
|
|
96
|
+
for cap in manifest.get("capabilities", []):
|
|
97
|
+
if cap["id"] not in capability_ids:
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
tool = ArohaCapabilityTool(
|
|
101
|
+
name=f"aroha_{cap['id'].replace('-', '_')}",
|
|
102
|
+
description=cap.get("description", cap["id"]),
|
|
103
|
+
agent_did=agent["did"],
|
|
104
|
+
agent_endpoint=agent["endpoint"],
|
|
105
|
+
capability_id=cap["id"],
|
|
106
|
+
orchestrator_did=orchestrator_did,
|
|
107
|
+
correlation_id=correlation_id,
|
|
108
|
+
)
|
|
109
|
+
tool._private_key = private_key
|
|
110
|
+
tool._client = client
|
|
111
|
+
tools.append(tool)
|
|
112
|
+
|
|
113
|
+
return tools
|
|
114
|
+
|
aroha/client.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
import httpx
|
|
3
|
+
from .envelope import build_envelope
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ArohaClient:
|
|
7
|
+
"""Minimal async HTTP client for sending Aroha messages."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, agent: dict, *, timeout: float = 30.0):
|
|
10
|
+
self.agent = agent
|
|
11
|
+
self.timeout = timeout
|
|
12
|
+
|
|
13
|
+
async def request(
|
|
14
|
+
self,
|
|
15
|
+
endpoint: str,
|
|
16
|
+
to_did: str,
|
|
17
|
+
capability: str,
|
|
18
|
+
params: dict,
|
|
19
|
+
*,
|
|
20
|
+
correlation_id: str | None = None,
|
|
21
|
+
) -> dict:
|
|
22
|
+
cid = correlation_id or f"saga-{uuid.uuid4()}"
|
|
23
|
+
envelope = build_envelope(
|
|
24
|
+
"ArohaRequest",
|
|
25
|
+
self.agent["did"],
|
|
26
|
+
to_did,
|
|
27
|
+
{"capability": capability, "params": params},
|
|
28
|
+
cid,
|
|
29
|
+
self.agent["private_key"],
|
|
30
|
+
)
|
|
31
|
+
async with httpx.AsyncClient(timeout=self.timeout) as http:
|
|
32
|
+
res = await http.post(
|
|
33
|
+
endpoint,
|
|
34
|
+
json=envelope,
|
|
35
|
+
headers={"Content-Type": "application/json"},
|
|
36
|
+
)
|
|
37
|
+
res.raise_for_status()
|
|
38
|
+
return res.json()
|
aroha/crypto.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Aroha Python SDK — Crypto: Signing + Encryption
|
|
3
|
+
|
|
4
|
+
Ed25519 signing (per spec: canonical JSON, keys sorted, proof excluded).
|
|
5
|
+
AES-256-GCM encryption for sensitive body fields (DIDComm-style).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
17
|
+
Ed25519PrivateKey,
|
|
18
|
+
Ed25519PublicKey,
|
|
19
|
+
)
|
|
20
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
21
|
+
from cryptography.hazmat.primitives import hashes
|
|
22
|
+
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ── Signing ────────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
def sign_message(
|
|
28
|
+
message: dict[str, Any],
|
|
29
|
+
private_key: Ed25519PrivateKey,
|
|
30
|
+
verification_method_id: str,
|
|
31
|
+
) -> dict[str, str]:
|
|
32
|
+
"""
|
|
33
|
+
Sign a Aroha message. Returns a proof dict to embed in the envelope.
|
|
34
|
+
|
|
35
|
+
Per spec: signature covers canonical JSON with 'proof' field excluded,
|
|
36
|
+
all keys sorted lexicographically at every nesting level.
|
|
37
|
+
"""
|
|
38
|
+
canonical = canonicalize_message(message)
|
|
39
|
+
signature = private_key.sign(canonical.encode("utf-8"))
|
|
40
|
+
proof_value = base64.urlsafe_b64encode(signature).rstrip(b"=").decode()
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
"type": "Ed25519Signature2020",
|
|
44
|
+
"created": datetime.now(timezone.utc).isoformat(),
|
|
45
|
+
"verificationMethod": verification_method_id,
|
|
46
|
+
"proofPurpose": "authentication",
|
|
47
|
+
"proofValue": proof_value,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def verify_message_signature(
|
|
52
|
+
message: dict[str, Any],
|
|
53
|
+
public_key: Ed25519PublicKey,
|
|
54
|
+
) -> bool:
|
|
55
|
+
"""
|
|
56
|
+
Verify a Aroha message signature.
|
|
57
|
+
Returns True if valid, False otherwise.
|
|
58
|
+
"""
|
|
59
|
+
proof = message.get("proof")
|
|
60
|
+
if not proof or not proof.get("proofValue"):
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
canonical = canonicalize_message(message)
|
|
64
|
+
proof_value = proof["proofValue"]
|
|
65
|
+
|
|
66
|
+
# Restore base64url padding
|
|
67
|
+
padding = 4 - len(proof_value) % 4
|
|
68
|
+
if padding != 4:
|
|
69
|
+
proof_value += "=" * padding
|
|
70
|
+
signature = base64.urlsafe_b64decode(proof_value)
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
public_key.verify(signature, canonical.encode("utf-8"))
|
|
74
|
+
return True
|
|
75
|
+
except Exception:
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def canonicalize_message(message: dict[str, Any]) -> str:
|
|
80
|
+
"""
|
|
81
|
+
Produce canonical JSON string for signing.
|
|
82
|
+
Excludes 'proof' field, sorts all keys lexicographically.
|
|
83
|
+
"""
|
|
84
|
+
without_proof = {k: v for k, v in message.items() if k != "proof"}
|
|
85
|
+
return json.dumps(_sort_keys_deep(without_proof), separators=(",", ":"))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _sort_keys_deep(obj: Any) -> Any:
|
|
89
|
+
if isinstance(obj, dict):
|
|
90
|
+
return {k: _sort_keys_deep(v) for k, v in sorted(obj.items())}
|
|
91
|
+
if isinstance(obj, list):
|
|
92
|
+
return [_sort_keys_deep(item) for item in obj]
|
|
93
|
+
return obj
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ── Encryption (DIDComm-style AES-256-GCM) ────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
def encrypt_body(plaintext: dict[str, Any], recipient_pub_bytes: bytes) -> dict[str, str]:
|
|
99
|
+
"""
|
|
100
|
+
Encrypt a message body for a specific recipient.
|
|
101
|
+
Uses AES-256-GCM with a random key + IV.
|
|
102
|
+
|
|
103
|
+
Returns an EncryptedBody dict with alg, iv, ciphertext, tag.
|
|
104
|
+
|
|
105
|
+
Note: In production use full ECDH-ES+A256GCM. This implementation
|
|
106
|
+
uses a simplified symmetric approach suitable for testing.
|
|
107
|
+
For full DIDComm v2 compliance use the `didcomm` PyPI package.
|
|
108
|
+
"""
|
|
109
|
+
key = os.urandom(32)
|
|
110
|
+
iv = os.urandom(12)
|
|
111
|
+
aesgcm = AESGCM(key)
|
|
112
|
+
payload = json.dumps(plaintext).encode("utf-8")
|
|
113
|
+
encrypted = aesgcm.encrypt(iv, payload, None)
|
|
114
|
+
ciphertext = encrypted[:-16]
|
|
115
|
+
tag = encrypted[-16:]
|
|
116
|
+
|
|
117
|
+
def b64(b: bytes) -> str:
|
|
118
|
+
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
"alg": "A256GCM",
|
|
122
|
+
"iv": b64(iv),
|
|
123
|
+
"ciphertext": b64(ciphertext),
|
|
124
|
+
"tag": b64(tag),
|
|
125
|
+
# In full ECDH-ES: encrypted_key and epk would be included here
|
|
126
|
+
"_key": b64(key), # DEV ONLY — remove in production
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def decrypt_body(encrypted: dict[str, str], private_key_bytes: bytes) -> dict[str, Any]:
|
|
131
|
+
"""Decrypt an encrypted message body."""
|
|
132
|
+
def b64d(s: str) -> bytes:
|
|
133
|
+
s += "=" * (4 - len(s) % 4)
|
|
134
|
+
return base64.urlsafe_b64decode(s)
|
|
135
|
+
|
|
136
|
+
key = b64d(encrypted["_key"]) # DEV ONLY
|
|
137
|
+
iv = b64d(encrypted["iv"])
|
|
138
|
+
ciphertext = b64d(encrypted["ciphertext"])
|
|
139
|
+
tag = b64d(encrypted["tag"])
|
|
140
|
+
|
|
141
|
+
aesgcm = AESGCM(key)
|
|
142
|
+
plaintext = aesgcm.decrypt(iv, ciphertext + tag, None)
|
|
143
|
+
return json.loads(plaintext.decode("utf-8"))
|