toru-vault 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.
toru_vault/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env python3
2
+ # Import functions from the core module
3
+ from .vault import env_load, env_load_all, get, get_all
4
+
5
+ __all__ = ["env_load", "env_load_all", "get", "get_all"]
toru_vault/__main__.py ADDED
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Command-line interface for the vault package.
4
+ """
5
+ import argparse
6
+ import os
7
+ import sys
8
+ import getpass
9
+
10
+ from .vault import (_initialize_client, set_to_keyring, _get_from_keyring_or_env,
11
+ _KEYRING_BWS_TOKEN_KEY, _KEYRING_ORG_ID_KEY, _KEYRING_STATE_FILE_KEY,
12
+ _KEYRING_AVAILABLE)
13
+
14
+
15
+ def list_projects(organization_id=None):
16
+ """
17
+ List all projects and their IDs for the given organization.
18
+
19
+ Args:
20
+ organization_id (str, optional): Organization ID
21
+
22
+ Returns:
23
+ list: List of projects
24
+ """
25
+ # Check for organization ID
26
+ if not organization_id:
27
+ organization_id = _get_from_keyring_or_env(_KEYRING_ORG_ID_KEY, "ORGANIZATION_ID")
28
+ if not organization_id:
29
+ print("Error: ORGANIZATION_ID not found in keyring or environment variable")
30
+ sys.exit(1)
31
+
32
+ try:
33
+ # Initialize client
34
+ client = _initialize_client()
35
+
36
+ # Get all projects
37
+ projects = client.projects().list(organization_id)
38
+
39
+ if not hasattr(projects, 'data') or not hasattr(projects.data, 'data'):
40
+ print("No projects found or invalid response format")
41
+ return []
42
+
43
+ return projects.data.data
44
+ except Exception as e:
45
+ print(f"Error listing projects: {e}")
46
+ sys.exit(1)
47
+
48
+
49
+ def init_vault():
50
+ """
51
+ Initialize vault by storing BWS_TOKEN, ORGANIZATION_ID, and STATE_FILE in keyring.
52
+
53
+ Returns:
54
+ bool: True if initialization was successful
55
+ """
56
+ # Check if keyring is available
57
+ if not _KEYRING_AVAILABLE:
58
+ print("Error: keyring package is not available. Cannot securely store credentials.")
59
+ return False
60
+
61
+ # Get existing values
62
+ existing_token = _get_from_keyring_or_env(_KEYRING_BWS_TOKEN_KEY, "BWS_TOKEN")
63
+ existing_org_id = _get_from_keyring_or_env(_KEYRING_ORG_ID_KEY, "ORGANIZATION_ID")
64
+ existing_state_file = _get_from_keyring_or_env(_KEYRING_STATE_FILE_KEY, "STATE_FILE")
65
+
66
+ # Suggest current directory for STATE_FILE if not set
67
+ current_dir = os.getcwd()
68
+ suggested_state_file = os.path.join(current_dir, "state")
69
+
70
+ # Ask for BWS_TOKEN or use existing
71
+ if existing_token:
72
+ print(f"Found existing BWS_TOKEN {'in keyring' if _KEYRING_AVAILABLE else 'in environment'}")
73
+ new_token = getpass.getpass("Enter new BWS_TOKEN (leave empty to keep existing): ")
74
+ token = new_token if new_token else existing_token
75
+ else:
76
+ token = getpass.getpass("Enter BWS_TOKEN: ")
77
+ if not token:
78
+ print("Error: BWS_TOKEN is required")
79
+ return False
80
+
81
+ # Ask for ORGANIZATION_ID or use existing
82
+ if existing_org_id:
83
+ print(f"Found existing ORGANIZATION_ID {'in keyring' if _KEYRING_AVAILABLE else 'in environment'}")
84
+ new_org_id = input("Enter new ORGANIZATION_ID (leave empty to keep existing): ")
85
+ org_id = new_org_id if new_org_id else existing_org_id
86
+ else:
87
+ org_id = input("Enter ORGANIZATION_ID: ")
88
+ if not org_id:
89
+ print("Error: ORGANIZATION_ID is required")
90
+ return False
91
+
92
+ # Ask for STATE_FILE or use existing
93
+ if existing_state_file:
94
+ print(f"Found existing STATE_FILE {'in keyring' if _KEYRING_AVAILABLE else 'in environment'}: {existing_state_file}")
95
+ new_state_file = input(f"Enter new STATE_FILE path (leave empty to keep existing, default: {suggested_state_file}): ")
96
+ state_file = new_state_file if new_state_file else existing_state_file
97
+ else:
98
+ state_file = input(f"Enter STATE_FILE path (default: {suggested_state_file}): ")
99
+ if not state_file:
100
+ state_file = suggested_state_file
101
+ print(f"Using default STATE_FILE path: {state_file}")
102
+
103
+ # Store in keyring
104
+ if _KEYRING_AVAILABLE:
105
+ if existing_token != token or not existing_token:
106
+ if set_to_keyring(_KEYRING_BWS_TOKEN_KEY, token):
107
+ print("BWS_TOKEN stored in keyring")
108
+ else:
109
+ print("Failed to store BWS_TOKEN in keyring")
110
+ return False
111
+
112
+ if existing_org_id != org_id or not existing_org_id:
113
+ if set_to_keyring(_KEYRING_ORG_ID_KEY, org_id):
114
+ print("ORGANIZATION_ID stored in keyring")
115
+ else:
116
+ print("Failed to store ORGANIZATION_ID in keyring")
117
+ return False
118
+
119
+ if existing_state_file != state_file or not existing_state_file:
120
+ if set_to_keyring(_KEYRING_STATE_FILE_KEY, state_file):
121
+ print("STATE_FILE stored in keyring")
122
+ else:
123
+ print("Failed to store STATE_FILE in keyring")
124
+ return False
125
+ else:
126
+ # Store in environment variables if keyring is not available
127
+ os.environ["BWS_TOKEN"] = token
128
+ os.environ["ORGANIZATION_ID"] = org_id
129
+ os.environ["STATE_FILE"] = state_file
130
+ print("Credentials stored in environment variables (not persistent)")
131
+
132
+ # Ensure state file directory exists
133
+ state_dir = os.path.dirname(state_file)
134
+ if state_dir and not os.path.exists(state_dir):
135
+ try:
136
+ os.makedirs(state_dir, exist_ok=True)
137
+ print(f"Created directory for STATE_FILE: {state_dir}")
138
+ except Exception as e:
139
+ print(f"Warning: Could not create state directory: {e}")
140
+
141
+ print("\nVault initialization completed successfully")
142
+ return True
143
+
144
+
145
+ def main():
146
+ """
147
+ Main entry point for the command-line interface.
148
+ """
149
+ parser = argparse.ArgumentParser(description="Bitwarden Secret Manager CLI")
150
+ subparsers = parser.add_subparsers(dest="command", help="Command to run")
151
+
152
+ # List command
153
+ list_parser = subparsers.add_parser("list", help="List projects")
154
+ list_parser.add_argument("--org-id", "-o", help="Organization ID")
155
+
156
+ # Init command
157
+ subparsers.add_parser("init", help="Initialize vault with BWS_TOKEN and ORGANIZATION_ID")
158
+
159
+ # Parse arguments
160
+ args = parser.parse_args()
161
+
162
+ # Execute command
163
+ if args.command == "list":
164
+ projects = list_projects(args.org_id)
165
+ if projects:
166
+ print("\nAvailable Projects:")
167
+ print("===================")
168
+ for project in projects:
169
+ print(f"ID: {project.id}")
170
+ print(f"Name: {project.name}")
171
+ print(f"Created: {project.creation_date}")
172
+ print("-" * 50)
173
+ else:
174
+ print("No projects found")
175
+ elif args.command == "init":
176
+ init_vault()
177
+ else:
178
+ parser.print_help()
179
+
180
+
181
+ if __name__ == "__main__":
182
+ main()
@@ -0,0 +1,87 @@
1
+ from collections.abc import MutableMapping
2
+ from typing import Dict, Optional, Callable, Set, Iterator, Tuple
3
+
4
+ class LazySecretsDict(MutableMapping):
5
+ """
6
+ A dictionary-like class that only loads/decrypts secrets when they are accessed.
7
+ When container=True, it uses the existing encryption methods.
8
+ When container=False, it uses OS keyring for secure storage.
9
+ """
10
+
11
+ def __init__(self,
12
+ secret_keys: Set[str],
13
+ getter_func: Callable[[str], str],
14
+ setter_func: Optional[Callable[[str, str], None]] = None,
15
+ deleter_func: Optional[Callable[[str], None]] = None):
16
+ """
17
+ Initialize the lazy dictionary with a list of available keys and functions to retrieve/set/delete values.
18
+
19
+ Args:
20
+ secret_keys: Set of keys that are available in this dictionary
21
+ getter_func: Function that takes a key and returns the secret value
22
+ setter_func: Optional function to set a value for a key
23
+ deleter_func: Optional function to delete a key
24
+ """
25
+ self._keys = secret_keys
26
+ self._getter = getter_func
27
+ self._setter = setter_func
28
+ self._deleter = deleter_func
29
+ self._cache: Dict[str, str] = {}
30
+
31
+ def __getitem__(self, key: str) -> str:
32
+ """Get an item from the dictionary, fetching/decrypting it on first access."""
33
+ if key not in self._keys:
34
+ raise KeyError(key)
35
+
36
+ # If not in cache, fetch it
37
+ if key not in self._cache:
38
+ value = self._getter(key)
39
+ if value is None:
40
+ raise KeyError(f"Failed to retrieve value for key: {key}")
41
+ self._cache[key] = value
42
+
43
+ return self._cache[key]
44
+
45
+ def __setitem__(self, key: str, value: str) -> None:
46
+ """Set an item in the dictionary."""
47
+ if self._setter is None:
48
+ raise NotImplementedError("This dictionary does not support item assignment")
49
+
50
+ self._setter(key, value)
51
+ self._cache[key] = value
52
+ self._keys.add(key)
53
+
54
+ def __delitem__(self, key: str) -> None:
55
+ """Delete an item from the dictionary."""
56
+ if self._deleter is None:
57
+ raise NotImplementedError("This dictionary does not support item deletion")
58
+
59
+ if key not in self._keys:
60
+ raise KeyError(key)
61
+
62
+ self._deleter(key)
63
+ if key in self._cache:
64
+ del self._cache[key]
65
+ self._keys.remove(key)
66
+
67
+ def __iter__(self) -> Iterator[str]:
68
+ """Return an iterator over the keys."""
69
+ return iter(self._keys)
70
+
71
+ def __len__(self) -> int:
72
+ """Return the number of keys."""
73
+ return len(self._keys)
74
+
75
+ def items(self) -> Iterator[Tuple[str, str]]:
76
+ """Return an iterator over (key, value) pairs."""
77
+ for key in self._keys:
78
+ yield (key, self[key])
79
+
80
+ def keys(self) -> Set[str]:
81
+ """Return the set of keys."""
82
+ return self._keys.copy()
83
+
84
+ def values(self) -> Iterator[str]:
85
+ """Return an iterator over values."""
86
+ for key in self._keys:
87
+ yield self[key]
toru_vault/py.typed ADDED
@@ -0,0 +1 @@
1
+
toru_vault/vault.py ADDED
@@ -0,0 +1,532 @@
1
+ #!/usr/bin/env python3
2
+ import os
3
+ import logging
4
+ import time
5
+ import json
6
+ import tempfile
7
+ import stat
8
+ import atexit
9
+ import secrets as pysecrets
10
+ from typing import Dict, Optional, Tuple
11
+ from bitwarden_sdk import BitwardenClient, DeviceType, client_settings_from_dict
12
+ from cryptography.fernet import Fernet
13
+ from cryptography.hazmat.primitives import hashes
14
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
15
+ import base64
16
+ from .lazy_dict import LazySecretsDict
17
+
18
+ # Try importing keyring - it might not be available in container environments
19
+ try:
20
+ import keyring
21
+ _KEYRING_AVAILABLE = True
22
+ except ImportError:
23
+ _KEYRING_AVAILABLE = False
24
+
25
+ # Setup minimal logging
26
+ logger = logging.getLogger(__name__)
27
+
28
+ # Constants for keyring storage
29
+ _KEYRING_SERVICE_NAME = "bitwarden_vault"
30
+ _KEYRING_BWS_TOKEN_KEY = "bws_token"
31
+ _KEYRING_ORG_ID_KEY = "organization_id"
32
+ _KEYRING_STATE_FILE_KEY = "state_file"
33
+
34
+ # Secure cache configuration
35
+ _SECRET_CACHE_TIMEOUT = 300 # 5 minutes
36
+ _secrets_cache: Dict[str, Tuple[float, Dict[str, str]]] = {}
37
+
38
+ def _generate_encryption_key(salt: bytes = None) -> Tuple[bytes, bytes]:
39
+ """
40
+ Generate an encryption key for securing the cache
41
+
42
+ Args:
43
+ salt (bytes, optional): Salt for key derivation
44
+
45
+ Returns:
46
+ Tuple[bytes, bytes]: Key and salt
47
+ """
48
+ if salt is None:
49
+ salt = os.urandom(16)
50
+
51
+ # Generate a key from the machine-specific information and random salt
52
+ machine_id = _get_machine_id()
53
+ password = machine_id.encode()
54
+
55
+ kdf = PBKDF2HMAC(
56
+ algorithm=hashes.SHA256(),
57
+ length=32,
58
+ salt=salt,
59
+ iterations=100000,
60
+ )
61
+ key = base64.urlsafe_b64encode(kdf.derive(password))
62
+ return key, salt
63
+
64
+ def _get_machine_id() -> str:
65
+ """Get a unique identifier for the current machine"""
66
+ # Try platform-specific methods to get a machine ID
67
+ machine_id = ""
68
+
69
+ if os.path.exists('/etc/machine-id'):
70
+ with open('/etc/machine-id', 'r') as f:
71
+ machine_id = f.read().strip()
72
+ elif os.path.exists('/var/lib/dbus/machine-id'):
73
+ with open('/var/lib/dbus/machine-id', 'r') as f:
74
+ machine_id = f.read().strip()
75
+ elif os.name == 'nt': # Windows
76
+ import subprocess
77
+ try:
78
+ result = subprocess.run(['wmic', 'csproduct', 'get', 'UUID'], capture_output=True, text=True)
79
+ if result.returncode == 0:
80
+ machine_id = result.stdout.strip().split('\n')[-1].strip()
81
+ except (FileNotFoundError, subprocess.SubprocessError):
82
+ pass
83
+
84
+ # Fallback if we couldn't get a machine ID
85
+ if not machine_id:
86
+ # Use a combination of hostname and a persisted random value
87
+ import socket
88
+ hostname = socket.gethostname()
89
+
90
+ # Create a persistent random ID
91
+ id_file = os.path.join(tempfile.gettempdir(), '.vault_machine_id')
92
+ if os.path.exists(id_file):
93
+ try:
94
+ with open(id_file, 'r') as f:
95
+ random_id = f.read().strip()
96
+ except Exception:
97
+ random_id = pysecrets.token_hex(16)
98
+ else:
99
+ random_id = pysecrets.token_hex(16)
100
+ try:
101
+ # Try to save it with restricted permissions
102
+ with open(id_file, 'w') as f:
103
+ f.write(random_id)
104
+ os.chmod(id_file, stat.S_IRUSR | stat.S_IWUSR) # 0600 permissions
105
+ except Exception:
106
+ pass
107
+
108
+ machine_id = f"{hostname}-{random_id}"
109
+
110
+ return machine_id
111
+
112
+ def _encrypt_secrets(secrets_dict: Dict[str, str]) -> Optional[str]:
113
+ """
114
+ Encrypt secrets dictionary
115
+
116
+ Args:
117
+ secrets_dict (Dict[str, str]): Dictionary of secrets
118
+
119
+ Returns:
120
+ Optional[str]: Encrypted data or None if encryption fails
121
+ """
122
+ try:
123
+ key, salt = _generate_encryption_key()
124
+ if not key:
125
+ return None
126
+
127
+ # Encrypt the serialized secrets
128
+ f = Fernet(key)
129
+ encrypted_data = f.encrypt(json.dumps(secrets_dict).encode())
130
+
131
+ # Store along with the salt
132
+ return base64.urlsafe_b64encode(salt).decode() + ":" + encrypted_data.decode()
133
+ except Exception as e:
134
+ logger.warning(f"Failed to encrypt secrets: {e}")
135
+ return None
136
+
137
+ def _decrypt_secrets(encrypted_data: str) -> Optional[Dict[str, str]]:
138
+ """
139
+ Decrypt secrets
140
+
141
+ Args:
142
+ encrypted_data (str): Encrypted data
143
+
144
+ Returns:
145
+ Optional[Dict[str, str]]: Decrypted secrets dictionary or None if decryption fails
146
+ """
147
+ try:
148
+ # Split salt and encrypted data
149
+ salt_b64, encrypted = encrypted_data.split(":", 1)
150
+ salt = base64.urlsafe_b64decode(salt_b64)
151
+
152
+ # Regenerate the key with the same salt
153
+ key, _ = _generate_encryption_key(salt)
154
+ if not key:
155
+ return None
156
+
157
+ # Decrypt the data
158
+ f = Fernet(key)
159
+ decrypted_data = f.decrypt(encrypted.encode())
160
+
161
+ return json.loads(decrypted_data.decode())
162
+ except Exception as e:
163
+ logger.warning(f"Failed to decrypt secrets: {e}")
164
+ return None
165
+
166
+ def _secure_state_file(state_path: str) -> None:
167
+ """
168
+ Ensure the state file has secure permissions
169
+
170
+ Args:
171
+ state_path (str): Path to the state file
172
+ """
173
+ try:
174
+ if os.path.exists(state_path):
175
+ if os.name == 'posix': # Linux/Mac
176
+ os.chmod(state_path, stat.S_IRUSR | stat.S_IWUSR) # 0600 permissions
177
+ elif os.name == 'nt': # Windows
178
+ import subprocess
179
+ subprocess.run(['icacls', state_path, '/inheritance:r', '/grant:r', f'{os.getlogin()}:(F)'],
180
+ capture_output=True)
181
+ except Exception as e:
182
+ logger.warning(f"Could not set secure permissions on state file: {e}")
183
+
184
+ def _clear_cache() -> None:
185
+ """Clear the secrets cache on exit"""
186
+ global _secrets_cache
187
+ _secrets_cache = {}
188
+
189
+ # Register the cache clearing function to run on exit
190
+ atexit.register(_clear_cache)
191
+
192
+ def _get_from_keyring_or_env(key, env_var):
193
+ """
194
+ Get a value from keyring or environment variable
195
+
196
+ Args:
197
+ key (str): Key in keyring
198
+ env_var (str): Environment variable name
199
+
200
+ Returns:
201
+ str: Value from keyring or environment variable
202
+ """
203
+ value = None
204
+
205
+ # Try keyring first if available
206
+ if _KEYRING_AVAILABLE:
207
+ try:
208
+ value = keyring.get_password(_KEYRING_SERVICE_NAME, key)
209
+ except Exception as e:
210
+ logger.warning(f"Failed to get {key} from keyring: {e}")
211
+
212
+ # Fall back to environment variable
213
+ if not value:
214
+ value = os.getenv(env_var)
215
+
216
+ return value
217
+
218
+ def set_to_keyring(key, value):
219
+ """
220
+ Set a value to keyring
221
+
222
+ Args:
223
+ key (str): Key in keyring
224
+ value (str): Value to store
225
+
226
+ Returns:
227
+ bool: True if successful, False otherwise
228
+ """
229
+ if not _KEYRING_AVAILABLE:
230
+ return False
231
+
232
+ try:
233
+ keyring.set_password(_KEYRING_SERVICE_NAME, key, value)
234
+ return True
235
+ except Exception as e:
236
+ logger.warning(f"Failed to set {key} to keyring: {e}")
237
+ return False
238
+
239
+ def _initialize_client():
240
+ """
241
+ Initialize the Bitwarden client
242
+ """
243
+ # Get environment variables with defaults
244
+ api_url = os.getenv("API_URL", "https://api.bitwarden.com")
245
+ identity_url = os.getenv("IDENTITY_URL", "https://identity.bitwarden.com")
246
+
247
+ # Get BWS_TOKEN from keyring or environment variable
248
+ bws_token = _get_from_keyring_or_env(_KEYRING_BWS_TOKEN_KEY, "BWS_TOKEN")
249
+
250
+ # Get STATE_FILE from keyring or environment variable
251
+ state_path = _get_from_keyring_or_env(_KEYRING_STATE_FILE_KEY, "STATE_FILE")
252
+
253
+ # Validate required environment variables
254
+ if not bws_token:
255
+ raise ValueError("BWS_TOKEN not found in keyring or environment variable")
256
+ if not state_path:
257
+ raise ValueError("STATE_FILE not found in keyring or environment variable")
258
+
259
+ # Ensure state file directory exists
260
+ state_dir = os.path.dirname(state_path)
261
+ if state_dir and not os.path.exists(state_dir):
262
+ try:
263
+ os.makedirs(state_dir, exist_ok=True)
264
+ # Secure the directory if possible
265
+ if os.name == 'posix': # Linux/Mac
266
+ os.chmod(state_dir, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR) # 0700 permissions
267
+ except Exception as e:
268
+ logger.warning(f"Could not create state directory with secure permissions: {e}")
269
+
270
+ # Secure the state file
271
+ _secure_state_file(state_path)
272
+
273
+ # Create and initialize the client
274
+ client = BitwardenClient(
275
+ client_settings_from_dict({
276
+ "apiUrl": api_url,
277
+ "deviceType": DeviceType.SDK,
278
+ "identityUrl": identity_url,
279
+ "userAgent": "Python",
280
+ })
281
+ )
282
+
283
+ # Authenticate with the Secrets Manager Access Token
284
+ client.auth().login_access_token(bws_token, state_path)
285
+
286
+ return client
287
+
288
+ def _load_secrets(project_id=None):
289
+ """
290
+ Load secrets from Bitwarden
291
+
292
+ Args:
293
+ project_id (str): Project ID to filter secrets
294
+
295
+ Returns:
296
+ dict: Dictionary of secrets with their names as keys
297
+ """
298
+ # Initialize client with credentials from environment or keyring
299
+ try:
300
+ client = _initialize_client()
301
+ except Exception as e:
302
+ logger.error(f"Failed to initialize Bitwarden client: {e}")
303
+ return {}
304
+
305
+ # Get ORGANIZATION_ID from keyring or environment variable
306
+ organization_id = _get_from_keyring_or_env(_KEYRING_ORG_ID_KEY, "ORGANIZATION_ID")
307
+ if not organization_id:
308
+ logger.error("ORGANIZATION_ID not found in keyring or environment variable")
309
+ return {}
310
+
311
+ # Get secrets from BitWarden
312
+ try:
313
+ # Sync secrets to ensure we have the latest
314
+ client.secrets().sync(organization_id, None)
315
+
316
+ # Initialize empty secrets dictionary
317
+ secrets = {}
318
+
319
+ # Retrieve all secrets
320
+ all_secrets = client.secrets().list(organization_id)
321
+
322
+ # Validate response format
323
+ if not hasattr(all_secrets, 'data') or not hasattr(all_secrets.data, 'data'):
324
+ return {}
325
+
326
+ # We need to collect all secret IDs first
327
+ secret_ids = []
328
+ for secret in all_secrets.data.data:
329
+ secret_ids.append(secret.id)
330
+
331
+ # If we have secret IDs, fetch their values
332
+ if secret_ids:
333
+ # Get detailed information for all secrets by their IDs
334
+ secrets_detailed = client.secrets().get_by_ids(secret_ids)
335
+
336
+ # Validate response format
337
+ if not hasattr(secrets_detailed, 'data') or not hasattr(secrets_detailed.data, 'data'):
338
+ return {}
339
+
340
+ # Process each secret
341
+ for secret in secrets_detailed.data.data:
342
+ # Extract the project ID
343
+ secret_project_id = getattr(secret, 'project_id', None)
344
+
345
+ # Check if this secret belongs to the specified project
346
+ if project_id and secret_project_id is not None and project_id != str(secret_project_id):
347
+ continue
348
+
349
+ # Add the secret to our dictionary
350
+ secrets[secret.key] = secret.value
351
+
352
+ # Update the cache with encryption
353
+ encrypted_data = _encrypt_secrets(secrets)
354
+ if encrypted_data:
355
+ _secrets_cache[f"{organization_id}:{project_id or ''}"] = (time.time(), encrypted_data)
356
+ else:
357
+ _secrets_cache[f"{organization_id}:{project_id or ''}"] = (time.time(), secrets.copy())
358
+
359
+ return secrets
360
+ except Exception as e:
361
+ logger.error(f"Error loading secrets: {e}")
362
+ raise
363
+
364
+ def env_load(project_id=None, override=False):
365
+ """
366
+ Load all secrets related to the project into environmental variables.
367
+
368
+ Args:
369
+ project_id (str, optional): Project ID to filter secrets
370
+ override (bool, optional): Whether to override existing environment variables
371
+ """
372
+ # Get all secrets from BWS
373
+ secrets = _load_secrets(project_id)
374
+
375
+ # Set environment variables
376
+ for key, value in secrets.items():
377
+ if override or key not in os.environ:
378
+ os.environ[key] = value
379
+
380
+ def env_load_all(override=False):
381
+ """
382
+ Load all secrets from all projects that user has access to into environment variables
383
+
384
+ Args:
385
+ override (bool, optional): Whether to override existing environment variables
386
+ """
387
+ # Get ORGANIZATION_ID from keyring or environment variable
388
+ organization_id = _get_from_keyring_or_env(_KEYRING_ORG_ID_KEY, "ORGANIZATION_ID")
389
+
390
+ # Initialize Bitwarden client
391
+ try:
392
+ client = _initialize_client()
393
+ except Exception as e:
394
+ logger.error(f"Failed to initialize Bitwarden client: {e}")
395
+ return
396
+
397
+ try:
398
+ # Sync to ensure we have the latest data
399
+ client.secrets().sync(organization_id, None)
400
+
401
+ # Get all projects
402
+ projects_response = client.projects().list(organization_id)
403
+
404
+ # Validate response format
405
+ if not hasattr(projects_response, 'data') or not hasattr(projects_response.data, 'data'):
406
+ logger.warning(f"No projects found in organization {organization_id}")
407
+ return
408
+
409
+ # Process each project
410
+ for project in projects_response.data.data:
411
+ if hasattr(project, 'id'):
412
+ project_id = str(project.id)
413
+
414
+ # Load environment variables for this project
415
+ try:
416
+ # Get the secrets for this project and set them as environment variables
417
+ env_load(project_id=project_id, override=override)
418
+ logger.info(f"Loaded secrets from project: {getattr(project, 'name', project_id)}")
419
+ except Exception as e:
420
+ logger.warning(f"Failed to load secrets from project {project_id}: {e}")
421
+
422
+ except Exception as e:
423
+ logger.error(f"Failed to load all secrets into environment variables: {e}")
424
+
425
+ def get(project_id=None, refresh=False, use_keyring=True):
426
+ """
427
+ Return a dictionary of all project secrets
428
+
429
+ Args:
430
+ project_id (str, optional): Project ID to filter secrets
431
+ refresh (bool, optional): Force refresh the secrets cache
432
+ use_keyring (bool, optional): Whether to use system keyring (True) or in-memory encryption (False)
433
+
434
+ Returns:
435
+ dict: Dictionary of secrets with their names as keys, using lazy loading
436
+ """
437
+ # Get ORGANIZATION_ID from keyring or environment variable
438
+ organization_id = _get_from_keyring_or_env(_KEYRING_ORG_ID_KEY, "ORGANIZATION_ID")
439
+
440
+ # Build the service name for keyring storage
441
+ service_name = f"vault_{organization_id or 'default'}"
442
+
443
+ # Function to either fetch from keyring or decrypt from cache based on container flag
444
+ def _load_decrypted_secrets():
445
+ # Check if we need to force a refresh
446
+ if refresh:
447
+ return _load_secrets(project_id)
448
+
449
+ # Otherwise try to use cached values first
450
+ cache_key = f"{organization_id}:{project_id or ''}"
451
+ current_time = time.time()
452
+
453
+ if cache_key in _secrets_cache:
454
+ timestamp, encrypted_secrets = _secrets_cache[cache_key]
455
+
456
+ # If cache hasn't expired
457
+ if current_time - timestamp < _SECRET_CACHE_TIMEOUT:
458
+ # If we have encryption, try to decrypt
459
+ if encrypted_secrets:
460
+ decrypted_secrets = _decrypt_secrets(encrypted_secrets)
461
+ if decrypted_secrets:
462
+ return decrypted_secrets
463
+ # Otherwise return the unencrypted data (backward compatibility)
464
+ elif isinstance(encrypted_secrets, dict):
465
+ return encrypted_secrets.copy()
466
+
467
+ # If we couldn't get from cache, load fresh
468
+ return _load_secrets(project_id)
469
+
470
+ # Get all secrets and their keys
471
+ all_secrets = _load_decrypted_secrets()
472
+ secret_keys = set(all_secrets.keys())
473
+
474
+ # Store secrets in keyring if available and requested
475
+ keyring_usable = _KEYRING_AVAILABLE and use_keyring
476
+ if keyring_usable:
477
+ for key, value in all_secrets.items():
478
+ keyring.set_password(service_name, key, value)
479
+
480
+ # When keyring is unavailable or not requested (likely in container)
481
+ if not keyring_usable:
482
+ # Create a dictionary of cached secrets for container mode
483
+ container_secrets = {}
484
+ encrypted_data = None
485
+ cache_key = f"{organization_id}:{project_id or ''}"
486
+
487
+ # If we have a cached encrypted version, use that
488
+ if cache_key in _secrets_cache:
489
+ _, encrypted_data = _secrets_cache[cache_key]
490
+
491
+ # Create getter function for container mode
492
+ def _container_getter(key):
493
+ if key in container_secrets:
494
+ return container_secrets[key]
495
+
496
+ # If not in memory cache, check if we have pre-loaded decrypted secrets
497
+ if all_secrets and key in all_secrets:
498
+ container_secrets[key] = all_secrets[key]
499
+ return container_secrets[key]
500
+
501
+ # Otherwise, try to decrypt from cache
502
+ if encrypted_data and not isinstance(encrypted_data, dict):
503
+ decrypted = _decrypt_secrets(encrypted_data)
504
+ if decrypted and key in decrypted:
505
+ container_secrets[key] = decrypted[key]
506
+ return container_secrets[key]
507
+
508
+ # If all else fails, load from API
509
+ fresh_secrets = _load_secrets(project_id)
510
+ if key in fresh_secrets:
511
+ container_secrets[key] = fresh_secrets[key]
512
+ return container_secrets[key]
513
+
514
+ return None
515
+
516
+ # Create the lazy dictionary with container getter
517
+ return LazySecretsDict(secret_keys, _container_getter)
518
+ else:
519
+ # Create getter function for keyring mode
520
+ def _keyring_getter(key):
521
+ return keyring.get_password(service_name, key)
522
+
523
+ # Create setter function for keyring mode
524
+ def _keyring_setter(key, value):
525
+ keyring.set_password(service_name, key, value)
526
+
527
+ # Create deleter function for keyring mode
528
+ def _keyring_deleter(key):
529
+ keyring.delete_password(service_name, key)
530
+
531
+ # Create the lazy dictionary with keyring getter/setter/deleter
532
+ return LazySecretsDict(secret_keys, _keyring_getter, _keyring_setter, _keyring_deleter)
@@ -0,0 +1,250 @@
1
+ Metadata-Version: 2.4
2
+ Name: toru-vault
3
+ Version: 0.1.0
4
+ Summary: ToruVault: A simple Python package for managing Bitwarden secrets
5
+ Author: Toru AI
6
+ Author-email: ToruAI <mpaszynski@toruai.com>
7
+ License: MIT
8
+ Project-URL: Homepage, https://github.com/ToruAI/vault
9
+ Project-URL: Issues, https://github.com/ToruAI/vault/issues
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.6
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: bitwarden-sdk
17
+ Requires-Dist: cryptography>=36.0.0
18
+ Dynamic: author
19
+ Dynamic: license-file
20
+ Dynamic: requires-python
21
+
22
+ <p align="center">
23
+ <img src="img/logo.svg" alt="ToruVault Logo" width="300"/>
24
+ </p>
25
+
26
+ # ToruVault
27
+
28
+ A simple Python package for managing Bitwarden secrets with enhanced security.
29
+
30
+
31
+ ![Version](https://img.shields.io/badge/version-1.0.0-blue)
32
+ ![Python](https://img.shields.io/badge/python-3.10%2B-blue)
33
+ ![License](https://img.shields.io/badge/license-MIT-green)
34
+
35
+ ## Features
36
+
37
+ - Load secrets from Bitwarden Secret Manager into environment variables
38
+ - Get secrets as a Python dictionary
39
+ - Filter secrets by project ID
40
+ - Secure in-memory caching with encryption
41
+ - Automatic cache expiration (5 minutes)
42
+ - Secure file permissions for state storage
43
+ - Machine-specific secret protection
44
+ - Secure credential storage using OS keyring
45
+
46
+ ## Installation
47
+
48
+ ### Using UV (Recommended)
49
+
50
+ ```bash
51
+ # Install UV if you don't have it already
52
+ curl -LsSf https://astral.sh/uv/install.sh | sh
53
+
54
+ # Install toru-vault package
55
+ uv pip install toru-vault
56
+
57
+ # Or install in a virtual environment (recommended)
58
+ uv venv create -p python3.10 .venv
59
+ source .venv/bin/activate # On Windows: .venv\Scripts\activate
60
+ uv pip install toru-vault
61
+ ```
62
+
63
+
64
+ This will automatically install all required dependencies:
65
+ - bitwarden-sdk - For interfacing with Bitwarden API
66
+ - keyring - For secure credential storage
67
+ - cryptography - For encryption/decryption operations
68
+
69
+ ### From Source with UV
70
+
71
+ ```bash
72
+ # Clone the repository
73
+ git clone https://github.com/ToruAI/vault.git
74
+ cd vault
75
+
76
+ uv venv create -p python3.10 .venv
77
+ source .venv/bin/activate # On Windows: .venv\Scripts\activate
78
+
79
+ # Install dependencies
80
+ uv pip install -r requirements.txt
81
+
82
+ # Install in development mode
83
+ uv pip install -e .
84
+ ```
85
+
86
+ ## Configuration
87
+
88
+ You have two options for configuring the vault:
89
+
90
+ ### Option 1: Initialize with Keyring Storage (Recommended)
91
+
92
+ The most secure way to set up vault is to use your operating system's secure keyring:
93
+
94
+ ```bash
95
+ # Initialize vault with secure keyring storage
96
+ python -m vault init
97
+ ```
98
+
99
+ This will prompt you to enter:
100
+ - Your Bitwarden access token (BWS_TOKEN)
101
+ - Your Bitwarden organization ID (ORGANIZATION_ID)
102
+ - The path to the state file (STATE_FILE)
103
+
104
+ [How to get the BWS_TOKEN, ORGANIZATION_ID, and STATE_FILE](#Bitwarden-Secrets)
105
+
106
+ These credentials will be securely stored in your OS keyring and used automatically by the vault.
107
+
108
+ ### Option 2: Environment Variables
109
+
110
+ Alternatively, you can set the following environment variables:
111
+
112
+ - `BWS_TOKEN`: Your Bitwarden access token
113
+ - `ORGANIZATION_ID`: Your Bitwarden organization ID
114
+ - `STATE_FILE`: Path to the state file (must be in an existing directory)
115
+ - `API_URL` (optional): Defaults to "https://api.bitwarden.com"
116
+ - `IDENTITY_URL` (optional): Defaults to "https://identity.bitwarden.com"
117
+
118
+ Setting these environment variables is useful for container environments or when keyring is not available.
119
+
120
+ ## CLI Commands
121
+
122
+ ### Initialize Vault
123
+
124
+ ```bash
125
+ # Set up vault with secure credential storage
126
+ python -m vault init
127
+ ```
128
+
129
+ ### Listing Available Projects
130
+
131
+ ```bash
132
+ # List all projects in your organization
133
+ python -m vault list
134
+
135
+ # With a specific organization ID
136
+ python -m vault list --org-id YOUR_ORGANIZATION_ID
137
+ ```
138
+
139
+ ## Python Usage
140
+
141
+ ### Loading secrets into environment variables
142
+
143
+ ```python
144
+ import toru_vault as vault
145
+
146
+ # Load all secrets into environment variables
147
+ vault.env_load()
148
+
149
+ # Now you can access secrets as environment variables
150
+ import os
151
+ print(os.environ.get("SECRET_NAME"))
152
+
153
+ # Load secrets for a specific project
154
+ vault.env_load(project_id="your-project-id")
155
+
156
+ # Override existing environment variables (default: False)
157
+ vault.env_load(override=True)
158
+ ```
159
+
160
+ ### Getting secrets as a dictionary
161
+
162
+ ```python
163
+ import toru_vault as vault
164
+
165
+ # Get all secrets as a dictionary
166
+ secrets = vault.get()
167
+ print(secrets["SECRET_NAME"]) # Secret is only decrypted when accessed
168
+
169
+ # Force refresh the cache
170
+ secrets = vault.get(refresh=True)
171
+
172
+ # Get secrets for a specific project
173
+ secrets = vault.get(project_id="your-project-id")
174
+
175
+ # Use in-memory encryption instead of system keyring
176
+ secrets = vault.get(use_keyring=False)
177
+ ```
178
+
179
+ ### Loading secrets from all projects
180
+
181
+ ```python
182
+ import toru_vault as vault
183
+
184
+ # Load secrets from all projects you have access to into environment variables
185
+ vault.env_load_all()
186
+
187
+ # Override existing environment variables (default: False)
188
+ vault.env_load_all(override=True)
189
+ ```
190
+
191
+ ## Security Features
192
+
193
+ The vault package includes several security enhancements:
194
+
195
+ 1. **OS Keyring Integration**: Securely stores BWS_TOKEN, ORGANIZATION_ID, and STATE_FILE in your OS keyring
196
+ 2. **Memory Protection**: Secrets are encrypted in memory using Fernet encryption (AES-128)
197
+ 3. **Lazy Decryption**: Secrets are only decrypted when explicitly accessed
198
+ 4. **Cache Expiration**: Cached secrets expire after 5 minutes by default
199
+ 5. **Secure File Permissions**: Sets secure permissions on state files
200
+ 6. **Machine-Specific Encryption**: Uses machine-specific identifiers for encryption keys
201
+ 7. **Cache Clearing**: Automatically clears secret cache on program exit
202
+ 8. **Environment Variable Protection**: Doesn't override existing environment variables by default
203
+ 9. **Secure Key Derivation**: Uses PBKDF2 with SHA-256 for key derivation
204
+ 10. **No Direct Storage**: Never stores secrets in plain text on disk
205
+
206
+ ## Bitwarden Secrets
207
+
208
+ ### BWS_TOKEN
209
+
210
+ Your Bitwarden access token. You can get it from the Bitwarden web app:
211
+
212
+ 1. Log in to your Bitwarden account
213
+ 2. Go to Secret Manager at left bottom
214
+ 3. Go to the "Machine accounts" section
215
+ 4. Create new machine account.
216
+ 5. Go to Access Token Tab
217
+ ![image](img/token-tab.png)
218
+ 6. This is your `BWS_TOKEN`.
219
+
220
+ Remember that you need to assign access to the machine account for the projects you want to use.
221
+
222
+ ### ORGANIZATION_ID
223
+
224
+ Your Bitwarden organization ID. You can get it from the Bitwarden web app:
225
+
226
+ 1. Log in to your Bitwarden account
227
+ 2. Go to Secret Manager at left bottom
228
+ 3. Go to the "Machine accounts" section
229
+ 4. Create new machine account.
230
+ 5. Go to Config Tab
231
+ 6. There is your `ORGANIZATION_ID`.
232
+
233
+ ### STATE_FILE
234
+
235
+ The `STATE_FILE` is used by the login_access_token method to store persistent authentication state information after successfully logging in with an access token.
236
+
237
+ You can set it to any existing file path.
238
+
239
+ ## Security Best Practices
240
+
241
+ When working with secrets, always follow these important guidelines:
242
+
243
+ 1. **Never Embed Keys in Code**: Always use environment variables, keyring, or secure secret management systems.
244
+ 2. **Never Commit Secrets**: Add secret files and credentials to your `.gitignore` file.
245
+ 3. **Use Key Rotation**: Regularly rotate your access tokens as a security measure.
246
+ 4. **Limit Access**: Only provide access to secrets on a need-to-know basis.
247
+ 5. **Monitor Usage**: Regularly audit which applications and users are accessing your secrets.
248
+ 6. **Use Environment-Specific Secrets**: Use different secrets for development, staging, and production environments.
249
+
250
+ Remember that the vault package is designed to protect secrets once they're in your system, but you must handle the initial configuration securely.
@@ -0,0 +1,11 @@
1
+ toru_vault/__init__.py,sha256=Co9SSa9gFFTME0YcMzA1vEqJxs045-0kYfdP9GxGasU,177
2
+ toru_vault/__main__.py,sha256=KRw1dF3tK71DDmAac30tbBgSBRCNyCLOe1NylNXxRi4,6702
3
+ toru_vault/lazy_dict.py,sha256=OZVD-VYQHFRzMw1dOPXpagnddAJNNCZKtcdmTiio4lk,3232
4
+ toru_vault/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
5
+ toru_vault/vault.py,sha256=RLJanRaVnOb4SCvKjJiZJYlUX2QBHRzqpI-Z_vp32Jk,18981
6
+ toru_vault-0.1.0.dist-info/licenses/LICENSE,sha256=TbuuchABSutbmmaI1M232F22GsaI88_hwEvto5w_Ux4,1063
7
+ toru_vault-0.1.0.dist-info/METADATA,sha256=FJ5_wb8Wj5zgnBPwtTT0440UAtxWffymw2s1MGHhxhM,7665
8
+ toru_vault-0.1.0.dist-info/WHEEL,sha256=A8Eltl-h0W-qZDVezsLjjslosEH_pdYC2lQ0JcbgCzs,91
9
+ toru_vault-0.1.0.dist-info/entry_points.txt,sha256=dfqkbNftpmAv0iKzVgdkjymkCfj3TwzUrQm2PO7Xgxs,56
10
+ toru_vault-0.1.0.dist-info/top_level.txt,sha256=c9ulQ18kKs3HbkI5oeoLmnFTknjC0rY1BwsNLJKDua8,11
11
+ toru_vault-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.7.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ toru-vault = toru_vault.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 ToruAI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ toru_vault