repr-cli 0.1.0__py3-none-any.whl → 0.2.1__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.
repr/keychain.py ADDED
@@ -0,0 +1,255 @@
1
+ """
2
+ Secure credential storage using OS keychain.
3
+
4
+ Supports:
5
+ - macOS: Keychain
6
+ - Windows: Credential Manager
7
+ - Linux: Secret Service (libsecret)
8
+
9
+ Falls back to encrypted file storage if keychain is unavailable.
10
+ """
11
+
12
+ import base64
13
+ import hashlib
14
+ import json
15
+ import os
16
+ import secrets
17
+ import tempfile
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ # Service name for keychain entries
22
+ SERVICE_NAME = "repr-cli"
23
+
24
+ # Fallback storage location
25
+ REPR_HOME = Path(os.getenv("REPR_HOME", Path.home() / ".repr"))
26
+ SECRETS_FILE = REPR_HOME / ".secrets.enc"
27
+ KEY_FILE = REPR_HOME / ".secrets.key"
28
+
29
+ # Try to import keyring
30
+ _keyring_available = False
31
+ try:
32
+ import keyring
33
+ from keyring.errors import KeyringError, PasswordDeleteError
34
+ _keyring_available = True
35
+ except ImportError:
36
+ keyring = None
37
+ KeyringError = Exception
38
+ PasswordDeleteError = Exception
39
+
40
+
41
+ def is_keyring_available() -> bool:
42
+ """Check if OS keychain is available and functional."""
43
+ if not _keyring_available:
44
+ return False
45
+
46
+ try:
47
+ # Test keychain access with a dummy operation
48
+ test_key = f"repr_keychain_test_{secrets.token_hex(4)}"
49
+ keyring.set_password(SERVICE_NAME, test_key, "test")
50
+ result = keyring.get_password(SERVICE_NAME, test_key)
51
+ keyring.delete_password(SERVICE_NAME, test_key)
52
+ return result == "test"
53
+ except Exception:
54
+ return False
55
+
56
+
57
+ def _get_encryption_key() -> bytes:
58
+ """Get or generate encryption key for fallback storage."""
59
+ REPR_HOME.mkdir(parents=True, exist_ok=True)
60
+
61
+ if KEY_FILE.exists():
62
+ return base64.b64decode(KEY_FILE.read_text().strip())
63
+
64
+ # Generate new key
65
+ key = secrets.token_bytes(32)
66
+ KEY_FILE.write_text(base64.b64encode(key).decode())
67
+ KEY_FILE.chmod(0o600) # Owner read/write only
68
+ return key
69
+
70
+
71
+ def _simple_encrypt(data: str, key: bytes) -> str:
72
+ """Simple XOR encryption with key derivation.
73
+
74
+ Note: This is NOT cryptographically secure for serious use.
75
+ It's a fallback when keychain is unavailable.
76
+ For production, consider using cryptography library.
77
+ """
78
+ # Derive a longer key using hash
79
+ derived = hashlib.sha256(key).digest()
80
+ data_bytes = data.encode('utf-8')
81
+
82
+ # XOR with repeating key
83
+ encrypted = bytes(
84
+ b ^ derived[i % len(derived)]
85
+ for i, b in enumerate(data_bytes)
86
+ )
87
+ return base64.b64encode(encrypted).decode()
88
+
89
+
90
+ def _simple_decrypt(encrypted: str, key: bytes) -> str:
91
+ """Decrypt data encrypted with _simple_encrypt."""
92
+ derived = hashlib.sha256(key).digest()
93
+ encrypted_bytes = base64.b64decode(encrypted)
94
+
95
+ decrypted = bytes(
96
+ b ^ derived[i % len(derived)]
97
+ for i, b in enumerate(encrypted_bytes)
98
+ )
99
+ return decrypted.decode('utf-8')
100
+
101
+
102
+ def _load_fallback_secrets() -> dict[str, str]:
103
+ """Load secrets from fallback encrypted file."""
104
+ if not SECRETS_FILE.exists():
105
+ return {}
106
+
107
+ try:
108
+ key = _get_encryption_key()
109
+ encrypted = SECRETS_FILE.read_text().strip()
110
+ decrypted = _simple_decrypt(encrypted, key)
111
+ return json.loads(decrypted)
112
+ except Exception:
113
+ return {}
114
+
115
+
116
+ def _save_fallback_secrets(secrets_dict: dict[str, str]) -> None:
117
+ """Save secrets to fallback encrypted file."""
118
+ REPR_HOME.mkdir(parents=True, exist_ok=True)
119
+ key = _get_encryption_key()
120
+ encrypted = _simple_encrypt(json.dumps(secrets_dict), key)
121
+
122
+ # Atomic write
123
+ fd, tmp_path = tempfile.mkstemp(dir=REPR_HOME, suffix=".tmp")
124
+ try:
125
+ with os.fdopen(fd, "w") as f:
126
+ f.write(encrypted)
127
+ os.replace(tmp_path, SECRETS_FILE)
128
+ SECRETS_FILE.chmod(0o600)
129
+ except Exception:
130
+ if os.path.exists(tmp_path):
131
+ os.unlink(tmp_path)
132
+ raise
133
+
134
+
135
+ def store_secret(key: str, value: str) -> bool:
136
+ """
137
+ Store a secret in the OS keychain or fallback storage.
138
+
139
+ Args:
140
+ key: Unique identifier for the secret (e.g., 'auth_token', 'byok_openai')
141
+ value: Secret value to store
142
+
143
+ Returns:
144
+ True if stored successfully
145
+ """
146
+ if _keyring_available:
147
+ try:
148
+ keyring.set_password(SERVICE_NAME, key, value)
149
+ return True
150
+ except KeyringError:
151
+ pass
152
+
153
+ # Fallback to encrypted file
154
+ secrets_dict = _load_fallback_secrets()
155
+ secrets_dict[key] = value
156
+ _save_fallback_secrets(secrets_dict)
157
+ return True
158
+
159
+
160
+ def get_secret(key: str) -> str | None:
161
+ """
162
+ Retrieve a secret from the OS keychain or fallback storage.
163
+
164
+ Args:
165
+ key: Unique identifier for the secret
166
+
167
+ Returns:
168
+ Secret value or None if not found
169
+ """
170
+ if _keyring_available:
171
+ try:
172
+ value = keyring.get_password(SERVICE_NAME, key)
173
+ if value is not None:
174
+ return value
175
+ except KeyringError:
176
+ pass
177
+
178
+ # Fallback to encrypted file
179
+ secrets_dict = _load_fallback_secrets()
180
+ return secrets_dict.get(key)
181
+
182
+
183
+ def delete_secret(key: str) -> bool:
184
+ """
185
+ Delete a secret from the OS keychain or fallback storage.
186
+
187
+ Args:
188
+ key: Unique identifier for the secret
189
+
190
+ Returns:
191
+ True if deleted, False if not found
192
+ """
193
+ deleted = False
194
+
195
+ if _keyring_available:
196
+ try:
197
+ keyring.delete_password(SERVICE_NAME, key)
198
+ deleted = True
199
+ except (KeyringError, PasswordDeleteError):
200
+ pass
201
+
202
+ # Also remove from fallback if present
203
+ secrets_dict = _load_fallback_secrets()
204
+ if key in secrets_dict:
205
+ del secrets_dict[key]
206
+ _save_fallback_secrets(secrets_dict)
207
+ deleted = True
208
+
209
+ return deleted
210
+
211
+
212
+ def list_secrets() -> list[str]:
213
+ """
214
+ List all secret keys (not values).
215
+
216
+ Note: OS keychain doesn't support listing, so this only works for fallback storage.
217
+
218
+ Returns:
219
+ List of secret keys
220
+ """
221
+ secrets_dict = _load_fallback_secrets()
222
+ return list(secrets_dict.keys())
223
+
224
+
225
+ def migrate_plaintext_token(plaintext_token: str, key: str = "auth_token") -> bool:
226
+ """
227
+ Migrate a plaintext token to secure storage.
228
+
229
+ Args:
230
+ plaintext_token: The token to migrate
231
+ key: Key to store under (default: 'auth_token')
232
+
233
+ Returns:
234
+ True if migration successful
235
+ """
236
+ return store_secret(key, plaintext_token)
237
+
238
+
239
+ def get_storage_info() -> dict[str, Any]:
240
+ """
241
+ Get information about current storage backend.
242
+
243
+ Returns:
244
+ Dict with storage type and details
245
+ """
246
+ using_keyring = _keyring_available and is_keyring_available()
247
+
248
+ return {
249
+ "backend": "keyring" if using_keyring else "encrypted_file",
250
+ "keyring_available": _keyring_available,
251
+ "keyring_functional": is_keyring_available() if _keyring_available else False,
252
+ "fallback_file": str(SECRETS_FILE) if SECRETS_FILE.exists() else None,
253
+ "service_name": SERVICE_NAME,
254
+ }
255
+