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.
@@ -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
@@ -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
@@ -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])