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.
- nterm/__init__.py +54 -0
- nterm/__main__.py +619 -0
- nterm/askpass/__init__.py +22 -0
- nterm/askpass/server.py +393 -0
- nterm/config.py +158 -0
- nterm/connection/__init__.py +17 -0
- nterm/connection/profile.py +296 -0
- nterm/manager/__init__.py +29 -0
- nterm/manager/connect_dialog.py +322 -0
- nterm/manager/editor.py +262 -0
- nterm/manager/io.py +678 -0
- nterm/manager/models.py +346 -0
- nterm/manager/settings.py +264 -0
- nterm/manager/tree.py +493 -0
- nterm/resources.py +48 -0
- nterm/session/__init__.py +60 -0
- nterm/session/askpass_ssh.py +399 -0
- nterm/session/base.py +110 -0
- nterm/session/interactive_ssh.py +522 -0
- nterm/session/pty_transport.py +571 -0
- nterm/session/ssh.py +610 -0
- nterm/terminal/__init__.py +11 -0
- nterm/terminal/bridge.py +83 -0
- nterm/terminal/resources/terminal.html +253 -0
- nterm/terminal/resources/terminal.js +414 -0
- nterm/terminal/resources/xterm-addon-fit.min.js +8 -0
- nterm/terminal/resources/xterm-addon-unicode11.min.js +8 -0
- nterm/terminal/resources/xterm-addon-web-links.min.js +8 -0
- nterm/terminal/resources/xterm.css +209 -0
- nterm/terminal/resources/xterm.min.js +8 -0
- nterm/terminal/widget.py +380 -0
- nterm/theme/__init__.py +10 -0
- nterm/theme/engine.py +456 -0
- nterm/theme/stylesheet.py +377 -0
- nterm/theme/themes/clean.yaml +0 -0
- nterm/theme/themes/default.yaml +36 -0
- nterm/theme/themes/dracula.yaml +36 -0
- nterm/theme/themes/gruvbox_dark.yaml +36 -0
- nterm/theme/themes/gruvbox_hybrid.yaml +38 -0
- nterm/theme/themes/gruvbox_light.yaml +36 -0
- nterm/vault/__init__.py +32 -0
- nterm/vault/credential_manager.py +163 -0
- nterm/vault/keychain.py +135 -0
- nterm/vault/manager_ui.py +962 -0
- nterm/vault/profile.py +219 -0
- nterm/vault/resolver.py +250 -0
- nterm/vault/store.py +642 -0
- ntermqt-0.1.0.dist-info/METADATA +327 -0
- ntermqt-0.1.0.dist-info/RECORD +52 -0
- ntermqt-0.1.0.dist-info/WHEEL +5 -0
- ntermqt-0.1.0.dist-info/entry_points.txt +5 -0
- 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
|