mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-09 15:58:48 +00:00
Add option to export and import database without encryption
This commit is contained in:
@@ -1309,6 +1309,11 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
|
|||||||
|
|
||||||
exp = sub.add_parser("export")
|
exp = sub.add_parser("export")
|
||||||
exp.add_argument("--file")
|
exp.add_argument("--file")
|
||||||
|
exp.add_argument(
|
||||||
|
"--unencrypted",
|
||||||
|
action="store_true",
|
||||||
|
help="Export without encryption",
|
||||||
|
)
|
||||||
|
|
||||||
imp = sub.add_parser("import")
|
imp = sub.add_parser("import")
|
||||||
imp.add_argument("--file")
|
imp.add_argument("--file")
|
||||||
@@ -1380,7 +1385,9 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
|
|||||||
password_manager.deterministic_totp = True
|
password_manager.deterministic_totp = True
|
||||||
|
|
||||||
if args.command == "export":
|
if args.command == "export":
|
||||||
password_manager.handle_export_database(Path(args.file))
|
password_manager.handle_export_database(
|
||||||
|
Path(args.file), encrypt=not args.unencrypted
|
||||||
|
)
|
||||||
return 0
|
return 0
|
||||||
if args.command == "import":
|
if args.command == "import":
|
||||||
password_manager.handle_import_database(Path(args.file))
|
password_manager.handle_import_database(Path(args.file))
|
||||||
|
@@ -36,7 +36,7 @@ from .entry_management import EntryManager
|
|||||||
from .password_generation import PasswordGenerator
|
from .password_generation import PasswordGenerator
|
||||||
from .backup import BackupManager
|
from .backup import BackupManager
|
||||||
from .vault import Vault
|
from .vault import Vault
|
||||||
from .portable_backup import export_backup, import_backup
|
from .portable_backup import export_backup, import_backup, PortableMode
|
||||||
from cryptography.fernet import InvalidToken
|
from cryptography.fernet import InvalidToken
|
||||||
from .totp import TotpManager
|
from .totp import TotpManager
|
||||||
from .entry_types import EntryType
|
from .entry_types import EntryType
|
||||||
@@ -4102,8 +4102,15 @@ class PasswordManager:
|
|||||||
def handle_export_database(
|
def handle_export_database(
|
||||||
self,
|
self,
|
||||||
dest: Path | None = None,
|
dest: Path | None = None,
|
||||||
|
*,
|
||||||
|
encrypt: bool | None = None,
|
||||||
) -> Path | None:
|
) -> Path | None:
|
||||||
"""Export the current database to an encrypted portable file."""
|
"""Export the current database to a portable file.
|
||||||
|
|
||||||
|
If ``encrypt`` is ``True`` (default) the payload is encrypted. When
|
||||||
|
``encrypt`` is ``False`` the export contains plaintext data. When
|
||||||
|
``encrypt`` is ``None`` the user is prompted interactively.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
fp, parent_fp, child_fp = self.header_fingerprint_args
|
fp, parent_fp, child_fp = self.header_fingerprint_args
|
||||||
clear_header_with_notification(
|
clear_header_with_notification(
|
||||||
@@ -4113,11 +4120,16 @@ class PasswordManager:
|
|||||||
parent_fingerprint=parent_fp,
|
parent_fingerprint=parent_fp,
|
||||||
child_fingerprint=child_fp,
|
child_fingerprint=child_fp,
|
||||||
)
|
)
|
||||||
|
if encrypt is None:
|
||||||
|
encrypt = not confirm_action(
|
||||||
|
"Export database without encryption? (Y/N): "
|
||||||
|
)
|
||||||
path = export_backup(
|
path = export_backup(
|
||||||
self.vault,
|
self.vault,
|
||||||
self.backup_manager,
|
self.backup_manager,
|
||||||
dest,
|
dest,
|
||||||
parent_seed=self.parent_seed,
|
parent_seed=self.parent_seed,
|
||||||
|
encrypt=encrypt,
|
||||||
)
|
)
|
||||||
print(colored(f"Database exported to '{path}'.", "green"))
|
print(colored(f"Database exported to '{path}'.", "green"))
|
||||||
audit_logger = getattr(self, "audit_logger", None)
|
audit_logger = getattr(self, "audit_logger", None)
|
||||||
@@ -4132,15 +4144,26 @@ class PasswordManager:
|
|||||||
def handle_import_database(self, src: Path) -> None:
|
def handle_import_database(self, src: Path) -> None:
|
||||||
"""Import a portable database file, replacing the current index."""
|
"""Import a portable database file, replacing the current index."""
|
||||||
|
|
||||||
if not src.name.endswith(".json.enc"):
|
if not (src.name.endswith(".json.enc") or src.name.endswith(".json")):
|
||||||
print(
|
print(
|
||||||
colored(
|
colored(
|
||||||
"Error: Selected file must be a SeedPass database backup (.json.enc).",
|
"Error: Selected file must be a SeedPass database backup (.json or .json.enc).",
|
||||||
"red",
|
"red",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Determine encryption mode for post-processing
|
||||||
|
mode = None
|
||||||
|
try:
|
||||||
|
raw = src.read_bytes()
|
||||||
|
if src.suffix.endswith(".enc"):
|
||||||
|
raw = self.vault.encryption_manager.decrypt_data(raw, context=str(src))
|
||||||
|
wrapper = json.loads(raw.decode("utf-8"))
|
||||||
|
mode = wrapper.get("encryption_mode")
|
||||||
|
except Exception:
|
||||||
|
mode = None
|
||||||
|
|
||||||
fp, parent_fp, child_fp = self.header_fingerprint_args
|
fp, parent_fp, child_fp = self.header_fingerprint_args
|
||||||
clear_header_with_notification(
|
clear_header_with_notification(
|
||||||
self,
|
self,
|
||||||
@@ -4180,6 +4203,23 @@ class PasswordManager:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if mode == PortableMode.NONE.value:
|
||||||
|
try:
|
||||||
|
password = prompt_new_password()
|
||||||
|
iterations = self.config_manager.get_kdf_iterations()
|
||||||
|
seed_key = derive_key_from_password(
|
||||||
|
password, self.current_fingerprint, iterations=iterations
|
||||||
|
)
|
||||||
|
seed_mgr = EncryptionManager(seed_key, self.fingerprint_dir)
|
||||||
|
seed_mgr.encrypt_parent_seed(self.parent_seed)
|
||||||
|
self.store_hashed_password(password)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(
|
||||||
|
f"Failed to set master password after import: {e}", exc_info=True
|
||||||
|
)
|
||||||
|
print(colored(f"Error: Failed to set master password: {e}", "red"))
|
||||||
|
return
|
||||||
|
|
||||||
print(colored("Database imported successfully.", "green"))
|
print(colored("Database imported successfully.", "green"))
|
||||||
self.sync_vault()
|
self.sync_vault()
|
||||||
|
|
||||||
|
@@ -32,6 +32,7 @@ class PortableMode(Enum):
|
|||||||
"""Encryption mode for portable exports."""
|
"""Encryption mode for portable exports."""
|
||||||
|
|
||||||
SEED_ONLY = EncryptionMode.SEED_ONLY.value
|
SEED_ONLY = EncryptionMode.SEED_ONLY.value
|
||||||
|
NONE = "none"
|
||||||
|
|
||||||
|
|
||||||
def _derive_export_key(seed: str) -> bytes:
|
def _derive_export_key(seed: str) -> bytes:
|
||||||
@@ -47,8 +48,15 @@ def export_backup(
|
|||||||
*,
|
*,
|
||||||
publish: bool = False,
|
publish: bool = False,
|
||||||
parent_seed: str | None = None,
|
parent_seed: str | None = None,
|
||||||
|
encrypt: bool = True,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
"""Export the current vault state to a portable encrypted file."""
|
"""Export the current vault state to a portable file.
|
||||||
|
|
||||||
|
When ``encrypt`` is ``True`` (the default) the payload is encrypted with a
|
||||||
|
key derived from the parent seed. When ``encrypt`` is ``False`` the payload
|
||||||
|
is written in plaintext and the wrapper records an ``encryption_mode`` of
|
||||||
|
:data:`PortableMode.NONE`.
|
||||||
|
"""
|
||||||
|
|
||||||
if dest_path is None:
|
if dest_path is None:
|
||||||
ts = int(time.time())
|
ts = int(time.time())
|
||||||
@@ -57,6 +65,9 @@ def export_backup(
|
|||||||
dest_path = dest_dir / EXPORT_NAME_TEMPLATE.format(ts=ts)
|
dest_path = dest_dir / EXPORT_NAME_TEMPLATE.format(ts=ts)
|
||||||
|
|
||||||
index_data = vault.load_index()
|
index_data = vault.load_index()
|
||||||
|
canonical = canonical_json_dumps(index_data)
|
||||||
|
|
||||||
|
if encrypt:
|
||||||
seed = (
|
seed = (
|
||||||
parent_seed
|
parent_seed
|
||||||
if parent_seed is not None
|
if parent_seed is not None
|
||||||
@@ -64,17 +75,22 @@ def export_backup(
|
|||||||
)
|
)
|
||||||
key = _derive_export_key(seed)
|
key = _derive_export_key(seed)
|
||||||
enc_mgr = EncryptionManager(key, vault.fingerprint_dir)
|
enc_mgr = EncryptionManager(key, vault.fingerprint_dir)
|
||||||
|
|
||||||
canonical = canonical_json_dumps(index_data)
|
|
||||||
payload_bytes = enc_mgr.encrypt_data(canonical.encode("utf-8"))
|
payload_bytes = enc_mgr.encrypt_data(canonical.encode("utf-8"))
|
||||||
|
mode = PortableMode.SEED_ONLY
|
||||||
|
cipher = "aes-gcm"
|
||||||
|
else:
|
||||||
|
payload_bytes = canonical.encode("utf-8")
|
||||||
|
mode = PortableMode.NONE
|
||||||
|
cipher = "none"
|
||||||
|
|
||||||
checksum = json_checksum(index_data)
|
checksum = json_checksum(index_data)
|
||||||
|
|
||||||
wrapper = {
|
wrapper = {
|
||||||
"format_version": FORMAT_VERSION,
|
"format_version": FORMAT_VERSION,
|
||||||
"created_at": int(time.time()),
|
"created_at": int(time.time()),
|
||||||
"fingerprint": vault.fingerprint_dir.name,
|
"fingerprint": vault.fingerprint_dir.name,
|
||||||
"encryption_mode": PortableMode.SEED_ONLY.value,
|
"encryption_mode": mode.value,
|
||||||
"cipher": "aes-gcm",
|
"cipher": cipher,
|
||||||
"checksum": checksum,
|
"checksum": checksum,
|
||||||
"payload": base64.b64encode(payload_bytes).decode("utf-8"),
|
"payload": base64.b64encode(payload_bytes).decode("utf-8"),
|
||||||
}
|
}
|
||||||
@@ -118,10 +134,10 @@ def import_backup(
|
|||||||
if wrapper.get("format_version") != FORMAT_VERSION:
|
if wrapper.get("format_version") != FORMAT_VERSION:
|
||||||
raise ValueError("Unsupported backup format")
|
raise ValueError("Unsupported backup format")
|
||||||
|
|
||||||
if wrapper.get("encryption_mode") != PortableMode.SEED_ONLY.value:
|
mode = wrapper.get("encryption_mode")
|
||||||
raise ValueError("Unsupported encryption mode")
|
|
||||||
payload = base64.b64decode(wrapper["payload"])
|
payload = base64.b64decode(wrapper["payload"])
|
||||||
|
|
||||||
|
if mode == PortableMode.SEED_ONLY.value:
|
||||||
seed = (
|
seed = (
|
||||||
parent_seed
|
parent_seed
|
||||||
if parent_seed is not None
|
if parent_seed is not None
|
||||||
@@ -131,6 +147,11 @@ def import_backup(
|
|||||||
enc_mgr = EncryptionManager(key, vault.fingerprint_dir)
|
enc_mgr = EncryptionManager(key, vault.fingerprint_dir)
|
||||||
enc_mgr._legacy_migrate_flag = False
|
enc_mgr._legacy_migrate_flag = False
|
||||||
index_bytes = enc_mgr.decrypt_data(payload, context="backup payload")
|
index_bytes = enc_mgr.decrypt_data(payload, context="backup payload")
|
||||||
|
elif mode == PortableMode.NONE.value:
|
||||||
|
index_bytes = payload
|
||||||
|
else:
|
||||||
|
raise ValueError("Unsupported encryption mode")
|
||||||
|
|
||||||
index = json.loads(index_bytes.decode("utf-8"))
|
index = json.loads(index_bytes.decode("utf-8"))
|
||||||
|
|
||||||
checksum = json_checksum(index)
|
checksum = json_checksum(index)
|
||||||
|
@@ -378,7 +378,7 @@ async def test_vault_export_endpoint(client, tmp_path):
|
|||||||
out = tmp_path / "out.json"
|
out = tmp_path / "out.json"
|
||||||
out.write_text("data")
|
out.write_text("data")
|
||||||
|
|
||||||
api.app.state.pm.handle_export_database = lambda: out
|
api.app.state.pm.handle_export_database = lambda *a, **k: out
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {token}",
|
"Authorization": f"Bearer {token}",
|
||||||
|
@@ -36,6 +36,7 @@ def test_audit_logger_records_events(monkeypatch, tmp_path):
|
|||||||
monkeypatch.setattr(manager_module, "export_backup", lambda *a, **k: dest)
|
monkeypatch.setattr(manager_module, "export_backup", lambda *a, **k: dest)
|
||||||
pm.vault = object()
|
pm.vault = object()
|
||||||
pm.backup_manager = object()
|
pm.backup_manager = object()
|
||||||
|
monkeypatch.setattr("seedpass.core.manager.confirm_action", lambda *_a, **_k: True)
|
||||||
pm.handle_export_database(dest)
|
pm.handle_export_database(dest)
|
||||||
|
|
||||||
confirms = iter([True, False])
|
confirms = iter([True, False])
|
||||||
|
@@ -42,7 +42,7 @@ class DummyPM:
|
|||||||
)
|
)
|
||||||
self.parent_seed = "seed"
|
self.parent_seed = "seed"
|
||||||
self.handle_display_totp_codes = lambda: None
|
self.handle_display_totp_codes = lambda: None
|
||||||
self.handle_export_database = lambda path: None
|
self.handle_export_database = lambda path, **kwargs: None
|
||||||
self.handle_import_database = lambda path: None
|
self.handle_import_database = lambda path: None
|
||||||
self.change_password = lambda *a, **kw: None
|
self.change_password = lambda *a, **kw: None
|
||||||
self.lock_vault = lambda: None
|
self.lock_vault = lambda: None
|
||||||
|
@@ -17,8 +17,8 @@ def _setup_pm(tmp_path: Path):
|
|||||||
cfg = ConfigManager(vault, tmp_path)
|
cfg = ConfigManager(vault, tmp_path)
|
||||||
backup = BackupManager(tmp_path, cfg)
|
backup = BackupManager(tmp_path, cfg)
|
||||||
pm = SimpleNamespace(
|
pm = SimpleNamespace(
|
||||||
handle_export_database=lambda p: export_backup(
|
handle_export_database=lambda p, encrypt=True: export_backup(
|
||||||
vault, backup, p, parent_seed=TEST_SEED
|
vault, backup, p, parent_seed=TEST_SEED, encrypt=encrypt
|
||||||
),
|
),
|
||||||
handle_import_database=lambda p: import_backup(
|
handle_import_database=lambda p: import_backup(
|
||||||
vault, backup, p, parent_seed=TEST_SEED
|
vault, backup, p, parent_seed=TEST_SEED
|
||||||
@@ -91,3 +91,36 @@ def test_cli_import_round_trip(monkeypatch, tmp_path):
|
|||||||
rc = main.main(["import", "--file", str(export_path)])
|
rc = main.main(["import", "--file", str(export_path)])
|
||||||
assert rc == 0
|
assert rc == 0
|
||||||
assert vault.load_index() == original
|
assert vault.load_index() == original
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_export_import_unencrypted(monkeypatch, tmp_path):
|
||||||
|
pm, vault = _setup_pm(tmp_path)
|
||||||
|
data = {
|
||||||
|
"schema_version": 4,
|
||||||
|
"entries": {
|
||||||
|
"0": {
|
||||||
|
"label": "example",
|
||||||
|
"type": "password",
|
||||||
|
"notes": "",
|
||||||
|
"custom_fields": [],
|
||||||
|
"origin": "",
|
||||||
|
"tags": [],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
vault.save_index(data)
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm)
|
||||||
|
monkeypatch.setattr(main, "configure_logging", lambda: None)
|
||||||
|
monkeypatch.setattr(main, "initialize_app", lambda: None)
|
||||||
|
monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None)
|
||||||
|
|
||||||
|
export_path = tmp_path / "out.json"
|
||||||
|
rc = main.main(["export", "--file", str(export_path), "--unencrypted"])
|
||||||
|
assert rc == 0
|
||||||
|
assert export_path.exists()
|
||||||
|
|
||||||
|
vault.save_index({"schema_version": 4, "entries": {}})
|
||||||
|
rc = main.main(["import", "--file", str(export_path)])
|
||||||
|
assert rc == 0
|
||||||
|
assert vault.load_index() == data
|
||||||
|
@@ -15,6 +15,7 @@ from seedpass.core.vault import Vault
|
|||||||
from seedpass.core.backup import BackupManager
|
from seedpass.core.backup import BackupManager
|
||||||
from seedpass.core.config_manager import ConfigManager
|
from seedpass.core.config_manager import ConfigManager
|
||||||
from seedpass.core.portable_backup import export_backup, import_backup
|
from seedpass.core.portable_backup import export_backup, import_backup
|
||||||
|
from seedpass.core.portable_backup import PortableMode
|
||||||
from utils.key_derivation import derive_index_key, derive_key_from_password
|
from utils.key_derivation import derive_index_key, derive_key_from_password
|
||||||
from utils.fingerprint import generate_fingerprint
|
from utils.fingerprint import generate_fingerprint
|
||||||
|
|
||||||
@@ -54,6 +55,22 @@ def test_round_trip(monkeypatch):
|
|||||||
assert vault.load_index()["pw"] == data["pw"]
|
assert vault.load_index()["pw"] == data["pw"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_round_trip_unencrypted(monkeypatch):
|
||||||
|
with TemporaryDirectory() as td:
|
||||||
|
tmp = Path(td)
|
||||||
|
vault, backup, _ = setup_vault(tmp)
|
||||||
|
data = {"pw": 1}
|
||||||
|
vault.save_index(data)
|
||||||
|
|
||||||
|
path = export_backup(vault, backup, parent_seed=SEED, encrypt=False)
|
||||||
|
wrapper = json.loads(path.read_text())
|
||||||
|
assert wrapper["encryption_mode"] == PortableMode.NONE.value
|
||||||
|
|
||||||
|
vault.save_index({"pw": 0})
|
||||||
|
import_backup(vault, backup, path, parent_seed=SEED)
|
||||||
|
assert vault.load_index()["pw"] == data["pw"]
|
||||||
|
|
||||||
|
|
||||||
from cryptography.fernet import InvalidToken
|
from cryptography.fernet import InvalidToken
|
||||||
|
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user