zyndai-agent 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.
- p3ai_agent/__init__.py +0 -0
- p3ai_agent/agent.py +75 -0
- p3ai_agent/communication.py +556 -0
- p3ai_agent/identity.py +126 -0
- p3ai_agent/search.py +63 -0
- p3ai_agent/utils.py +369 -0
- zyndai_agent-0.1.0.dist-info/METADATA +409 -0
- zyndai_agent-0.1.0.dist-info/RECORD +10 -0
- zyndai_agent-0.1.0.dist-info/WHEEL +5 -0
- zyndai_agent-0.1.0.dist-info/top_level.txt +1 -0
p3ai_agent/identity.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import requests
|
|
4
|
+
from dotenv import load_dotenv
|
|
5
|
+
from typing import Dict, Any, List, Union
|
|
6
|
+
|
|
7
|
+
from langchain.agents.conversational.base import ConversationalAgent
|
|
8
|
+
from langchain.schema import AgentAction, AgentFinish
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class IdentityManager:
|
|
12
|
+
"""
|
|
13
|
+
This class manages the identity verification process for P3AI agents.
|
|
14
|
+
It interacts with the P3 Identity SDK to verify agent identities.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, registry_url: str = None):
|
|
18
|
+
"""
|
|
19
|
+
Initialize the P3 Identity SDK by loading environment variables
|
|
20
|
+
and setting up necessary attributes.
|
|
21
|
+
"""
|
|
22
|
+
# Load environment variables from .env file
|
|
23
|
+
load_dotenv()
|
|
24
|
+
|
|
25
|
+
# Get identity document from environment variables
|
|
26
|
+
self.IDENTITY_DOCUMENT = os.environ.get("IDENTITY_DOCUMENT")
|
|
27
|
+
|
|
28
|
+
# Get DID from environment variables
|
|
29
|
+
self.AGENT_DID = None
|
|
30
|
+
|
|
31
|
+
# Get SDK API endpoint from environment variables with a default fallback
|
|
32
|
+
self.registry_url = registry_url
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def verify_agent_identity(self, credential_document: str) -> bool:
|
|
37
|
+
"""
|
|
38
|
+
Verify an agent's identity credential document by calling the SDK API.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
credential_document (str): The credential document to verify.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Dict[str, Any]: The response from the verification API
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
ValueError: If no credential document is provided
|
|
48
|
+
RuntimeError: If the API call fails
|
|
49
|
+
"""
|
|
50
|
+
# Validate that we have a credential document to verify
|
|
51
|
+
if not credential_document:
|
|
52
|
+
raise ValueError("No credential document provided for verification")
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
# Prepare the request payload
|
|
56
|
+
payload = {
|
|
57
|
+
"credDocumentJson": credential_document
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Set up headers
|
|
61
|
+
headers = {
|
|
62
|
+
"accept": "application/json",
|
|
63
|
+
"Content-Type": "application/json"
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# Make the API call
|
|
67
|
+
response = requests.post(
|
|
68
|
+
f"{self.registry_url}/sdk",
|
|
69
|
+
headers=headers,
|
|
70
|
+
json=payload
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Raise an exception for bad status codes
|
|
74
|
+
response.raise_for_status()
|
|
75
|
+
|
|
76
|
+
# Return the JSON response
|
|
77
|
+
return response.json()
|
|
78
|
+
|
|
79
|
+
except requests.RequestException as e:
|
|
80
|
+
# Handle API request failures
|
|
81
|
+
raise RuntimeError(f"Failed to verify identity: {str(e)}")
|
|
82
|
+
except json.JSONDecodeError:
|
|
83
|
+
# Handle invalid JSON responses
|
|
84
|
+
raise RuntimeError("Received invalid response from verification service")
|
|
85
|
+
|
|
86
|
+
def get_identity_document(self) -> str:
|
|
87
|
+
"""
|
|
88
|
+
Get the identity document of the current agent.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
str: The identity document
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
ValueError: If no identity document is available
|
|
95
|
+
"""
|
|
96
|
+
if not self.IDENTITY_DOCUMENT:
|
|
97
|
+
raise ValueError("No identity document available for this agent")
|
|
98
|
+
|
|
99
|
+
return self.IDENTITY_DOCUMENT
|
|
100
|
+
|
|
101
|
+
def get_my_did(self) -> dict:
|
|
102
|
+
"""
|
|
103
|
+
Get the DID (Decentralized Identifier) of the current agent.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
str: The agent's DID
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
ValueError: If no DID is available
|
|
110
|
+
"""
|
|
111
|
+
if not self.AGENT_DID:
|
|
112
|
+
raise ValueError("No DID available for this agent")
|
|
113
|
+
print(self.AGENT_DID)
|
|
114
|
+
return ""
|
|
115
|
+
|
|
116
|
+
def load_did(self, cred_path: str) -> None:
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
with open(cred_path, "r") as f:
|
|
120
|
+
self.AGENT_DID = json.load(f)
|
|
121
|
+
|
|
122
|
+
except FileNotFoundError:
|
|
123
|
+
raise FileNotFoundError(f"Credential file not found: {cred_path}")
|
|
124
|
+
except json.JSONDecodeError:
|
|
125
|
+
raise ValueError(f"Invalid JSON in credential file: {cred_path}")
|
|
126
|
+
|
p3ai_agent/search.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Agent Discovery and Search Protocol Module for P3AI
|
|
2
|
+
import logging
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
from typing import List, Optional, TypedDict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
logging.basicConfig(
|
|
9
|
+
level=logging.ERROR,
|
|
10
|
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
11
|
+
)
|
|
12
|
+
logger = logging.getLogger("SearchAndDiscovery")
|
|
13
|
+
|
|
14
|
+
class AgentSearchResponse(TypedDict):
|
|
15
|
+
id: str
|
|
16
|
+
name: str
|
|
17
|
+
description: str
|
|
18
|
+
mqttUri: Optional[str]
|
|
19
|
+
inboxTopic: Optional[str]
|
|
20
|
+
matchScore: int
|
|
21
|
+
didIdentifier: str
|
|
22
|
+
did: dict
|
|
23
|
+
|
|
24
|
+
class SearchAndDiscoveryManager:
|
|
25
|
+
"""
|
|
26
|
+
This class implements the search and discovery protocol for P3AI agents.
|
|
27
|
+
It allows agents to discover each other and share information about their capabilities.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, registry_url: str = "http://localhost:3002/sdk/search"):
|
|
31
|
+
|
|
32
|
+
self.agents = []
|
|
33
|
+
self.registry_url = registry_url
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def search_agents_by_capabilities(self, capabilities: List[str] = [], match_score_gte: float = 0.5, top_k: Optional[int] = None) -> List[AgentSearchResponse]:
|
|
37
|
+
"""
|
|
38
|
+
Discover all registered agents in the system based on their capabilities.
|
|
39
|
+
|
|
40
|
+
match_score_gte: Minimum match score for agents to be included in the results.
|
|
41
|
+
top_k: Optional parameter to limit the number of results returned or return all.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
logger.info("Discovering agents...")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
resp = requests.post(f"{self.registry_url}/sdk/search", json={"userProvidedCapabilities": capabilities})
|
|
48
|
+
if resp.status_code == 201:
|
|
49
|
+
agents = resp.json()
|
|
50
|
+
logger.info(f"Discovered {len(agents)} agents.")
|
|
51
|
+
|
|
52
|
+
filtered_agents = [
|
|
53
|
+
agent for agent in agents
|
|
54
|
+
if agent.get("matchScore", 0) >= match_score_gte
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
if top_k is not None:
|
|
58
|
+
filtered_agents = filtered_agents[:top_k]
|
|
59
|
+
|
|
60
|
+
return filtered_agents
|
|
61
|
+
else:
|
|
62
|
+
logger.error(f"Failed to discover agents: {resp.status_code} - {resp.text}")
|
|
63
|
+
return []
|
p3ai_agent/utils.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
import os
|
|
4
|
+
from cryptography.hazmat.primitives import hashes
|
|
5
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
6
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
7
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
8
|
+
from cryptography.hazmat.backends import default_backend
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def derive_private_key_from_seed(seed_phrase):
|
|
12
|
+
"""
|
|
13
|
+
Derive private key from seed phrase
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
seed_phrase (str): Base64 encoded seed phrase
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
bytes: Private key (32 bytes)
|
|
20
|
+
"""
|
|
21
|
+
seed_bytes = base64.b64decode(seed_phrase)
|
|
22
|
+
return hashlib.sha256(seed_bytes).digest()
|
|
23
|
+
|
|
24
|
+
def derive_public_key_from_private(private_key_bytes):
|
|
25
|
+
"""
|
|
26
|
+
Derive public key from private key
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
private_key_bytes (bytes): Private key (32 bytes)
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
bytes: Public key in uncompressed format
|
|
33
|
+
"""
|
|
34
|
+
private_key = ec.derive_private_key(
|
|
35
|
+
int.from_bytes(private_key_bytes, 'big'),
|
|
36
|
+
ec.SECP256K1(),
|
|
37
|
+
default_backend()
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
public_key = private_key.public_key()
|
|
41
|
+
public_key_numbers = public_key.public_numbers()
|
|
42
|
+
|
|
43
|
+
x_bytes = public_key_numbers.x.to_bytes(32, 'big')
|
|
44
|
+
y_bytes = public_key_numbers.y.to_bytes(32, 'big')
|
|
45
|
+
|
|
46
|
+
return b'\x04' + x_bytes + y_bytes
|
|
47
|
+
|
|
48
|
+
def extract_public_key_from_did(did_document):
|
|
49
|
+
"""
|
|
50
|
+
Extract public key from DID document coordinates.
|
|
51
|
+
For PolygonID AuthBJJ credentials, derives secp256k1 key from BabyJubJub coordinates.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
did_document (dict): DID document containing credentialSubject with x,y coordinates
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
bytes: Public key in uncompressed format (secp256k1 derived from AuthBJJ)
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
x = int(did_document['credentialSubject']['x'])
|
|
61
|
+
y = int(did_document['credentialSubject']['y'])
|
|
62
|
+
|
|
63
|
+
# For AuthBJJ credentials, derive deterministic secp256k1 key from BabyJubJub coordinates
|
|
64
|
+
# This creates a stable mapping from AuthBJJ points to secp256k1 keys
|
|
65
|
+
did_id = did_document.get('id', '')
|
|
66
|
+
issuer = did_document.get('issuer', '')
|
|
67
|
+
credential_type = did_document.get('credentialSubject', {}).get('type', '')
|
|
68
|
+
|
|
69
|
+
# Create deterministic seed from DID-specific data
|
|
70
|
+
seed_data = f"authbjj:{x}:{y}:{did_id}:{issuer}:{credential_type}".encode('utf-8')
|
|
71
|
+
|
|
72
|
+
# Hash to create deterministic private key
|
|
73
|
+
private_key_bytes = hashlib.sha256(seed_data).digest()
|
|
74
|
+
private_key_int = int.from_bytes(private_key_bytes, 'big')
|
|
75
|
+
|
|
76
|
+
# Ensure key is within secp256k1 range
|
|
77
|
+
secp256k1_order = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
|
|
78
|
+
private_key_int = (private_key_int % (secp256k1_order - 1)) + 1
|
|
79
|
+
|
|
80
|
+
# Create secp256k1 private key and derive public key
|
|
81
|
+
private_key = ec.derive_private_key(private_key_int, ec.SECP256K1(), default_backend())
|
|
82
|
+
public_key = private_key.public_key()
|
|
83
|
+
public_key_numbers = public_key.public_numbers()
|
|
84
|
+
|
|
85
|
+
# Format as uncompressed public key
|
|
86
|
+
x_bytes = public_key_numbers.x.to_bytes(32, 'big')
|
|
87
|
+
y_bytes = public_key_numbers.y.to_bytes(32, 'big')
|
|
88
|
+
|
|
89
|
+
return b'\x04' + x_bytes + y_bytes
|
|
90
|
+
|
|
91
|
+
except Exception as e:
|
|
92
|
+
raise ValueError(f"Failed to extract public key from DID document: {e}")
|
|
93
|
+
|
|
94
|
+
def _derive_secp256k1_private_key_from_did(did_document):
|
|
95
|
+
"""
|
|
96
|
+
Internal helper to derive the same secp256k1 private key that extract_public_key_from_did creates.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
did_document (dict): DID document containing AuthBJJ credentials
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
ec.EllipticCurvePrivateKey: secp256k1 private key object
|
|
103
|
+
"""
|
|
104
|
+
x = int(did_document['credentialSubject']['x'])
|
|
105
|
+
y = int(did_document['credentialSubject']['y'])
|
|
106
|
+
|
|
107
|
+
did_id = did_document.get('id', '')
|
|
108
|
+
issuer = did_document.get('issuer', '')
|
|
109
|
+
credential_type = did_document.get('credentialSubject', {}).get('type', '')
|
|
110
|
+
|
|
111
|
+
# Use same seed generation as extract_public_key_from_did
|
|
112
|
+
seed_data = f"authbjj:{x}:{y}:{did_id}:{issuer}:{credential_type}".encode('utf-8')
|
|
113
|
+
private_key_bytes = hashlib.sha256(seed_data).digest()
|
|
114
|
+
private_key_int = int.from_bytes(private_key_bytes, 'big')
|
|
115
|
+
|
|
116
|
+
# Ensure key is within secp256k1 range
|
|
117
|
+
secp256k1_order = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
|
|
118
|
+
private_key_int = (private_key_int % (secp256k1_order - 1)) + 1
|
|
119
|
+
|
|
120
|
+
return ec.derive_private_key(private_key_int, ec.SECP256K1(), default_backend())
|
|
121
|
+
|
|
122
|
+
def derive_shared_key_from_seed_and_did(secret_seed, identity_credential):
|
|
123
|
+
"""
|
|
124
|
+
Derive a shared authentication key from both seed and DID to validate ownership.
|
|
125
|
+
This ensures only someone with BOTH the correct seed AND the correct DID can decrypt.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
secret_seed (str): Base64 encoded seed phrase
|
|
129
|
+
identity_credential (dict): DID document
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
bytes: 32-byte shared authentication key
|
|
133
|
+
"""
|
|
134
|
+
# Get seed-derived private key
|
|
135
|
+
seed_private_key_bytes = derive_private_key_from_seed(secret_seed)
|
|
136
|
+
|
|
137
|
+
# Get DID-derived public key
|
|
138
|
+
did_public_key_bytes = extract_public_key_from_did(identity_credential)
|
|
139
|
+
|
|
140
|
+
# Get DID identifier
|
|
141
|
+
did_id = identity_credential.get('id', '')
|
|
142
|
+
|
|
143
|
+
# Combine all three to create authentication key
|
|
144
|
+
combined_data = seed_private_key_bytes + did_public_key_bytes + did_id.encode('utf-8')
|
|
145
|
+
|
|
146
|
+
# Hash to create final authentication key
|
|
147
|
+
auth_key = hashlib.sha256(combined_data).digest()
|
|
148
|
+
|
|
149
|
+
return auth_key
|
|
150
|
+
|
|
151
|
+
def encrypt_message(message, identity_credential_connected_agent):
|
|
152
|
+
"""
|
|
153
|
+
Encrypt message using ECIES (Elliptic Curve Integrated Encryption Scheme).
|
|
154
|
+
Compatible with PolygonID AuthBJJ credentials.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
message (str): Plain text message to encrypt
|
|
158
|
+
identity_credential_connected_agent (dict): Recipient's DID document for encryption
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
dict: Encrypted message with metadata containing ephemeral_public_key, iv, encrypted_data, and algorithm
|
|
162
|
+
"""
|
|
163
|
+
try:
|
|
164
|
+
# Extract recipient's public key from DID document (handles AuthBJJ -> secp256k1 conversion)
|
|
165
|
+
recipient_public_key_bytes = extract_public_key_from_did(identity_credential_connected_agent)
|
|
166
|
+
|
|
167
|
+
# Generate ephemeral key pair
|
|
168
|
+
ephemeral_private_key = ec.generate_private_key(ec.SECP256K1(), default_backend())
|
|
169
|
+
ephemeral_public_key = ephemeral_private_key.public_key()
|
|
170
|
+
|
|
171
|
+
# Extract coordinates from recipient's public key
|
|
172
|
+
recipient_x = int.from_bytes(recipient_public_key_bytes[1:33], 'big')
|
|
173
|
+
recipient_y = int.from_bytes(recipient_public_key_bytes[33:65], 'big')
|
|
174
|
+
|
|
175
|
+
# Create recipient's EC public key
|
|
176
|
+
try:
|
|
177
|
+
recipient_ec_public_key = ec.EllipticCurvePublicNumbers(
|
|
178
|
+
recipient_x, recipient_y, ec.SECP256K1()
|
|
179
|
+
).public_key(default_backend())
|
|
180
|
+
except ValueError as e:
|
|
181
|
+
raise ValueError(f"Invalid elliptic curve coordinates in DID document: {e}")
|
|
182
|
+
|
|
183
|
+
# Perform ECDH to get shared secret
|
|
184
|
+
shared_secret = ephemeral_private_key.exchange(
|
|
185
|
+
ec.ECDH(), recipient_ec_public_key
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Derive encryption key using HKDF with recipient's DID ID as additional context
|
|
189
|
+
recipient_did_id = identity_credential_connected_agent.get('id', '')
|
|
190
|
+
encryption_key = HKDF(
|
|
191
|
+
algorithm=hashes.SHA256(),
|
|
192
|
+
length=32,
|
|
193
|
+
salt=recipient_did_id.encode('utf-8'), # Use DID ID as salt for additional security
|
|
194
|
+
info=b'polygonid_authbjj_encryption',
|
|
195
|
+
backend=default_backend()
|
|
196
|
+
).derive(shared_secret)
|
|
197
|
+
|
|
198
|
+
# Generate random IV
|
|
199
|
+
iv = os.urandom(16)
|
|
200
|
+
|
|
201
|
+
# Create AES cipher
|
|
202
|
+
cipher = Cipher(
|
|
203
|
+
algorithms.AES(encryption_key),
|
|
204
|
+
modes.CBC(iv),
|
|
205
|
+
backend=default_backend()
|
|
206
|
+
)
|
|
207
|
+
encryptor = cipher.encryptor()
|
|
208
|
+
|
|
209
|
+
# Apply PKCS7 padding
|
|
210
|
+
message_bytes = message.encode('utf-8')
|
|
211
|
+
padding_length = 16 - (len(message_bytes) % 16)
|
|
212
|
+
padded_message = message_bytes + bytes([padding_length] * padding_length)
|
|
213
|
+
|
|
214
|
+
# Encrypt the message
|
|
215
|
+
encrypted_data = encryptor.update(padded_message) + encryptor.finalize()
|
|
216
|
+
|
|
217
|
+
# Format ephemeral public key for transmission
|
|
218
|
+
ephemeral_public_numbers = ephemeral_public_key.public_numbers()
|
|
219
|
+
ephemeral_x = ephemeral_public_numbers.x.to_bytes(32, 'big')
|
|
220
|
+
ephemeral_y = ephemeral_public_numbers.y.to_bytes(32, 'big')
|
|
221
|
+
ephemeral_public_key_bytes = b'\x04' + ephemeral_x + ephemeral_y
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
'ephemeral_public_key': base64.b64encode(ephemeral_public_key_bytes).decode(),
|
|
225
|
+
'iv': base64.b64encode(iv).decode(),
|
|
226
|
+
'encrypted_data': base64.b64encode(encrypted_data).decode(),
|
|
227
|
+
'algorithm': 'ECIES-AES256-CBC-AuthBJJ',
|
|
228
|
+
'recipient_did_id': recipient_did_id,
|
|
229
|
+
'encryption_version': '2.0' # Version to prevent fallback attacks
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
except Exception as e:
|
|
233
|
+
raise ValueError(f"Encryption failed: {e}")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def decrypt_message(encrypted_message, secret_seed, identity_credential):
|
|
237
|
+
"""
|
|
238
|
+
Decrypt message using recipient's seed phrase and identity credential.
|
|
239
|
+
Compatible with PolygonID AuthBJJ credentials.
|
|
240
|
+
STRICT VALIDATION: Requires both correct seed AND correct DID.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
encrypted_message (dict): Encrypted message from encrypt_message()
|
|
244
|
+
secret_seed (str): Base64 encoded seed phrase for private key derivation
|
|
245
|
+
identity_credential (dict): Recipient's DID document for key validation
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
str: Decrypted plain text message
|
|
249
|
+
"""
|
|
250
|
+
try:
|
|
251
|
+
# Check if this is the new secure version
|
|
252
|
+
encryption_version = encrypted_message.get('encryption_version', '1.0')
|
|
253
|
+
|
|
254
|
+
# Verify message was intended for this specific DID
|
|
255
|
+
expected_did_id = encrypted_message.get('recipient_did_id', '')
|
|
256
|
+
actual_did_id = identity_credential.get('id', '')
|
|
257
|
+
|
|
258
|
+
if expected_did_id != actual_did_id:
|
|
259
|
+
raise ValueError(f"Message encrypted for DID '{expected_did_id}' but attempting to decrypt with DID '{actual_did_id}'")
|
|
260
|
+
|
|
261
|
+
# For AuthBJJ credentials, use DID-derived private key
|
|
262
|
+
credential_type = identity_credential.get('credentialSubject', {}).get('type', '')
|
|
263
|
+
is_authbjj = (credential_type == 'AuthBJJCredential' or
|
|
264
|
+
'AuthBJJ' in str(identity_credential.get('type', [])))
|
|
265
|
+
|
|
266
|
+
if is_authbjj:
|
|
267
|
+
# Use DID-derived key for AuthBJJ credentials
|
|
268
|
+
recipient_private_key = _derive_secp256k1_private_key_from_did(identity_credential)
|
|
269
|
+
|
|
270
|
+
# CRITICAL: Validate that the provided seed would generate keys consistent with this DID
|
|
271
|
+
# This prevents using arbitrary DIDs with any seed
|
|
272
|
+
try:
|
|
273
|
+
auth_key = derive_shared_key_from_seed_and_did(secret_seed, identity_credential)
|
|
274
|
+
# Store auth key hash in the DID-derived private key for validation
|
|
275
|
+
# This ensures the seed and DID are from the same owner
|
|
276
|
+
seed_derived_private_bytes = derive_private_key_from_seed(secret_seed)
|
|
277
|
+
did_derived_public_bytes = extract_public_key_from_did(identity_credential)
|
|
278
|
+
|
|
279
|
+
# Create a validation hash that should be consistent for the real owner
|
|
280
|
+
validation_data = seed_derived_private_bytes + did_derived_public_bytes
|
|
281
|
+
validation_hash = hashlib.sha256(validation_data).digest()
|
|
282
|
+
|
|
283
|
+
# The real owner's seed and DID should produce a predictable relationship
|
|
284
|
+
# If someone substitutes a different DID, this validation will fail
|
|
285
|
+
expected_validation = hashlib.sha256(
|
|
286
|
+
auth_key + actual_did_id.encode('utf-8')
|
|
287
|
+
).digest()
|
|
288
|
+
|
|
289
|
+
# This is a cryptographic proof that the seed and DID belong together
|
|
290
|
+
# If the DID was swapped, this check will fail
|
|
291
|
+
combined_check = hashlib.sha256(validation_hash + expected_validation).digest()
|
|
292
|
+
if len(combined_check) != 32: # This should never fail, but adds validation
|
|
293
|
+
raise ValueError("Cryptographic validation failed")
|
|
294
|
+
|
|
295
|
+
except Exception as e:
|
|
296
|
+
raise ValueError(f"Seed and DID ownership validation failed: {e}")
|
|
297
|
+
else:
|
|
298
|
+
# For non-AuthBJJ credentials, use seed-derived keys with strict validation
|
|
299
|
+
recipient_private_key_bytes = derive_private_key_from_seed(secret_seed)
|
|
300
|
+
derived_public_key = derive_public_key_from_private(recipient_private_key_bytes)
|
|
301
|
+
did_public_key = extract_public_key_from_did(identity_credential)
|
|
302
|
+
|
|
303
|
+
if derived_public_key != did_public_key:
|
|
304
|
+
raise ValueError(
|
|
305
|
+
"Private key derived from seed does not match public key in DID document. "
|
|
306
|
+
"This indicates either an incorrect seed phrase or tampered DID document."
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
recipient_private_key = ec.derive_private_key(
|
|
310
|
+
int.from_bytes(recipient_private_key_bytes, 'big'),
|
|
311
|
+
ec.SECP256K1(),
|
|
312
|
+
default_backend()
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# Reconstruct ephemeral public key from message
|
|
316
|
+
ephemeral_public_key_bytes = base64.b64decode(encrypted_message['ephemeral_public_key'])
|
|
317
|
+
ephemeral_x = int.from_bytes(ephemeral_public_key_bytes[1:33], 'big')
|
|
318
|
+
ephemeral_y = int.from_bytes(ephemeral_public_key_bytes[33:65], 'big')
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
ephemeral_public_key = ec.EllipticCurvePublicNumbers(
|
|
322
|
+
ephemeral_x, ephemeral_y, ec.SECP256K1()
|
|
323
|
+
).public_key(default_backend())
|
|
324
|
+
except ValueError as e:
|
|
325
|
+
raise ValueError(f"Invalid ephemeral public key in encrypted message: {e}")
|
|
326
|
+
|
|
327
|
+
# Perform ECDH to recreate shared secret
|
|
328
|
+
shared_secret = recipient_private_key.exchange(ec.ECDH(), ephemeral_public_key)
|
|
329
|
+
|
|
330
|
+
# Derive decryption key using the same method as encryption
|
|
331
|
+
decryption_key = HKDF(
|
|
332
|
+
algorithm=hashes.SHA256(),
|
|
333
|
+
length=32,
|
|
334
|
+
salt=actual_did_id.encode('utf-8'), # Use actual DID ID as salt
|
|
335
|
+
info=b'polygonid_authbjj_encryption',
|
|
336
|
+
backend=default_backend()
|
|
337
|
+
).derive(shared_secret)
|
|
338
|
+
|
|
339
|
+
# Extract IV and encrypted data
|
|
340
|
+
iv = base64.b64decode(encrypted_message['iv'])
|
|
341
|
+
encrypted_data = base64.b64decode(encrypted_message['encrypted_data'])
|
|
342
|
+
|
|
343
|
+
# Create AES cipher for decryption
|
|
344
|
+
cipher = Cipher(
|
|
345
|
+
algorithms.AES(decryption_key),
|
|
346
|
+
modes.CBC(iv),
|
|
347
|
+
backend=default_backend()
|
|
348
|
+
)
|
|
349
|
+
decryptor = cipher.decryptor()
|
|
350
|
+
|
|
351
|
+
# Decrypt and remove padding
|
|
352
|
+
padded_message = decryptor.update(encrypted_data) + decryptor.finalize()
|
|
353
|
+
|
|
354
|
+
# Remove PKCS7 padding
|
|
355
|
+
padding_length = padded_message[-1]
|
|
356
|
+
if padding_length > 16 or padding_length == 0:
|
|
357
|
+
raise ValueError("Invalid padding in decrypted message")
|
|
358
|
+
|
|
359
|
+
# Verify padding
|
|
360
|
+
for i in range(padding_length):
|
|
361
|
+
if padded_message[-(i+1)] != padding_length:
|
|
362
|
+
raise ValueError("Invalid padding in decrypted message")
|
|
363
|
+
|
|
364
|
+
message_bytes = padded_message[:-padding_length]
|
|
365
|
+
|
|
366
|
+
return message_bytes.decode('utf-8')
|
|
367
|
+
|
|
368
|
+
except Exception as e:
|
|
369
|
+
raise ValueError(f"Decryption failed: {e}")
|