ntermqt 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.
Files changed (52) hide show
  1. nterm/__init__.py +54 -0
  2. nterm/__main__.py +619 -0
  3. nterm/askpass/__init__.py +22 -0
  4. nterm/askpass/server.py +393 -0
  5. nterm/config.py +158 -0
  6. nterm/connection/__init__.py +17 -0
  7. nterm/connection/profile.py +296 -0
  8. nterm/manager/__init__.py +29 -0
  9. nterm/manager/connect_dialog.py +322 -0
  10. nterm/manager/editor.py +262 -0
  11. nterm/manager/io.py +678 -0
  12. nterm/manager/models.py +346 -0
  13. nterm/manager/settings.py +264 -0
  14. nterm/manager/tree.py +493 -0
  15. nterm/resources.py +48 -0
  16. nterm/session/__init__.py +60 -0
  17. nterm/session/askpass_ssh.py +399 -0
  18. nterm/session/base.py +110 -0
  19. nterm/session/interactive_ssh.py +522 -0
  20. nterm/session/pty_transport.py +571 -0
  21. nterm/session/ssh.py +610 -0
  22. nterm/terminal/__init__.py +11 -0
  23. nterm/terminal/bridge.py +83 -0
  24. nterm/terminal/resources/terminal.html +253 -0
  25. nterm/terminal/resources/terminal.js +414 -0
  26. nterm/terminal/resources/xterm-addon-fit.min.js +8 -0
  27. nterm/terminal/resources/xterm-addon-unicode11.min.js +8 -0
  28. nterm/terminal/resources/xterm-addon-web-links.min.js +8 -0
  29. nterm/terminal/resources/xterm.css +209 -0
  30. nterm/terminal/resources/xterm.min.js +8 -0
  31. nterm/terminal/widget.py +380 -0
  32. nterm/theme/__init__.py +10 -0
  33. nterm/theme/engine.py +456 -0
  34. nterm/theme/stylesheet.py +377 -0
  35. nterm/theme/themes/clean.yaml +0 -0
  36. nterm/theme/themes/default.yaml +36 -0
  37. nterm/theme/themes/dracula.yaml +36 -0
  38. nterm/theme/themes/gruvbox_dark.yaml +36 -0
  39. nterm/theme/themes/gruvbox_hybrid.yaml +38 -0
  40. nterm/theme/themes/gruvbox_light.yaml +36 -0
  41. nterm/vault/__init__.py +32 -0
  42. nterm/vault/credential_manager.py +163 -0
  43. nterm/vault/keychain.py +135 -0
  44. nterm/vault/manager_ui.py +962 -0
  45. nterm/vault/profile.py +219 -0
  46. nterm/vault/resolver.py +250 -0
  47. nterm/vault/store.py +642 -0
  48. ntermqt-0.1.0.dist-info/METADATA +327 -0
  49. ntermqt-0.1.0.dist-info/RECORD +52 -0
  50. ntermqt-0.1.0.dist-info/WHEEL +5 -0
  51. ntermqt-0.1.0.dist-info/entry_points.txt +5 -0
  52. ntermqt-0.1.0.dist-info/top_level.txt +1 -0
nterm/vault/store.py ADDED
@@ -0,0 +1,642 @@
1
+ """
2
+ Encrypted credential storage using SQLite + Fernet.
3
+ """
4
+
5
+ from __future__ import annotations
6
+ import base64
7
+ import hashlib
8
+ import logging
9
+ import secrets
10
+ import sqlite3
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ from cryptography.fernet import Fernet, InvalidToken
17
+ from cryptography.hazmat.primitives import hashes
18
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ @dataclass
24
+ class StoredCredential:
25
+ """Credential stored in vault."""
26
+ id: int
27
+ name: str
28
+ username: str
29
+
30
+ # Auth options (encrypted at rest)
31
+ password: Optional[str] = None
32
+ ssh_key: Optional[str] = None
33
+ ssh_key_passphrase: Optional[str] = None
34
+
35
+ # Jump host config
36
+ jump_host: Optional[str] = None
37
+ jump_username: Optional[str] = None
38
+ jump_auth_method: str = "agent" # agent, password, key
39
+ jump_requires_touch: bool = False
40
+
41
+ # Matching rules
42
+ match_hosts: list[str] = field(default_factory=list)
43
+ match_tags: list[str] = field(default_factory=list)
44
+
45
+ # Metadata
46
+ is_default: bool = False
47
+ created_at: Optional[datetime] = None
48
+ last_used: Optional[datetime] = None
49
+
50
+ @property
51
+ def has_password(self) -> bool:
52
+ return self.password is not None and len(self.password) > 0
53
+
54
+ @property
55
+ def has_ssh_key(self) -> bool:
56
+ return self.ssh_key is not None and len(self.ssh_key) > 0
57
+
58
+
59
+ class CredentialStore:
60
+ """
61
+ Encrypted credential storage.
62
+
63
+ Uses SQLite for storage and Fernet for encryption.
64
+ Master password is required to unlock the vault.
65
+ """
66
+
67
+ SCHEMA_VERSION = 1
68
+
69
+ def __init__(self, db_path: Path = None):
70
+ """
71
+ Initialize credential store.
72
+
73
+ Args:
74
+ db_path: Path to SQLite database file
75
+ """
76
+ self.db_path = db_path or Path.home() / ".nterm" / "vault.db"
77
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
78
+
79
+ self._conn: Optional[sqlite3.Connection] = None
80
+ self._fernet: Optional[Fernet] = None
81
+ self._unlocked = False
82
+
83
+ def is_initialized(self) -> bool:
84
+ """Check if vault has been initialized."""
85
+ if not self.db_path.exists():
86
+ return False
87
+
88
+ conn = sqlite3.connect(str(self.db_path))
89
+ try:
90
+ cursor = conn.execute(
91
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='vault_meta'"
92
+ )
93
+ return cursor.fetchone() is not None
94
+ finally:
95
+ conn.close()
96
+
97
+ def init_vault(self, password: str) -> None:
98
+ """
99
+ Initialize vault with master password.
100
+
101
+ Args:
102
+ password: Master password for encryption
103
+ """
104
+ if self.is_initialized():
105
+ raise RuntimeError("Vault already initialized")
106
+
107
+ # Generate salt for key derivation
108
+ salt = secrets.token_bytes(16)
109
+
110
+ # Derive key from password
111
+ key = self._derive_key(password, salt)
112
+
113
+ # Create verification token
114
+ verify_token = secrets.token_bytes(32)
115
+ fernet = Fernet(key)
116
+ encrypted_verify = fernet.encrypt(verify_token)
117
+
118
+ # Create database
119
+ conn = sqlite3.connect(str(self.db_path))
120
+ try:
121
+ conn.executescript('''
122
+ CREATE TABLE vault_meta (
123
+ key TEXT PRIMARY KEY,
124
+ value BLOB
125
+ );
126
+
127
+ CREATE TABLE credentials (
128
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
129
+ name TEXT UNIQUE NOT NULL,
130
+ username TEXT NOT NULL,
131
+ password_enc BLOB,
132
+ ssh_key_enc BLOB,
133
+ ssh_key_passphrase_enc BLOB,
134
+ jump_host TEXT,
135
+ jump_username TEXT,
136
+ jump_auth_method TEXT DEFAULT 'agent',
137
+ jump_requires_touch INTEGER DEFAULT 0,
138
+ match_hosts TEXT,
139
+ match_tags TEXT,
140
+ is_default INTEGER DEFAULT 0,
141
+ created_at TEXT,
142
+ last_used TEXT
143
+ );
144
+
145
+ CREATE INDEX idx_credentials_name ON credentials(name);
146
+ CREATE INDEX idx_credentials_default ON credentials(is_default);
147
+ ''')
148
+
149
+ conn.execute(
150
+ "INSERT INTO vault_meta (key, value) VALUES (?, ?)",
151
+ ('salt', salt)
152
+ )
153
+ conn.execute(
154
+ "INSERT INTO vault_meta (key, value) VALUES (?, ?)",
155
+ ('verify', encrypted_verify)
156
+ )
157
+ conn.execute(
158
+ "INSERT INTO vault_meta (key, value) VALUES (?, ?)",
159
+ ('verify_plain', verify_token)
160
+ )
161
+ conn.execute(
162
+ "INSERT INTO vault_meta (key, value) VALUES (?, ?)",
163
+ ('version', str(self.SCHEMA_VERSION).encode())
164
+ )
165
+ conn.commit()
166
+ finally:
167
+ conn.close()
168
+
169
+ logger.info(f"Vault initialized at {self.db_path}")
170
+
171
+ def unlock(self, password: str) -> bool:
172
+ """
173
+ Unlock vault with master password.
174
+
175
+ Args:
176
+ password: Master password
177
+
178
+ Returns:
179
+ True if unlock successful
180
+ """
181
+ if not self.is_initialized():
182
+ raise RuntimeError("Vault not initialized")
183
+
184
+ conn = sqlite3.connect(str(self.db_path))
185
+ try:
186
+ # Get salt
187
+ cursor = conn.execute(
188
+ "SELECT value FROM vault_meta WHERE key = ?", ('salt',)
189
+ )
190
+ row = cursor.fetchone()
191
+ if not row:
192
+ return False
193
+ salt = row[0]
194
+
195
+ # Derive key
196
+ key = self._derive_key(password, salt)
197
+ fernet = Fernet(key)
198
+
199
+ # Verify password
200
+ cursor = conn.execute(
201
+ "SELECT value FROM vault_meta WHERE key = ?", ('verify',)
202
+ )
203
+ row = cursor.fetchone()
204
+ if not row:
205
+ return False
206
+ encrypted_verify = row[0]
207
+
208
+ cursor = conn.execute(
209
+ "SELECT value FROM vault_meta WHERE key = ?", ('verify_plain',)
210
+ )
211
+ row = cursor.fetchone()
212
+ if not row:
213
+ return False
214
+ verify_plain = row[0]
215
+
216
+ try:
217
+ decrypted = fernet.decrypt(encrypted_verify)
218
+ if decrypted != verify_plain:
219
+ return False
220
+ except InvalidToken:
221
+ return False
222
+
223
+ # Success - store connection and fernet
224
+ self._conn = conn
225
+ self._fernet = fernet
226
+ self._unlocked = True
227
+ logger.info("Vault unlocked")
228
+ return True
229
+
230
+ except Exception as e:
231
+ logger.exception(f"Unlock failed: {e}")
232
+ conn.close()
233
+ return False
234
+
235
+ def lock(self) -> None:
236
+ """Lock vault."""
237
+ if self._conn:
238
+ self._conn.close()
239
+ self._conn = None
240
+ self._fernet = None
241
+ self._unlocked = False
242
+ logger.info("Vault locked")
243
+
244
+ @property
245
+ def is_unlocked(self) -> bool:
246
+ """Check if vault is unlocked."""
247
+ return self._unlocked and self._fernet is not None
248
+
249
+ def _derive_key(self, password: str, salt: bytes) -> bytes:
250
+ """Derive encryption key from password."""
251
+ kdf = PBKDF2HMAC(
252
+ algorithm=hashes.SHA256(),
253
+ length=32,
254
+ salt=salt,
255
+ iterations=480000,
256
+ )
257
+ key = base64.urlsafe_b64encode(kdf.derive(password.encode()))
258
+ return key
259
+
260
+ def _encrypt(self, data: str) -> bytes:
261
+ """Encrypt string data."""
262
+ if not self._fernet:
263
+ raise RuntimeError("Vault not unlocked")
264
+ return self._fernet.encrypt(data.encode())
265
+
266
+ def _decrypt(self, data: bytes) -> str:
267
+ """Decrypt to string."""
268
+ if not self._fernet:
269
+ raise RuntimeError("Vault not unlocked")
270
+ return self._fernet.decrypt(data).decode()
271
+
272
+ def add_credential(
273
+ self,
274
+ name: str,
275
+ username: str,
276
+ password: str = None,
277
+ ssh_key: str = None,
278
+ ssh_key_passphrase: str = None,
279
+ jump_host: str = None,
280
+ jump_username: str = None,
281
+ jump_auth_method: str = "agent",
282
+ jump_requires_touch: bool = False,
283
+ match_hosts: list[str] = None,
284
+ match_tags: list[str] = None,
285
+ is_default: bool = False,
286
+ ) -> int:
287
+ """
288
+ Add credential to vault.
289
+
290
+ Returns:
291
+ Credential ID
292
+ """
293
+ if not self.is_unlocked:
294
+ raise RuntimeError("Vault not unlocked")
295
+
296
+ # Encrypt sensitive fields
297
+ password_enc = self._encrypt(password) if password else None
298
+ ssh_key_enc = self._encrypt(ssh_key) if ssh_key else None
299
+ ssh_key_pass_enc = self._encrypt(ssh_key_passphrase) if ssh_key_passphrase else None
300
+
301
+ # Serialize lists
302
+ match_hosts_str = ",".join(match_hosts) if match_hosts else None
303
+ match_tags_str = ",".join(match_tags) if match_tags else None
304
+
305
+ # If setting as default, clear other defaults
306
+ if is_default:
307
+ self._conn.execute("UPDATE credentials SET is_default = 0")
308
+
309
+ cursor = self._conn.execute('''
310
+ INSERT INTO credentials (
311
+ name, username, password_enc, ssh_key_enc, ssh_key_passphrase_enc,
312
+ jump_host, jump_username, jump_auth_method, jump_requires_touch,
313
+ match_hosts, match_tags, is_default, created_at
314
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
315
+ ''', (
316
+ name, username, password_enc, ssh_key_enc, ssh_key_pass_enc,
317
+ jump_host, jump_username, jump_auth_method, int(jump_requires_touch),
318
+ match_hosts_str, match_tags_str, int(is_default),
319
+ datetime.now().isoformat()
320
+ ))
321
+
322
+ self._conn.commit()
323
+ return cursor.lastrowid
324
+
325
+ def get_credential(self, name: str) -> Optional[StoredCredential]:
326
+ """
327
+ Get credential by name.
328
+
329
+ Args:
330
+ name: Credential name
331
+
332
+ Returns:
333
+ StoredCredential if found
334
+ """
335
+ if not self.is_unlocked:
336
+ raise RuntimeError("Vault not unlocked")
337
+
338
+ cursor = self._conn.execute(
339
+ "SELECT * FROM credentials WHERE name = ?", (name,)
340
+ )
341
+ row = cursor.fetchone()
342
+ if not row:
343
+ return None
344
+
345
+ return self._row_to_credential(row)
346
+
347
+ def get_credential_by_id(self, cred_id: int) -> Optional[StoredCredential]:
348
+ """Get credential by ID."""
349
+ if not self.is_unlocked:
350
+ raise RuntimeError("Vault not unlocked")
351
+
352
+ cursor = self._conn.execute(
353
+ "SELECT * FROM credentials WHERE id = ?", (cred_id,)
354
+ )
355
+ row = cursor.fetchone()
356
+ if not row:
357
+ return None
358
+
359
+ return self._row_to_credential(row)
360
+
361
+ def list_credentials(self) -> list[StoredCredential]:
362
+ """
363
+ List all credentials (without decrypting secrets).
364
+
365
+ Returns:
366
+ List of credentials with metadata only
367
+ """
368
+ # This works even when locked - just returns metadata
369
+ conn = self._conn or sqlite3.connect(str(self.db_path))
370
+ try:
371
+ cursor = conn.execute('''
372
+ SELECT id, name, username,
373
+ password_enc IS NOT NULL as has_password,
374
+ ssh_key_enc IS NOT NULL as has_ssh_key,
375
+ is_default, created_at, last_used
376
+ FROM credentials
377
+ ORDER BY name
378
+ ''')
379
+
380
+ results = []
381
+ for row in cursor:
382
+ cred = StoredCredential(
383
+ id=row[0],
384
+ name=row[1],
385
+ username=row[2],
386
+ is_default=bool(row[5]),
387
+ created_at=datetime.fromisoformat(row[6]) if row[6] else None,
388
+ last_used=datetime.fromisoformat(row[7]) if row[7] else None,
389
+ )
390
+ # Set flags for display
391
+ cred.password = "***" if row[3] else None
392
+ cred.ssh_key = "***" if row[4] else None
393
+ results.append(cred)
394
+
395
+ return results
396
+ finally:
397
+ if not self._conn:
398
+ conn.close()
399
+
400
+ def _row_to_credential(self, row) -> StoredCredential:
401
+ """Convert database row to StoredCredential."""
402
+ # Decrypt sensitive fields
403
+ password = self._decrypt(row[3]) if row[3] else None
404
+ ssh_key = self._decrypt(row[4]) if row[4] else None
405
+ ssh_key_passphrase = self._decrypt(row[5]) if row[5] else None
406
+
407
+ # Parse lists
408
+ match_hosts = row[10].split(",") if row[10] else []
409
+ match_tags = row[11].split(",") if row[11] else []
410
+
411
+ return StoredCredential(
412
+ id=row[0],
413
+ name=row[1],
414
+ username=row[2],
415
+ password=password,
416
+ ssh_key=ssh_key,
417
+ ssh_key_passphrase=ssh_key_passphrase,
418
+ jump_host=row[6],
419
+ jump_username=row[7],
420
+ jump_auth_method=row[8] or "agent",
421
+ jump_requires_touch=bool(row[9]),
422
+ match_hosts=match_hosts,
423
+ match_tags=match_tags,
424
+ is_default=bool(row[12]),
425
+ created_at=datetime.fromisoformat(row[13]) if row[13] else None,
426
+ last_used=datetime.fromisoformat(row[14]) if row[14] else None,
427
+ )
428
+
429
+ def remove_credential(self, name: str) -> bool:
430
+ """
431
+ Remove credential by name.
432
+
433
+ Returns:
434
+ True if removed
435
+ """
436
+ conn = self._conn or sqlite3.connect(str(self.db_path))
437
+ try:
438
+ cursor = conn.execute(
439
+ "DELETE FROM credentials WHERE name = ?", (name,)
440
+ )
441
+ conn.commit()
442
+ return cursor.rowcount > 0
443
+ finally:
444
+ if not self._conn:
445
+ conn.close()
446
+
447
+ def set_default(self, name: str) -> bool:
448
+ """
449
+ Set credential as default.
450
+
451
+ Returns:
452
+ True if successful
453
+ """
454
+ conn = self._conn or sqlite3.connect(str(self.db_path))
455
+ try:
456
+ conn.execute("UPDATE credentials SET is_default = 0")
457
+ cursor = conn.execute(
458
+ "UPDATE credentials SET is_default = 1 WHERE name = ?", (name,)
459
+ )
460
+ conn.commit()
461
+ return cursor.rowcount > 0
462
+ finally:
463
+ if not self._conn:
464
+ conn.close()
465
+
466
+ def get_default(self) -> Optional[StoredCredential]:
467
+ """Get default credential."""
468
+ if not self.is_unlocked:
469
+ raise RuntimeError("Vault not unlocked")
470
+
471
+ cursor = self._conn.execute(
472
+ "SELECT * FROM credentials WHERE is_default = 1"
473
+ )
474
+ row = cursor.fetchone()
475
+ if not row:
476
+ return None
477
+
478
+ return self._row_to_credential(row)
479
+
480
+ def update_last_used(self, name: str) -> None:
481
+ """Update last used timestamp."""
482
+ conn = self._conn or sqlite3.connect(str(self.db_path))
483
+ try:
484
+ conn.execute(
485
+ "UPDATE credentials SET last_used = ? WHERE name = ?",
486
+ (datetime.now().isoformat(), name)
487
+ )
488
+ conn.commit()
489
+ finally:
490
+ if not self._conn:
491
+ conn.close()
492
+
493
+ def update_credential(
494
+ self,
495
+ name: str,
496
+ **kwargs
497
+ ) -> bool:
498
+ """
499
+ Update an existing credential.
500
+
501
+ Args:
502
+ name: Credential name to update
503
+ **kwargs: Fields to update
504
+
505
+ Returns:
506
+ True if updated
507
+ """
508
+ if not self.is_unlocked:
509
+ raise RuntimeError("Vault not unlocked")
510
+
511
+ # Get existing credential
512
+ existing = self.get_credential(name)
513
+ if not existing:
514
+ return False
515
+
516
+ # Build update data - merge existing with new
517
+ updates = {}
518
+
519
+ if 'username' in kwargs:
520
+ updates['username'] = kwargs['username']
521
+
522
+ if 'password' in kwargs:
523
+ updates['password_enc'] = self._encrypt(kwargs['password']) if kwargs['password'] else None
524
+
525
+ if 'ssh_key' in kwargs:
526
+ updates['ssh_key_enc'] = self._encrypt(kwargs['ssh_key']) if kwargs['ssh_key'] else None
527
+
528
+ if 'ssh_key_passphrase' in kwargs:
529
+ updates['ssh_key_passphrase_enc'] = self._encrypt(kwargs['ssh_key_passphrase']) if kwargs['ssh_key_passphrase'] else None
530
+
531
+ if 'jump_host' in kwargs:
532
+ updates['jump_host'] = kwargs['jump_host']
533
+
534
+ if 'jump_username' in kwargs:
535
+ updates['jump_username'] = kwargs['jump_username']
536
+
537
+ if 'jump_auth_method' in kwargs:
538
+ updates['jump_auth_method'] = kwargs['jump_auth_method']
539
+
540
+ if 'jump_requires_touch' in kwargs:
541
+ updates['jump_requires_touch'] = int(kwargs['jump_requires_touch'])
542
+
543
+ if 'match_hosts' in kwargs:
544
+ updates['match_hosts'] = ",".join(kwargs['match_hosts']) if kwargs['match_hosts'] else None
545
+
546
+ if 'match_tags' in kwargs:
547
+ updates['match_tags'] = ",".join(kwargs['match_tags']) if kwargs['match_tags'] else None
548
+
549
+ if 'is_default' in kwargs:
550
+ if kwargs['is_default']:
551
+ self._conn.execute("UPDATE credentials SET is_default = 0")
552
+ updates['is_default'] = int(kwargs['is_default'])
553
+
554
+ if not updates:
555
+ return True # Nothing to update
556
+
557
+ # Build SQL
558
+ set_clause = ", ".join(f"{k} = ?" for k in updates.keys())
559
+ values = list(updates.values()) + [name]
560
+
561
+ cursor = self._conn.execute(
562
+ f"UPDATE credentials SET {set_clause} WHERE name = ?",
563
+ values
564
+ )
565
+ self._conn.commit()
566
+
567
+ return cursor.rowcount > 0
568
+
569
+ def change_master_password(self, old_password: str, new_password: str) -> bool:
570
+ """
571
+ Change the master password.
572
+
573
+ Re-encrypts all credentials with new password.
574
+
575
+ Args:
576
+ old_password: Current master password
577
+ new_password: New master password
578
+
579
+ Returns:
580
+ True if successful
581
+ """
582
+ # Verify old password works
583
+ if not self.unlock(old_password):
584
+ return False
585
+
586
+ # Get all credentials with decrypted data
587
+ credentials = []
588
+ cursor = self._conn.execute("SELECT * FROM credentials")
589
+ for row in cursor:
590
+ credentials.append(self._row_to_credential(row))
591
+
592
+ # Generate new salt and key
593
+ new_salt = secrets.token_bytes(16)
594
+ new_key = self._derive_key(new_password, new_salt)
595
+ new_fernet = Fernet(new_key)
596
+
597
+ # Create new verification token
598
+ verify_token = secrets.token_bytes(32)
599
+ encrypted_verify = new_fernet.encrypt(verify_token)
600
+
601
+ # Update vault metadata
602
+ self._conn.execute(
603
+ "UPDATE vault_meta SET value = ? WHERE key = ?",
604
+ (new_salt, 'salt')
605
+ )
606
+ self._conn.execute(
607
+ "UPDATE vault_meta SET value = ? WHERE key = ?",
608
+ (encrypted_verify, 'verify')
609
+ )
610
+ self._conn.execute(
611
+ "UPDATE vault_meta SET value = ? WHERE key = ?",
612
+ (verify_token, 'verify_plain')
613
+ )
614
+
615
+ # Re-encrypt all credentials
616
+ for cred in credentials:
617
+ updates = {}
618
+
619
+ if cred.password:
620
+ updates['password_enc'] = new_fernet.encrypt(cred.password.encode())
621
+
622
+ if cred.ssh_key:
623
+ updates['ssh_key_enc'] = new_fernet.encrypt(cred.ssh_key.encode())
624
+
625
+ if cred.ssh_key_passphrase:
626
+ updates['ssh_key_passphrase_enc'] = new_fernet.encrypt(cred.ssh_key_passphrase.encode())
627
+
628
+ if updates:
629
+ set_clause = ", ".join(f"{k} = ?" for k in updates.keys())
630
+ values = list(updates.values()) + [cred.name]
631
+ self._conn.execute(
632
+ f"UPDATE credentials SET {set_clause} WHERE name = ?",
633
+ values
634
+ )
635
+
636
+ self._conn.commit()
637
+
638
+ # Update internal state
639
+ self._fernet = new_fernet
640
+
641
+ logger.info("Master password changed successfully")
642
+ return True