evolvingmachines-evolve 0.0.55.dev1355__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.
@@ -0,0 +1,265 @@
1
+ """Standalone browser credentials client."""
2
+
3
+ import asyncio
4
+ import base64
5
+ import hashlib
6
+ import os
7
+ import secrets
8
+ import urllib.parse
9
+ from dataclasses import dataclass
10
+ from typing import Any, Dict, List, Optional, Tuple
11
+
12
+ from . import _http
13
+ from .config import BrowserCredentialsClientConfig
14
+
15
+
16
+ DEFAULT_DASHBOARD_URL = 'https://dashboard.evolvingmachines.ai'
17
+ BROWSER_AUTH_ALGORITHM = 'RSA-OAEP-256'
18
+
19
+
20
+ @dataclass
21
+ class BrowserCredentialMetadata:
22
+ id: str
23
+ website: str
24
+ account_label: str
25
+ email: str
26
+ enabled: bool
27
+ created_by: str
28
+ created_at: str
29
+ updated_at: str
30
+ last_used_at: Optional[str]
31
+
32
+
33
+ @dataclass
34
+ class BrowserCredentialsPage:
35
+ credentials: List[BrowserCredentialMetadata]
36
+ total: int
37
+ count: int
38
+ offset: int
39
+ has_more: bool
40
+
41
+
42
+ def _metadata_from_dict(data: Dict[str, Any]) -> BrowserCredentialMetadata:
43
+ return BrowserCredentialMetadata(
44
+ id=data['id'],
45
+ website=data['website'],
46
+ account_label=data['account_label'],
47
+ email=data['email'],
48
+ enabled=bool(data.get('enabled', True)),
49
+ created_by=data.get('createdBy') or data.get('created_by') or 'user',
50
+ created_at=data.get('createdAt') or data.get('created_at') or '',
51
+ updated_at=data.get('updatedAt') or data.get('updated_at') or '',
52
+ last_used_at=data.get('lastUsedAt') or data.get('last_used_at'),
53
+ )
54
+
55
+
56
+ class BrowserCredentialsClient:
57
+ """List, create, and delete saved browser logins without returning passwords."""
58
+
59
+ def __init__(
60
+ self,
61
+ config: Optional[BrowserCredentialsClientConfig] = None,
62
+ ):
63
+ self.config = config or BrowserCredentialsClientConfig()
64
+
65
+ async def __aenter__(self):
66
+ return self
67
+
68
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
69
+ await self.close()
70
+
71
+ async def close(self):
72
+ return None
73
+
74
+ async def list(
75
+ self,
76
+ website: Optional[str] = None,
77
+ limit: Optional[int] = None,
78
+ offset: Optional[int] = None,
79
+ ) -> BrowserCredentialsPage:
80
+ params = {}
81
+ if website is not None:
82
+ params['website'] = website
83
+ if limit is not None:
84
+ params['limit'] = str(limit)
85
+ if offset is not None:
86
+ params['offset'] = str(offset)
87
+ query = urllib.parse.urlencode(params)
88
+ result = await self._request_json(f'/api/browser-credentials{("?" + query) if query else ""}')
89
+ credentials = [_metadata_from_dict(item) for item in result.get('credentials', [])]
90
+ return BrowserCredentialsPage(
91
+ credentials=credentials,
92
+ total=int(result.get('total', len(credentials))),
93
+ count=int(result.get('count', len(credentials))),
94
+ offset=int(result.get('offset', offset or 0)),
95
+ has_more=bool(result.get('hasMore', result.get('has_more', False))),
96
+ )
97
+
98
+ async def create(
99
+ self,
100
+ *,
101
+ website: str,
102
+ account_label: str,
103
+ email: str,
104
+ password: str,
105
+ ) -> Dict[str, Any]:
106
+ encrypted_password = await self._encrypt_password(password)
107
+ result = await self._request_json('/api/browser-credentials', method='POST', body={
108
+ 'website': website,
109
+ 'account_label': account_label,
110
+ 'email': email,
111
+ 'encryptedPassword': encrypted_password,
112
+ })
113
+ return {
114
+ 'status': result['status'],
115
+ 'credential': _metadata_from_dict(result['credential']),
116
+ }
117
+
118
+ async def delete(
119
+ self,
120
+ *,
121
+ id: Optional[str] = None,
122
+ website: Optional[str] = None,
123
+ account_label: Optional[str] = None,
124
+ ) -> Dict[str, bool]:
125
+ if id:
126
+ body = {'id': id}
127
+ elif website and account_label:
128
+ body = {'website': website, 'account_label': account_label}
129
+ else:
130
+ raise ValueError('delete requires either id or website and account_label')
131
+ return await self._request_json('/api/browser-credentials', method='DELETE', body=body)
132
+
133
+ async def _encrypt_password(self, password: str) -> Dict[str, str]:
134
+ key = await self._request_json('/api/browser-credentials/public-key')
135
+ if key.get('algorithm') != BROWSER_AUTH_ALGORITHM:
136
+ raise ValueError('Unsupported browser credential encryption algorithm')
137
+ ciphertext = _rsa_oaep_sha256_encrypt(key['publicKey'], password.encode('utf-8'))
138
+ return {
139
+ 'algorithm': BROWSER_AUTH_ALGORITHM,
140
+ 'keyId': key['id'],
141
+ 'ciphertext': _base64url(ciphertext),
142
+ }
143
+
144
+ async def _request_json(
145
+ self,
146
+ path: str,
147
+ method: str = 'GET',
148
+ body: Optional[Dict[str, Any]] = None,
149
+ ) -> Dict[str, Any]:
150
+ return await asyncio.to_thread(
151
+ _http.request_json,
152
+ f'{_dashboard_base_url(self.config)}{path}',
153
+ api_key=_resolve_api_key(self.config),
154
+ error_prefix='Browser credentials',
155
+ method=method,
156
+ body=body,
157
+ )
158
+
159
+
160
+ def _dashboard_base_url(config: BrowserCredentialsClientConfig) -> str:
161
+ return (config.dashboard_url or os.environ.get('EVOLVE_DASHBOARD_URL') or DEFAULT_DASHBOARD_URL).rstrip('/')
162
+
163
+
164
+ def _resolve_api_key(config: BrowserCredentialsClientConfig) -> str:
165
+ api_key = config.api_key or os.environ.get('EVOLVE_API_KEY')
166
+ if not api_key:
167
+ raise ValueError('Browser credentials require EVOLVE_API_KEY or BrowserCredentialsClientConfig(api_key=...)')
168
+ return api_key
169
+
170
+
171
+ def _base64url(data: bytes) -> str:
172
+ return base64.urlsafe_b64encode(data).decode('ascii').rstrip('=')
173
+
174
+
175
+ def _read_der_length(data: bytes, offset: int) -> Tuple[int, int]:
176
+ first = data[offset]
177
+ offset += 1
178
+ if first < 0x80:
179
+ return first, offset
180
+ count = first & 0x7F
181
+ if count == 0 or count > 4:
182
+ raise ValueError('Unsupported DER length')
183
+ length = int.from_bytes(data[offset:offset + count], 'big')
184
+ return length, offset + count
185
+
186
+
187
+ def _read_der_tlv(data: bytes, offset: int, expected_tag: int) -> Tuple[bytes, int]:
188
+ if offset >= len(data) or data[offset] != expected_tag:
189
+ raise ValueError('Unexpected public key format')
190
+ length, value_start = _read_der_length(data, offset + 1)
191
+ value_end = value_start + length
192
+ if value_end > len(data):
193
+ raise ValueError('Invalid public key length')
194
+ return data[value_start:value_end], value_end
195
+
196
+
197
+ # DER encoding of OID 1.2.840.113549.1.1.1 (rsaEncryption), the only
198
+ # SubjectPublicKeyInfo algorithm this encryptor may read: the BIT STRING that
199
+ # follows is parsed as an RSAPublicKey, and under any other algorithm those
200
+ # bytes mean something else entirely.
201
+ _RSA_ENCRYPTION_OID = b'\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01'
202
+
203
+
204
+ def _parse_rsa_public_key(pem: str) -> Tuple[int, int, int]:
205
+ body = ''.join(
206
+ line.strip()
207
+ for line in pem.splitlines()
208
+ if line and not line.startswith('-----')
209
+ )
210
+ der = base64.b64decode(body)
211
+ spki, offset = _read_der_tlv(der, 0, 0x30)
212
+ if offset != len(der):
213
+ raise ValueError('Unexpected trailing public key data')
214
+ algorithm, offset = _read_der_tlv(spki, 0, 0x30)
215
+ oid, _ = _read_der_tlv(algorithm, 0, 0x06)
216
+ if oid != _RSA_ENCRYPTION_OID:
217
+ raise ValueError('Public key algorithm is not rsaEncryption')
218
+ bit_string, offset = _read_der_tlv(spki, offset, 0x03)
219
+ if offset != len(spki) or not bit_string or bit_string[0] != 0:
220
+ raise ValueError('Invalid RSA public key')
221
+ rsa_key, offset = _read_der_tlv(bit_string[1:], 0, 0x30)
222
+ if offset != len(bit_string) - 1:
223
+ raise ValueError('Unexpected RSA key data')
224
+ modulus_bytes, offset = _read_der_tlv(rsa_key, 0, 0x02)
225
+ exponent_bytes, offset = _read_der_tlv(rsa_key, offset, 0x02)
226
+ if offset != len(rsa_key):
227
+ raise ValueError('Unexpected RSA integer data')
228
+ n = int.from_bytes(modulus_bytes.lstrip(b'\x00'), 'big')
229
+ e = int.from_bytes(exponent_bytes, 'big')
230
+ k = (n.bit_length() + 7) // 8
231
+ return n, e, k
232
+
233
+
234
+ def _mgf1(seed: bytes, length: int) -> bytes:
235
+ output = bytearray()
236
+ counter = 0
237
+ while len(output) < length:
238
+ output.extend(hashlib.sha256(seed + counter.to_bytes(4, 'big')).digest())
239
+ counter += 1
240
+ return bytes(output[:length])
241
+
242
+
243
+ def _xor_bytes(left: bytes, right: bytes) -> bytes:
244
+ # zip() silently truncates to the shorter operand, and a truncated XOR in
245
+ # OAEP is a malformed encoding, not an error anyone sees — refuse instead.
246
+ if len(left) != len(right):
247
+ raise ValueError('XOR operands must be the same length')
248
+ return bytes(a ^ b for a, b in zip(left, right))
249
+
250
+
251
+ def _rsa_oaep_sha256_encrypt(public_key_pem: str, plaintext: bytes) -> bytes:
252
+ n, e, k = _parse_rsa_public_key(public_key_pem)
253
+ h_len = hashlib.sha256().digest_size
254
+ if len(plaintext) > k - 2 * h_len - 2:
255
+ raise ValueError('Browser credential password is too long for RSA-OAEP-256')
256
+
257
+ label_hash = hashlib.sha256(b'').digest()
258
+ padding = b'\x00' * (k - len(plaintext) - 2 * h_len - 2)
259
+ data_block = label_hash + padding + b'\x01' + plaintext
260
+ seed = secrets.token_bytes(h_len)
261
+ masked_data_block = _xor_bytes(data_block, _mgf1(seed, k - h_len - 1))
262
+ masked_seed = _xor_bytes(seed, _mgf1(masked_data_block, h_len))
263
+ encoded = b'\x00' + masked_seed + masked_data_block
264
+ cipher_int = pow(int.from_bytes(encoded, 'big'), e, n)
265
+ return cipher_int.to_bytes(k, 'big')
@@ -0,0 +1,95 @@
1
+ """Standalone browser profiles client."""
2
+
3
+ import asyncio
4
+ import os
5
+ from dataclasses import dataclass
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ from . import _http
9
+ from .config import BrowserProfilesClientConfig
10
+
11
+
12
+ DEFAULT_DASHBOARD_URL = 'https://dashboard.evolvingmachines.ai'
13
+
14
+
15
+ @dataclass
16
+ class BrowserProfileMetadata:
17
+ id: str
18
+ profile: str
19
+ created_at: str
20
+ updated_at: str
21
+ last_used_at: Optional[str]
22
+
23
+
24
+ @dataclass
25
+ class BrowserProfilesPage:
26
+ profiles: List[BrowserProfileMetadata]
27
+
28
+
29
+ def _metadata_from_dict(data: Dict[str, Any]) -> BrowserProfileMetadata:
30
+ return BrowserProfileMetadata(
31
+ id=data['id'],
32
+ profile=data['profile'],
33
+ created_at=data.get('createdAt') or data.get('created_at') or '',
34
+ updated_at=data.get('updatedAt') or data.get('updated_at') or '',
35
+ last_used_at=data.get('lastUsedAt') or data.get('last_used_at'),
36
+ )
37
+
38
+
39
+ class BrowserProfilesClient:
40
+ """List and delete reusable browser profiles."""
41
+
42
+ def __init__(
43
+ self,
44
+ config: Optional[BrowserProfilesClientConfig] = None,
45
+ ):
46
+ self.config = config or BrowserProfilesClientConfig()
47
+
48
+ async def __aenter__(self):
49
+ return self
50
+
51
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
52
+ await self.close()
53
+
54
+ async def close(self):
55
+ return None
56
+
57
+ async def list(self) -> BrowserProfilesPage:
58
+ result = await self._request_json('/api/browser-profiles')
59
+ return BrowserProfilesPage(
60
+ profiles=[_metadata_from_dict(item) for item in result.get('profiles', [])],
61
+ )
62
+
63
+ async def delete(
64
+ self,
65
+ *,
66
+ profile: str,
67
+ ) -> Dict[str, bool]:
68
+ body: Dict[str, Any] = {'profile': profile}
69
+ return await self._request_json('/api/browser-profiles', method='DELETE', body=body)
70
+
71
+ async def _request_json(
72
+ self,
73
+ path: str,
74
+ method: str = 'GET',
75
+ body: Optional[Dict[str, Any]] = None,
76
+ ) -> Dict[str, Any]:
77
+ return await asyncio.to_thread(
78
+ _http.request_json,
79
+ f'{_dashboard_base_url(self.config)}{path}',
80
+ api_key=_resolve_api_key(self.config),
81
+ error_prefix='Browser profiles',
82
+ method=method,
83
+ body=body,
84
+ )
85
+
86
+
87
+ def _dashboard_base_url(config: BrowserProfilesClientConfig) -> str:
88
+ return (config.dashboard_url or os.environ.get('EVOLVE_DASHBOARD_URL') or DEFAULT_DASHBOARD_URL).rstrip('/')
89
+
90
+
91
+ def _resolve_api_key(config: BrowserProfilesClientConfig) -> str:
92
+ api_key = config.api_key or os.environ.get('EVOLVE_API_KEY')
93
+ if not api_key:
94
+ raise ValueError('Browser profiles require EVOLVE_API_KEY or BrowserProfilesClientConfig(api_key=...)')
95
+ return api_key