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/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}")