tests/test_vault_persistence.py
3.5 KB · 100 lines · python Raw
1 """Tests for Wallet persistence: save, load, tamper detection."""
2
3 from __future__ import annotations
4
5 import json
6
7 import pytest
8 from quantumshield.core.keys import get_backend
9
10 from pqc_agent_wallet import Wallet
11 from pqc_agent_wallet.errors import (
12 InvalidPassphraseError,
13 TamperedWalletError,
14 WalletFormatError,
15 )
16
17
18 def test_save_and_load_roundtrip(open_wallet: Wallet, owner, wallet_path) -> None:
19 open_wallet.save()
20 open_wallet.lock()
21
22 reloaded = Wallet.load(wallet_path, owner)
23 reloaded.unlock_with_passphrase("correct-horse-battery")
24 assert reloaded.get("openai_api_key") == "sk-test-openai"
25 assert reloaded.get("postgres_password") == "db-pass-123"
26 assert sorted(reloaded.list_names()) == ["openai_api_key", "postgres_password"]
27
28
29 def test_tampered_wallet_file_rejected(open_wallet: Wallet, owner, wallet_path) -> None:
30 """Flip a byte in an encrypted credential and verify load() detects it.
31
32 Requires a real signature backend. If the stub backend is active, skip.
33 """
34 if get_backend() == "stub":
35 pytest.skip("requires real signature backend to detect tampering")
36
37 open_wallet.save()
38 with open(wallet_path, encoding="utf-8") as f:
39 envelope = json.load(f)
40
41 # Flip a byte in one of the encrypted credential nonces (still valid hex)
42 any_name = next(iter(envelope["encrypted_credentials"]))
43 nonce_hex = envelope["encrypted_credentials"][any_name]["nonce"]
44 first_char = nonce_hex[0]
45 replaced = "0" if first_char != "0" else "1"
46 envelope["encrypted_credentials"][any_name]["nonce"] = replaced + nonce_hex[1:]
47
48 with open(wallet_path, "w", encoding="utf-8") as f:
49 json.dump(envelope, f, indent=2)
50
51 with pytest.raises(TamperedWalletError):
52 Wallet.load(wallet_path, owner)
53
54
55 def test_wrong_passphrase_raises(open_wallet: Wallet, owner, wallet_path) -> None:
56 open_wallet.save()
57 reloaded = Wallet.load(wallet_path, owner)
58 with pytest.raises(InvalidPassphraseError):
59 reloaded.unlock_with_passphrase("wrong-phrase")
60
61
62 def test_fresh_wallet_unlock_accepts_any_passphrase(owner, tmp_path) -> None:
63 """Behavior note: on a fresh wallet with no credentials, unlock cannot
64 validate the passphrase because there's no ciphertext to decrypt against.
65 unlock_with_passphrase therefore accepts any passphrase. Once credentials
66 are added and the wallet is saved/reloaded, validation works normally.
67 """
68 path = str(tmp_path / "fresh.wallet")
69 w = Wallet.create_with_passphrase(path, "original-phrase", owner)
70 w.save()
71
72 reloaded = Wallet.load(path, owner)
73 # No credentials means no check happens - this is accepted.
74 reloaded.unlock_with_passphrase("any-other-phrase")
75 assert reloaded.is_unlocked
76
77
78 def test_wrong_format_version_raises(open_wallet: Wallet, owner, wallet_path) -> None:
79 open_wallet.save()
80 with open(wallet_path, encoding="utf-8") as f:
81 envelope = json.load(f)
82 envelope["version"] = "99.0"
83 with open(wallet_path, "w", encoding="utf-8") as f:
84 json.dump(envelope, f, indent=2)
85
86 with pytest.raises(WalletFormatError):
87 Wallet.load(wallet_path, owner)
88
89
90 def test_missing_signature_fields_raises(open_wallet: Wallet, owner, wallet_path) -> None:
91 open_wallet.save()
92 with open(wallet_path, encoding="utf-8") as f:
93 envelope = json.load(f)
94 envelope.pop("signature", None)
95 with open(wallet_path, "w", encoding="utf-8") as f:
96 json.dump(envelope, f, indent=2)
97
98 with pytest.raises(WalletFormatError):
99 Wallet.load(wallet_path, owner)
100