aethervault-py 6.4.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.
- aethervault/__init__.py +49 -0
- aethervault/__main__.py +214 -0
- aethervault/assets/aethersol.ico +0 -0
- aethervault/assets/aethersol_logo.png +0 -0
- aethervault/assets/screens/aethervault-demo.gif +0 -0
- aethervault/assets/screens/entry.png +0 -0
- aethervault/assets/screens/generator.png +0 -0
- aethervault/assets/screens/health.png +0 -0
- aethervault/assets/screens/login.png +0 -0
- aethervault/assets/screens/vault.png +0 -0
- aethervault/core/__init__.py +6 -0
- aethervault/core/engine.py +226 -0
- aethervault/core/password.py +67 -0
- aethervault/docs/USER_GUIDE.html +238 -0
- aethervault/docs/USER_GUIDE.md +163 -0
- aethervault/gui/__init__.py +6 -0
- aethervault/gui/app.py +1059 -0
- aethervault/gui/click_to_copy_filter.py +24 -0
- aethervault/gui/conflict_dialog.py +141 -0
- aethervault/gui/credential_form.py +351 -0
- aethervault/gui/credential_table.py +344 -0
- aethervault/gui/dialogs.py +172 -0
- aethervault/gui/password_strength.py +46 -0
- aethervault/gui/theme.py +371 -0
- aethervault/shared/__init__.py +6 -0
- aethervault/shared/database.py +577 -0
- aethervault/shared/models.py +54 -0
- aethervault_py-6.4.0.dist-info/METADATA +200 -0
- aethervault_py-6.4.0.dist-info/RECORD +33 -0
- aethervault_py-6.4.0.dist-info/WHEEL +5 -0
- aethervault_py-6.4.0.dist-info/entry_points.txt +2 -0
- aethervault_py-6.4.0.dist-info/licenses/LICENSE +17 -0
- aethervault_py-6.4.0.dist-info/top_level.txt +1 -0
aethervault/__init__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Created: 2026-07-24
|
|
2
|
+
# Last Edited: 2026-08-05 17:06 CT (America/Chicago)
|
|
3
|
+
# Path: aethervault/__init__.py
|
|
4
|
+
# Purpose: Package init for AetherVault source. Defines PROJECT_ROOT and portable mode.
|
|
5
|
+
|
|
6
|
+
"""Package initializer providing PROJECT_ROOT, version constants, and portable mode controls."""
|
|
7
|
+
|
|
8
|
+
__version__ = "6.4.0"
|
|
9
|
+
VERSION = __version__
|
|
10
|
+
__app_name__ = "AetherVault"
|
|
11
|
+
APP_NAME = __app_name__
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
if getattr(sys, "frozen", False):
|
|
17
|
+
PROJECT_ROOT = os.getcwd()
|
|
18
|
+
else:
|
|
19
|
+
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
20
|
+
|
|
21
|
+
PORTABLE_MARKER = ".portable"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_portable() -> bool:
|
|
25
|
+
"""Return True if the portable marker file exists in PROJECT_ROOT."""
|
|
26
|
+
return os.path.exists(os.path.join(PROJECT_ROOT, PORTABLE_MARKER))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def enable_portable_mode() -> bool:
|
|
30
|
+
"""Create the portable marker file and return True, or return False on failure."""
|
|
31
|
+
try:
|
|
32
|
+
path = os.path.join(PROJECT_ROOT, PORTABLE_MARKER)
|
|
33
|
+
if not os.path.exists(path):
|
|
34
|
+
with open(path, "w") as f:
|
|
35
|
+
f.write("Portable mode enabled\n")
|
|
36
|
+
return True
|
|
37
|
+
except OSError:
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def disable_portable_mode() -> bool:
|
|
42
|
+
"""Remove the portable marker file and return True, or return False on failure."""
|
|
43
|
+
try:
|
|
44
|
+
path = os.path.join(PROJECT_ROOT, PORTABLE_MARKER)
|
|
45
|
+
if os.path.exists(path):
|
|
46
|
+
os.remove(path)
|
|
47
|
+
return True
|
|
48
|
+
except OSError:
|
|
49
|
+
return False
|
aethervault/__main__.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# Created: 2026-07-24
|
|
2
|
+
# Last Edited: 2026-08-01 01:55 CT (America/Chicago)
|
|
3
|
+
# Path: aethervault/__main__.py
|
|
4
|
+
# Purpose: Application entry point with CLI switches (--version, --debug, --upgrade, --foreground).
|
|
5
|
+
|
|
6
|
+
"""Application entry point with CLI switches (--version, --debug, --upgrade, --foreground)."""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import urllib.request
|
|
15
|
+
import urllib.error
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
from PySide6.QtCore import QTimer
|
|
19
|
+
from PySide6.QtWidgets import QApplication
|
|
20
|
+
|
|
21
|
+
from aethervault import PROJECT_ROOT, VERSION
|
|
22
|
+
from aethervault.gui.app import PySidePWManager
|
|
23
|
+
|
|
24
|
+
GITHUB_TAGS_API = "https://api.github.com/repos/AetherSolDev/AetherVault/tags"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
GIT_REPO_URL = "https://github.com/AetherSolDev/AetherVault.git"
|
|
28
|
+
RELEASES_URL = "https://github.com/AetherSolDev/AetherVault/releases/latest"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _is_git_repo() -> bool:
|
|
32
|
+
"""Return True if PROJECT_ROOT contains a .git directory."""
|
|
33
|
+
return os.path.isdir(os.path.join(PROJECT_ROOT, ".git"))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _get_pip_command() -> str:
|
|
37
|
+
"""Return the appropriate pip command (venv or system)."""
|
|
38
|
+
in_venv = sys.prefix != sys.base_prefix
|
|
39
|
+
if in_venv:
|
|
40
|
+
return os.path.join(sys.prefix, "bin", "pip")
|
|
41
|
+
return "pip"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _fetch_latest_tag() -> Optional[str]:
|
|
45
|
+
"""Fetch the latest version tag from GitHub. Returns tag string or None."""
|
|
46
|
+
try:
|
|
47
|
+
req = urllib.request.Request(GITHUB_TAGS_API, headers={"User-Agent": "AetherVault"})
|
|
48
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
49
|
+
tags = json.loads(resp.read().decode())
|
|
50
|
+
except urllib.error.HTTPError as e:
|
|
51
|
+
print(f"Upgrade check failed (HTTP {e.code})")
|
|
52
|
+
return None
|
|
53
|
+
except (urllib.error.URLError, json.JSONDecodeError, OSError) as e:
|
|
54
|
+
print(f"Upgrade check failed: {e}")
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
if not tags:
|
|
58
|
+
print("No version tags found on GitHub.")
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
latest_tag = tags[0].get("name", "").lstrip("v")
|
|
62
|
+
if not latest_tag:
|
|
63
|
+
print("Could not determine latest version.")
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
return latest_tag
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _perform_upgrade(latest_tag: str) -> bool:
|
|
70
|
+
"""Perform the actual upgrade. Returns True on success."""
|
|
71
|
+
print(f"Upgrading AetherVault v{VERSION} → v{latest_tag} ...")
|
|
72
|
+
print()
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
git_env = os.environ.copy()
|
|
76
|
+
git_env["GIT_DISCOVERY_ACROSS_FILESYSTEM"] = "1"
|
|
77
|
+
|
|
78
|
+
if _is_git_repo():
|
|
79
|
+
print("1. Pulling latest code via git ...", end=" ", flush=True)
|
|
80
|
+
result = subprocess.run(
|
|
81
|
+
["git", "pull"],
|
|
82
|
+
cwd=PROJECT_ROOT,
|
|
83
|
+
capture_output=True, text=True, timeout=60,
|
|
84
|
+
env=git_env,
|
|
85
|
+
)
|
|
86
|
+
if result.returncode != 0:
|
|
87
|
+
print("FAILED")
|
|
88
|
+
print(result.stderr)
|
|
89
|
+
print("Tip: Set git safe.directory or GIT_DISCOVERY_ACROSS_FILESYSTEM")
|
|
90
|
+
return False
|
|
91
|
+
print("done")
|
|
92
|
+
|
|
93
|
+
print("2. Reinstalling package ...", end=" ", flush=True)
|
|
94
|
+
pip_cmd = _get_pip_command()
|
|
95
|
+
result = subprocess.run(
|
|
96
|
+
[pip_cmd, "install", "-e", "."],
|
|
97
|
+
cwd=PROJECT_ROOT,
|
|
98
|
+
capture_output=True, text=True, timeout=120,
|
|
99
|
+
)
|
|
100
|
+
if result.returncode != 0:
|
|
101
|
+
print("FAILED")
|
|
102
|
+
print(result.stderr)
|
|
103
|
+
return False
|
|
104
|
+
print("done")
|
|
105
|
+
else:
|
|
106
|
+
print("1. Upgrading via pip ...", end=" ", flush=True)
|
|
107
|
+
pip_cmd = _get_pip_command()
|
|
108
|
+
result = subprocess.run(
|
|
109
|
+
[pip_cmd, "install", "--upgrade", f"git+{GIT_REPO_URL}"],
|
|
110
|
+
capture_output=True, text=True, timeout=120,
|
|
111
|
+
)
|
|
112
|
+
if result.returncode != 0:
|
|
113
|
+
print("FAILED")
|
|
114
|
+
print(result.stderr)
|
|
115
|
+
return False
|
|
116
|
+
print("done")
|
|
117
|
+
|
|
118
|
+
print(f"\nUpgrade to v{latest_tag} complete!")
|
|
119
|
+
return True
|
|
120
|
+
|
|
121
|
+
except (subprocess.TimeoutExpired, OSError) as e:
|
|
122
|
+
print(f"FAILED — {e}")
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def check_for_upgrades() -> bool:
|
|
127
|
+
"""Check GitHub tags for a newer version and upgrade if available. Returns True on success."""
|
|
128
|
+
latest_tag = _fetch_latest_tag()
|
|
129
|
+
if latest_tag is None:
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
def parse_ver(v: str):
|
|
133
|
+
parts = v.split(".")
|
|
134
|
+
return tuple(int(p) if p.isdigit() else 0 for p in parts[:3])
|
|
135
|
+
|
|
136
|
+
current = parse_ver(VERSION)
|
|
137
|
+
latest = parse_ver(latest_tag)
|
|
138
|
+
|
|
139
|
+
if latest <= current:
|
|
140
|
+
print(f"You're up to date! (v{VERSION})")
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
return _perform_upgrade(latest_tag)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def detach_from_terminal():
|
|
147
|
+
"""Fork and release the terminal (Unix only). Parent exits, child continues."""
|
|
148
|
+
try:
|
|
149
|
+
pid = os.fork()
|
|
150
|
+
if pid > 0:
|
|
151
|
+
sys.exit(0)
|
|
152
|
+
os.setsid()
|
|
153
|
+
devnull = os.open(os.devnull, os.O_RDWR)
|
|
154
|
+
os.dup2(devnull, 0)
|
|
155
|
+
os.dup2(devnull, 1)
|
|
156
|
+
os.dup2(devnull, 2)
|
|
157
|
+
os.close(devnull)
|
|
158
|
+
except OSError:
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def run():
|
|
163
|
+
"""Parse CLI arguments and run the application."""
|
|
164
|
+
parser = argparse.ArgumentParser(description="AetherVault — secure password manager")
|
|
165
|
+
parser.add_argument(
|
|
166
|
+
"--version", "-v",
|
|
167
|
+
action="store_true",
|
|
168
|
+
help="Show version and exit",
|
|
169
|
+
)
|
|
170
|
+
parser.add_argument(
|
|
171
|
+
"--debug", "-d",
|
|
172
|
+
action="store_true",
|
|
173
|
+
help="Enable debug logging to terminal",
|
|
174
|
+
)
|
|
175
|
+
parser.add_argument(
|
|
176
|
+
"--upgrade", "-u",
|
|
177
|
+
action="store_true",
|
|
178
|
+
help="Check for updates and auto-upgrade (git pull + pip install)",
|
|
179
|
+
)
|
|
180
|
+
parser.add_argument(
|
|
181
|
+
"--foreground", "-f",
|
|
182
|
+
action="store_true",
|
|
183
|
+
help="Keep terminal attached (for debugging)",
|
|
184
|
+
)
|
|
185
|
+
args = parser.parse_args()
|
|
186
|
+
|
|
187
|
+
if args.version:
|
|
188
|
+
print(f"AetherVault v{VERSION}")
|
|
189
|
+
sys.exit(0)
|
|
190
|
+
|
|
191
|
+
if args.upgrade:
|
|
192
|
+
check_for_upgrades()
|
|
193
|
+
sys.exit(0)
|
|
194
|
+
|
|
195
|
+
if args.debug:
|
|
196
|
+
logging.basicConfig(
|
|
197
|
+
level=logging.DEBUG,
|
|
198
|
+
stream=sys.stderr,
|
|
199
|
+
format="%(levelname)s:%(name)s:%(message)s",
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if not args.foreground and sys.platform != "win32":
|
|
203
|
+
detach_from_terminal()
|
|
204
|
+
|
|
205
|
+
app = QApplication(sys.argv)
|
|
206
|
+
app.setStyle("Fusion")
|
|
207
|
+
window = PySidePWManager()
|
|
208
|
+
window.show()
|
|
209
|
+
QTimer.singleShot(500, window.check_setup_state)
|
|
210
|
+
sys.exit(app.exec())
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
if __name__ == "__main__":
|
|
214
|
+
run()
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Created: 2026-08-05
|
|
2
|
+
# Last Edited: 2026-08-05 15:35 CT (America/Chicago)
|
|
3
|
+
# Path: aethervault/core/__init__.py
|
|
4
|
+
# Purpose: Business-logic package — engine, password utilities.
|
|
5
|
+
|
|
6
|
+
"""Business logic for AetherVault: engine (encryption/hashing/settings) and password utilities."""
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# Created: 2026-08-05
|
|
2
|
+
# Last Edited: 2026-08-05 15:35 CT (America/Chicago)
|
|
3
|
+
# Path: aethervault/core/engine.py
|
|
4
|
+
# Purpose: Encryption, hashing, key derivation, backup/wipe, and settings management.
|
|
5
|
+
|
|
6
|
+
"""Encryption, hashing, key derivation, backup/wipe, and settings management."""
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from cryptography.fernet import Fernet, InvalidToken
|
|
17
|
+
from cryptography.hazmat.backends import default_backend
|
|
18
|
+
from cryptography.hazmat.primitives import hashes
|
|
19
|
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
20
|
+
|
|
21
|
+
from aethervault import PROJECT_ROOT
|
|
22
|
+
|
|
23
|
+
DATA_DIR = os.path.join(PROJECT_ROOT, "data")
|
|
24
|
+
DB_PATH = os.path.join(DATA_DIR, "aethervault.db")
|
|
25
|
+
MASTER_KEY_FILE = os.path.join(DATA_DIR, ".master.key")
|
|
26
|
+
DURESS_KEY_FILE = os.path.join(DATA_DIR, ".duress.key")
|
|
27
|
+
DB_BACKUP_PATH = f"{DB_PATH}.bak"
|
|
28
|
+
BACKUP_MAX_FILES = 5
|
|
29
|
+
JOURNAL_FILE = os.path.join(PROJECT_ROOT, "journal.md")
|
|
30
|
+
APP_SETTINGS_FILE = os.path.join(DATA_DIR, ".app_settings.json")
|
|
31
|
+
|
|
32
|
+
APPLICATION_SALT = b"password_manager_salt_value_12345"
|
|
33
|
+
backend = default_backend()
|
|
34
|
+
DEFAULT_LOCKOUT_MINUTES = 3
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def derive_encryption_key(master_password_hash: str) -> bytes:
|
|
39
|
+
"""Derive an AES-256 Fernet key from the master password hash via PBKDF2."""
|
|
40
|
+
if not master_password_hash:
|
|
41
|
+
raise ValueError("Master password hash cannot be empty for key derivation.")
|
|
42
|
+
password_bytes = master_password_hash.encode("utf-8")
|
|
43
|
+
kdf = PBKDF2HMAC(
|
|
44
|
+
algorithm=hashes.SHA256(),
|
|
45
|
+
length=32,
|
|
46
|
+
salt=APPLICATION_SALT,
|
|
47
|
+
iterations=480000,
|
|
48
|
+
backend=backend,
|
|
49
|
+
)
|
|
50
|
+
key = base64.urlsafe_b64encode(kdf.derive(password_bytes))
|
|
51
|
+
return key
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_timestamped_backup_path() -> str:
|
|
55
|
+
"""Return a backup file path with a human-readable timestamp."""
|
|
56
|
+
timestamp = time.strftime("%Y.%m.%d_%H%M%S")
|
|
57
|
+
db_dir = os.path.dirname(DB_PATH)
|
|
58
|
+
return os.path.join(db_dir, f"aethervault_{timestamp}.db.bak")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def encrypt_data(data: str, key: bytes) -> str:
|
|
62
|
+
"""Encrypt a plaintext string using Fernet symmetric encryption."""
|
|
63
|
+
if not data:
|
|
64
|
+
return ""
|
|
65
|
+
try:
|
|
66
|
+
f = Fernet(key)
|
|
67
|
+
encrypted_bytes = f.encrypt(data.encode("utf-8"))
|
|
68
|
+
return encrypted_bytes.decode("utf-8")
|
|
69
|
+
except (TypeError, ValueError) as e:
|
|
70
|
+
logger.error("Encryption failed: %s", e)
|
|
71
|
+
return data
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def decrypt_data(encrypted_data: str, key: bytes) -> str:
|
|
75
|
+
"""Decrypt a Fernet-encrypted string back to plaintext."""
|
|
76
|
+
if not encrypted_data:
|
|
77
|
+
return ""
|
|
78
|
+
try:
|
|
79
|
+
f = Fernet(key)
|
|
80
|
+
decrypted_bytes = f.decrypt(encrypted_data.encode("utf-8"))
|
|
81
|
+
return decrypted_bytes.decode("utf-8")
|
|
82
|
+
except (InvalidToken, TypeError, ValueError):
|
|
83
|
+
return encrypted_data
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def hash_password(password: str) -> str:
|
|
87
|
+
"""Hash a password with a random salt using PBKDF2-SHA256 and return a Base64 string."""
|
|
88
|
+
salt = os.urandom(16)
|
|
89
|
+
hashed_bytes = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 600000)
|
|
90
|
+
return base64.b64encode(salt + hashed_bytes).decode("utf-8")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def verify_password(password: str, stored_hash: str) -> bool:
|
|
94
|
+
"""Verify a password against a stored PBKDF2-SHA256 hash."""
|
|
95
|
+
try:
|
|
96
|
+
decoded_hash = base64.b64decode(stored_hash)
|
|
97
|
+
salt = decoded_hash[:16]
|
|
98
|
+
stored_hash_part = decoded_hash[16:]
|
|
99
|
+
new_hash_part = hashlib.pbkdf2_hmac(
|
|
100
|
+
"sha256", password.encode("utf-8"), salt, 600000
|
|
101
|
+
)
|
|
102
|
+
return new_hash_part == stored_hash_part
|
|
103
|
+
except (ValueError, TypeError):
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def load_master_password(file_path: str) -> Optional[str]:
|
|
108
|
+
"""Read the stored master password hash from disk, or return None."""
|
|
109
|
+
if os.path.exists(file_path):
|
|
110
|
+
try:
|
|
111
|
+
with open(file_path, "r") as f:
|
|
112
|
+
return f.read().strip()
|
|
113
|
+
except OSError:
|
|
114
|
+
return None
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def store_master_password(password: str) -> bool:
|
|
119
|
+
"""Hash and persist the master password to the master key file."""
|
|
120
|
+
hashed_pass = hash_password(password)
|
|
121
|
+
try:
|
|
122
|
+
with open(MASTER_KEY_FILE, "w") as f:
|
|
123
|
+
f.write(hashed_pass)
|
|
124
|
+
return True
|
|
125
|
+
except OSError:
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def load_duress_password() -> Optional[str]:
|
|
130
|
+
"""Read the stored duress password hash from disk, or return None."""
|
|
131
|
+
return load_master_password(DURESS_KEY_FILE)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def store_duress_password(password: str) -> bool:
|
|
135
|
+
"""Hash and persist the duress password to the duress key file."""
|
|
136
|
+
hashed_pass = hash_password(password)
|
|
137
|
+
try:
|
|
138
|
+
with open(DURESS_KEY_FILE, "w") as f:
|
|
139
|
+
f.write(hashed_pass)
|
|
140
|
+
return True
|
|
141
|
+
except OSError:
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def clear_duress_password() -> bool:
|
|
146
|
+
"""Delete the duress key file. Returns True if removed (or absent)."""
|
|
147
|
+
try:
|
|
148
|
+
if os.path.exists(DURESS_KEY_FILE):
|
|
149
|
+
os.remove(DURESS_KEY_FILE)
|
|
150
|
+
return True
|
|
151
|
+
except OSError:
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def rotate_backups(max_files: int = BACKUP_MAX_FILES) -> int:
|
|
156
|
+
"""Prune timestamped .bak files in DATA_DIR, keeping the max_files most recent.
|
|
157
|
+
Returns the number of files removed."""
|
|
158
|
+
if max_files <= 0:
|
|
159
|
+
return 0
|
|
160
|
+
backups = sorted(
|
|
161
|
+
f for f in os.listdir(DATA_DIR)
|
|
162
|
+
if f.startswith("aethervault_") and f.endswith(".db.bak")
|
|
163
|
+
)
|
|
164
|
+
stale = backups[:-max_files]
|
|
165
|
+
for f in stale:
|
|
166
|
+
try:
|
|
167
|
+
os.remove(os.path.join(DATA_DIR, f))
|
|
168
|
+
except OSError:
|
|
169
|
+
pass
|
|
170
|
+
return len(stale)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _overwrite_and_remove(path: str):
|
|
174
|
+
"""Overwrite a file with random bytes then delete it (defense-in-depth wipe)."""
|
|
175
|
+
try:
|
|
176
|
+
size = os.path.getsize(path)
|
|
177
|
+
if size > 0:
|
|
178
|
+
with open(path, "r+b") as f:
|
|
179
|
+
remaining = size
|
|
180
|
+
chunk = 65536
|
|
181
|
+
while remaining > 0:
|
|
182
|
+
n = min(chunk, remaining)
|
|
183
|
+
f.write(os.urandom(n))
|
|
184
|
+
remaining -= n
|
|
185
|
+
f.flush()
|
|
186
|
+
os.fsync(f.fileno())
|
|
187
|
+
os.remove(path)
|
|
188
|
+
except OSError:
|
|
189
|
+
pass
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def wipe_vault() -> bool:
|
|
193
|
+
"""Destroy the vault and all backups, making the data unrecoverable.
|
|
194
|
+
|
|
195
|
+
Order matters: the master/duress key files are deleted FIRST so the AES
|
|
196
|
+
ciphertext becomes cryptographically unrecoverable even if a later step
|
|
197
|
+
is interrupted. Remaining files are then overwritten with random data
|
|
198
|
+
and removed as defense-in-depth."""
|
|
199
|
+
for key_file in (MASTER_KEY_FILE, DURESS_KEY_FILE):
|
|
200
|
+
_overwrite_and_remove(key_file)
|
|
201
|
+
for name in os.listdir(DATA_DIR):
|
|
202
|
+
path = os.path.join(DATA_DIR, name)
|
|
203
|
+
if not os.path.isfile(path):
|
|
204
|
+
continue
|
|
205
|
+
if name == ".portable":
|
|
206
|
+
continue
|
|
207
|
+
_overwrite_and_remove(path)
|
|
208
|
+
return True
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def load_settings() -> dict:
|
|
212
|
+
"""Load application settings from the JSON settings file, falling back to defaults."""
|
|
213
|
+
try:
|
|
214
|
+
with open(APP_SETTINGS_FILE, "r") as f:
|
|
215
|
+
return json.load(f)
|
|
216
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
217
|
+
return {"lockout_minutes": DEFAULT_LOCKOUT_MINUTES, "theme": "dark"}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def save_settings(settings: dict):
|
|
221
|
+
"""Persist application settings to the JSON settings file."""
|
|
222
|
+
try:
|
|
223
|
+
with open(APP_SETTINGS_FILE, "w") as f:
|
|
224
|
+
json.dump(settings, f, indent=4)
|
|
225
|
+
except IOError as e:
|
|
226
|
+
logger.error("Error saving settings: %s", e)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Created: 2026-08-05
|
|
2
|
+
# Last Edited: 2026-08-05 15:35 CT (America/Chicago)
|
|
3
|
+
# Path: aethervault/core/password.py
|
|
4
|
+
# Purpose: Password strength scoring and secure password generation.
|
|
5
|
+
|
|
6
|
+
"""Password strength scoring and secure password generation."""
|
|
7
|
+
|
|
8
|
+
import random
|
|
9
|
+
import re
|
|
10
|
+
import string
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def score_password(password: str) -> int:
|
|
14
|
+
"""Score a password 0-100 based on length and character diversity."""
|
|
15
|
+
if not password:
|
|
16
|
+
return 0
|
|
17
|
+
score = 0
|
|
18
|
+
if len(password) >= 8:
|
|
19
|
+
score += 15
|
|
20
|
+
if len(password) >= 12:
|
|
21
|
+
score += 15
|
|
22
|
+
if len(password) >= 16:
|
|
23
|
+
score += 10
|
|
24
|
+
if re.search(r"[a-z]", password):
|
|
25
|
+
score += 10
|
|
26
|
+
if re.search(r"[A-Z]", password):
|
|
27
|
+
score += 15
|
|
28
|
+
if re.search(r"\d", password):
|
|
29
|
+
score += 15
|
|
30
|
+
if re.search(r"[^a-zA-Z0-9]", password):
|
|
31
|
+
score += 20
|
|
32
|
+
return min(score, 100)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def generate_strong_password(
|
|
36
|
+
length: int = 18, use_lower=True, use_upper=True, use_digit=True, use_symbol=True
|
|
37
|
+
) -> str:
|
|
38
|
+
"""Generate a cryptographically random password with configurable character sets."""
|
|
39
|
+
if length < 1:
|
|
40
|
+
length = 1
|
|
41
|
+
char_sets = []
|
|
42
|
+
if use_lower:
|
|
43
|
+
char_sets.append(string.ascii_lowercase)
|
|
44
|
+
if use_upper:
|
|
45
|
+
char_sets.append(string.ascii_uppercase)
|
|
46
|
+
if use_digit:
|
|
47
|
+
char_sets.append(string.digits)
|
|
48
|
+
if use_symbol:
|
|
49
|
+
char_sets.append(string.punctuation)
|
|
50
|
+
if not char_sets:
|
|
51
|
+
char_sets.append(string.ascii_letters)
|
|
52
|
+
all_chars = "".join(char_sets)
|
|
53
|
+
if not all_chars:
|
|
54
|
+
return ""
|
|
55
|
+
password = []
|
|
56
|
+
if use_lower:
|
|
57
|
+
password.append(random.choice(string.ascii_lowercase))
|
|
58
|
+
if use_upper:
|
|
59
|
+
password.append(random.choice(string.ascii_uppercase))
|
|
60
|
+
if use_digit:
|
|
61
|
+
password.append(random.choice(string.digits))
|
|
62
|
+
if use_symbol:
|
|
63
|
+
password.append(random.choice(string.punctuation))
|
|
64
|
+
while len(password) < length:
|
|
65
|
+
password.append(random.choice(all_chars))
|
|
66
|
+
random.shuffle(password)
|
|
67
|
+
return "".join(password[:length])
|