encode-toolkit 0.3.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.
@@ -0,0 +1,4 @@
1
+ """ENCODE Project connector - MCP server and Python client."""
2
+
3
+ __version__ = "0.2.1"
4
+ __author__ = "Dr. Alex M. Mawla, PhD"
@@ -0,0 +1,5 @@
1
+ """Allow running as: python -m encode_connector"""
2
+
3
+ from encode_connector.server.main import main
4
+
5
+ main()
@@ -0,0 +1,6 @@
1
+ """ENCODE Project Python client library."""
2
+
3
+ from encode_connector.client.auth import CredentialManager
4
+ from encode_connector.client.encode_client import EncodeClient
5
+
6
+ __all__ = ["EncodeClient", "CredentialManager"]
@@ -0,0 +1,262 @@
1
+ """Secure credential management for ENCODE API authentication.
2
+
3
+ Credentials are stored in the OS keyring (macOS Keychain, Linux Secret Service,
4
+ Windows Credential Locker). Falls back to Fernet-encrypted file storage when
5
+ keyring is unavailable.
6
+
7
+ Credentials never appear in logs, error messages, or are sent anywhere
8
+ except the ENCODE API over HTTPS.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ import hashlib
15
+ import logging
16
+ import os
17
+ import platform
18
+ from pathlib import Path
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def _get_machine_key(salt_path: Path | None = None) -> bytes:
24
+ """Derive a machine-specific encryption key for fallback file storage.
25
+
26
+ Uses PBKDF2 with a random salt stored alongside the credentials file.
27
+ The salt is generated once and reused for subsequent key derivations.
28
+ """
29
+ if salt_path is None:
30
+ salt_path = Path.home() / ".encode_connector" / ".salt"
31
+
32
+ # Generate or read the salt
33
+ if salt_path.exists():
34
+ salt = salt_path.read_bytes()
35
+ else:
36
+ salt_path.parent.mkdir(parents=True, exist_ok=True)
37
+ salt = os.urandom(32)
38
+ salt_path.write_bytes(salt)
39
+ salt_path.chmod(0o600)
40
+
41
+ # Combine machine-specific values as key material
42
+ import getpass
43
+
44
+ try:
45
+ login = getpass.getuser()
46
+ except (KeyError, OSError):
47
+ # getpass.getuser() raises KeyError in containers/CI where the user
48
+ # is not in the password database, or OSError in restricted environments
49
+ login = os.environ.get("USER", os.environ.get("USERNAME", "encode-user"))
50
+ material = f"{platform.node()}-{login}-encode-connector"
51
+
52
+ # Use PBKDF2 with 600,000 iterations (OWASP recommendation)
53
+ dk = hashlib.pbkdf2_hmac("sha256", material.encode(), salt, 600_000, dklen=32)
54
+ return base64.urlsafe_b64encode(dk)
55
+
56
+
57
+ class CredentialManager:
58
+ """Manages ENCODE API credentials with secure storage.
59
+
60
+ Storage priority:
61
+ 1. OS keyring (macOS Keychain, Linux Secret Service, Windows Credential Locker)
62
+ 2. Fernet-encrypted file at ~/.encode_connector/credentials.enc
63
+ 3. Environment variables (read-only, for initial setup)
64
+ """
65
+
66
+ SERVICE_NAME = "encode-connector"
67
+ _fallback_dir = Path.home() / ".encode_connector"
68
+ _fallback_file = _fallback_dir / "credentials.enc"
69
+
70
+ def __init__(self) -> None:
71
+ self._access_key: str | None = None
72
+ self._secret_key: str | None = None
73
+ self._keyring_available: bool | None = None
74
+
75
+ def _check_keyring(self) -> bool:
76
+ """Check if OS keyring is available."""
77
+ if self._keyring_available is not None:
78
+ return self._keyring_available
79
+ try:
80
+ import keyring
81
+ from keyring.errors import NoKeyringError
82
+
83
+ try:
84
+ # Test keyring access
85
+ keyring.get_password(self.SERVICE_NAME, "__test__")
86
+ self._keyring_available = True
87
+ except NoKeyringError:
88
+ self._keyring_available = False
89
+ except Exception:
90
+ self._keyring_available = False
91
+ except ImportError:
92
+ self._keyring_available = False
93
+
94
+ return self._keyring_available
95
+
96
+ def _read_from_keyring(self) -> tuple[str | None, str | None]:
97
+ """Read credentials from OS keyring."""
98
+ if not self._check_keyring():
99
+ return None, None
100
+ try:
101
+ import keyring
102
+
103
+ access_key = keyring.get_password(self.SERVICE_NAME, "access_key")
104
+ secret_key = keyring.get_password(self.SERVICE_NAME, "secret_key")
105
+ return access_key, secret_key
106
+ except Exception:
107
+ return None, None
108
+
109
+ def _write_to_keyring(self, access_key: str, secret_key: str) -> bool:
110
+ """Store credentials in OS keyring. Returns True on success."""
111
+ if not self._check_keyring():
112
+ return False
113
+ try:
114
+ import keyring
115
+
116
+ keyring.set_password(self.SERVICE_NAME, "access_key", access_key)
117
+ keyring.set_password(self.SERVICE_NAME, "secret_key", secret_key)
118
+ logger.info("Credentials stored in OS keyring")
119
+ return True
120
+ except Exception as e:
121
+ logger.warning("Failed to store credentials in keyring: %s", type(e).__name__)
122
+ return False
123
+
124
+ def _read_from_encrypted_file(self) -> tuple[str | None, str | None]:
125
+ """Read credentials from Fernet-encrypted file."""
126
+ if not self._fallback_file.exists():
127
+ return None, None
128
+ try:
129
+ from cryptography.fernet import Fernet
130
+
131
+ key = _get_machine_key()
132
+ f = Fernet(key)
133
+ data = f.decrypt(self._fallback_file.read_bytes()).decode()
134
+ parts = data.split("\n", 1)
135
+ if len(parts) == 2:
136
+ return parts[0], parts[1]
137
+ except Exception:
138
+ logger.warning("Failed to decrypt credential file")
139
+ return None, None
140
+
141
+ def _write_to_encrypted_file(self, access_key: str, secret_key: str) -> bool:
142
+ """Store credentials in Fernet-encrypted file."""
143
+ try:
144
+ from cryptography.fernet import Fernet
145
+
146
+ self._fallback_dir.mkdir(parents=True, exist_ok=True)
147
+ # Restrict directory permissions
148
+ self._fallback_dir.chmod(0o700)
149
+
150
+ key = _get_machine_key()
151
+ f = Fernet(key)
152
+ data = f"{access_key}\n{secret_key}"
153
+ encrypted = f.encrypt(data.encode())
154
+ self._fallback_file.write_bytes(encrypted)
155
+ # Restrict file permissions
156
+ self._fallback_file.chmod(0o600)
157
+ logger.info("Credentials stored in encrypted file")
158
+ return True
159
+ except Exception as e:
160
+ logger.warning("Failed to write encrypted credential file: %s", type(e).__name__)
161
+ return False
162
+
163
+ def _read_from_env(self) -> tuple[str | None, str | None]:
164
+ """Read credentials from environment variables."""
165
+ access_key = os.environ.get("ENCODE_ACCESS_KEY")
166
+ secret_key = os.environ.get("ENCODE_SECRET_KEY")
167
+ if access_key and secret_key:
168
+ return access_key, secret_key
169
+ return None, None
170
+
171
+ def get_credentials(self) -> tuple[str | None, str | None]:
172
+ """Get ENCODE API credentials from the most secure available source.
173
+
174
+ Checks in order: cache -> keyring -> encrypted file -> env vars.
175
+ If found in env vars, migrates to keyring/encrypted file for future use.
176
+
177
+ Returns:
178
+ Tuple of (access_key, secret_key), both None if no credentials found.
179
+ """
180
+ # Check cache first
181
+ if self._access_key and self._secret_key:
182
+ return self._access_key, self._secret_key
183
+
184
+ # Try keyring
185
+ access_key, secret_key = self._read_from_keyring()
186
+ if access_key and secret_key:
187
+ self._access_key = access_key
188
+ self._secret_key = secret_key
189
+ return access_key, secret_key
190
+
191
+ # Try encrypted file
192
+ access_key, secret_key = self._read_from_encrypted_file()
193
+ if access_key and secret_key:
194
+ self._access_key = access_key
195
+ self._secret_key = secret_key
196
+ return access_key, secret_key
197
+
198
+ # Try env vars (and migrate to secure storage)
199
+ access_key, secret_key = self._read_from_env()
200
+ if access_key and secret_key:
201
+ self._access_key = access_key
202
+ self._secret_key = secret_key
203
+ # Migrate to secure storage
204
+ self.store_credentials(access_key, secret_key)
205
+ return access_key, secret_key
206
+
207
+ return None, None
208
+
209
+ def store_credentials(self, access_key: str, secret_key: str) -> str:
210
+ """Store credentials in the most secure available storage.
211
+
212
+ Returns:
213
+ Description of where credentials were stored.
214
+ """
215
+ self._access_key = access_key
216
+ self._secret_key = secret_key
217
+
218
+ if self._write_to_keyring(access_key, secret_key):
219
+ return "OS keyring (macOS Keychain / Linux Secret Service / Windows Credential Locker)"
220
+
221
+ if self._write_to_encrypted_file(access_key, secret_key):
222
+ return f"Encrypted file ({self._fallback_file})"
223
+
224
+ return "Memory only (credentials will not persist across sessions)"
225
+
226
+ def clear_credentials(self) -> None:
227
+ """Remove all stored credentials."""
228
+ self._access_key = None
229
+ self._secret_key = None
230
+
231
+ # Clear keyring
232
+ if self._check_keyring():
233
+ try:
234
+ import keyring
235
+
236
+ keyring.delete_password(self.SERVICE_NAME, "access_key")
237
+ keyring.delete_password(self.SERVICE_NAME, "secret_key")
238
+ except Exception:
239
+ pass
240
+
241
+ # Clear encrypted file
242
+ if self._fallback_file.exists():
243
+ self._fallback_file.unlink()
244
+
245
+ @property
246
+ def has_credentials(self) -> bool:
247
+ """Check if credentials are available without revealing them."""
248
+ access_key, secret_key = self.get_credentials()
249
+ return bool(access_key and secret_key)
250
+
251
+ def get_auth_header(self) -> dict[str, str] | None:
252
+ """Get HTTP Basic auth header for ENCODE API.
253
+
254
+ Returns:
255
+ Dict with Authorization header, or None if no credentials.
256
+ """
257
+ access_key, secret_key = self.get_credentials()
258
+ if not access_key or not secret_key:
259
+ return None
260
+
261
+ token = base64.b64encode(f"{access_key}:{secret_key}".encode()).decode()
262
+ return {"Authorization": f"Basic {token}"}