passvault-cli-tool 1.0.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,166 @@
1
+ Metadata-Version: 2.4
2
+ Name: passvault-cli-tool
3
+ Version: 1.0.0
4
+ Summary: A local-first TUI password manager for Linux.
5
+ Author: Senume
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Senume/PassVault
8
+ Keywords: password-manager,tui,cli,security
9
+ Classifier: Environment :: Console
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Security
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: cryptography>=41.0
17
+ Requires-Dist: argon2-cffi>=23.0
18
+ Requires-Dist: appdirs>=1.4
19
+ Requires-Dist: textual>=0.60
20
+ Requires-Dist: rich>=13.0
21
+ Requires-Dist: pydantic>=2.0
22
+ Requires-Dist: httpx>=0.27
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest; extra == "dev"
25
+ Requires-Dist: pytest-asyncio; extra == "dev"
26
+ Dynamic: license-file
27
+
28
+ # 🔐 PassVault
29
+
30
+ A local TUI (Terminal User Interface) password manager for Linux.
31
+ Credentials are encrypted with **Argon2id + AES-GCM** and never leave your machine.
32
+
33
+ ---
34
+
35
+ ## Features
36
+
37
+ | Key | Action |
38
+ |-----|--------|
39
+ | `/` | Open vault selector overlay |
40
+ | `n` | Create a new vault |
41
+ | `g` | Add a new credential to the current vault |
42
+ | `f` | Search credentials by name |
43
+ | `e` | Export current vault as an encrypted `.pvault` archive |
44
+ | `i` | Import a vault from a `.pvault` archive |
45
+ | `ctrl+d` | Delete the current vault (requires typing vault name) |
46
+ | `ctrl+g` | Auto-generate a strong password (inside Add Credential panel) |
47
+ | `ctrl+v` | Paste from clipboard into any input field |
48
+
49
+ ---
50
+
51
+ ## Requirements
52
+
53
+ ### System dependency
54
+ PassVault uses `xclip` for clipboard operations:
55
+ ```bash
56
+ sudo apt install xclip # Debian / Ubuntu
57
+ sudo pacman -S xclip # Arch
58
+ sudo dnf install xclip # Fedora
59
+ ```
60
+
61
+ ### Python
62
+ - Python **3.10+**
63
+
64
+ ---
65
+
66
+ ## Installation
67
+
68
+ ### From PyPI
69
+ ```bash
70
+ pip install passvault
71
+ ```
72
+
73
+ ### From source
74
+ ```bash
75
+ git clone https://github.com/Senume/PassVault.git
76
+ cd PassVault
77
+ pip install -e .
78
+ ```
79
+
80
+ ---
81
+
82
+ ## Running
83
+
84
+ ```bash
85
+ passvault
86
+ ```
87
+
88
+ ---
89
+
90
+ ## Data & log paths
91
+
92
+ PassVault resolves paths in priority order:
93
+
94
+ | | System path (root) | User fallback | Env override |
95
+ |---|---|---|---|
96
+ | **Vaults** | `/etc/passvault/vaults` | `~/.local/share/passvault/vaults` | `$PASSVAULT` |
97
+ | **Logs** | `/var/log/passvault/passvault.log` | `~/.local/state/passvault/passvault.log` | `$PASSVAULT_LOG` |
98
+
99
+ Directories are created automatically on first run.
100
+
101
+ > To grant your user write access to the system paths:
102
+ > ```bash
103
+ > sudo mkdir -p /etc/passvault/vaults /var/log/passvault
104
+ > sudo chown -R $USER /etc/passvault /var/log/passvault
105
+ > ```
106
+
107
+ ---
108
+
109
+ ## Usage walkthrough
110
+
111
+ ### 1 — Create a vault
112
+ Press `n`, enter a vault name and master password (confirmed twice). Press `Enter`.
113
+
114
+ ### 2 — Add credentials
115
+ Press `g` (a vault must be selected first via `/`).
116
+ Fill in **Name**, **Username**, **Password** — or press `Ctrl+G` to auto-generate a strong password.
117
+ Press `Enter` to confirm, then enter the vault master password.
118
+
119
+ ### 3 — View credentials
120
+ Select a credential from the list and press `Enter`.
121
+ Enter the master password to unlock.
122
+ Press `c` to copy the username or `p` to copy the password to clipboard.
123
+ Press `Esc` to close.
124
+
125
+ ### 4 — Search
126
+ Press `f` to focus the search bar. Type to filter credentials by name in real time. Press `Esc` to clear.
127
+
128
+ ### 5 — Export / Import
129
+ - **Export** (`e`): saves the current vault as an encrypted `.pvault` zip to a path you specify (default: `~/PassVault_exports/`).
130
+ - **Import** (`i`): restores a vault from a `.pvault` file path.
131
+
132
+ The archive keeps credentials AES-GCM encrypted — no master password is needed for the transfer itself.
133
+
134
+ ### 6 — Delete a vault
135
+ Press `Ctrl+D`, then type the vault name exactly to confirm permanent deletion.
136
+
137
+ ---
138
+
139
+ ## Security
140
+
141
+ - **KDF**: Argon2id (time cost 3, memory 64 MB, parallelism 1)
142
+ - **Encryption**: AES-256-GCM per credential — each `.ptr` file is independently encrypted
143
+ - **Master password**: never stored; derived key exists only in memory during a session
144
+ - **Clipboard**: copy operations use `xclip`; clear the clipboard manually after use
145
+
146
+ ---
147
+
148
+ ## Development
149
+
150
+ ```bash
151
+ git clone https://github.com/Senume/PassVault.git
152
+ cd PassVault
153
+ pip install -e ".[dev]"
154
+
155
+ # Run tests
156
+ PYTHONPATH=$PWD pytest -q
157
+
158
+ # Run the app
159
+ PASSVAULT=data passvault
160
+ ```
161
+
162
+ ---
163
+
164
+ ## License
165
+
166
+ MIT — see [LICENSE](LICENSE)
@@ -0,0 +1,30 @@
1
+ passvault_cli_tool-1.0.0.dist-info/licenses/LICENSE,sha256=gICzLXuoZpo2fX1S75l6jxOXizy-KD866VrgpGAdAHo,1063
2
+ passvault_core/clipboard.py,sha256=GxBMTSmPWPiiZ3nO7nImJflJEDykJrSNCbL-AkaaMUI,6226
3
+ passvault_core/crypto.py,sha256=MAtZQ0_eWCSoY_gWv-TUQl_hl6cEMmchTAXQfo9Opq8,1993
4
+ passvault_core/errors.py,sha256=bslo_4QVU5LJgCudAQQTXcS8cy-tP44Rg-NL2DcUt5g,252
5
+ passvault_core/schema.py,sha256=57e1yESnV5sW7TnUX19COiSnVgEe17qfq-sr8LdZymo,2773
6
+ passvault_core/storage.py,sha256=TaXvEx6h8B9Kvrk-YKf2JcImIblFoFUGJzjFn_WVXQ0,5673
7
+ passvault_core/tests/test_clipboard.py,sha256=NbWNcY5_Jf7YJgchvsBBbNybIeYp9q9YdjWTPsBVseg,12750
8
+ passvault_core/tests/test_crypto.py,sha256=pbc5xq627h6Jx97iK5FEEallAGVuBY1MY58lDgqL9KI,6835
9
+ passvault_core/tests/test_schema.py,sha256=OOXYlhyok87DUBRmUbJjeAFzoLD3l-36SYpNFOx1V5s,6510
10
+ passvault_core/tests/test_storage.py,sha256=0pbV6NtkxI5NRyaPvYou5jn5hBDpGg5g4eHm7KEbgSA,7166
11
+ passvault_tui/__init__.py,sha256=uPI0vnaBmnWNTT8t2hQRFPEaJJOEbRunw8M6eYwSqfY,94
12
+ passvault_tui/app.py,sha256=1kVPDBPLjQIxRuzkJ_Beh82LRqORECaEOXDUE8vyWB8,15809
13
+ passvault_tui/style.css,sha256=i5gw9Nna4bXnTlvw6gIQ6ky80IZF266aJMHMuqQ2aRg,6786
14
+ passvault_tui/screens/__init__.py,sha256=ZRASt7hmKuAB312gu98Zool-fYsVvbKy2H7sd_YUt5E,19
15
+ passvault_tui/utils/__init__.py,sha256=tmkYaXrYQoBaMH-swbN1SOWqXLFLgn4CUYh2JEMILWg,33
16
+ passvault_tui/widgets/__init__.py,sha256=K6RQqsqalzNchy9LEXqG9q0KU1kaKceviZfQNYhYpG8,572
17
+ passvault_tui/widgets/add_credential_panel.py,sha256=S7Ggk_KSUXY6emB6Zp7oM8_IKKai0HHVAAdBlwXDqb0,4902
18
+ passvault_tui/widgets/add_vault_panel.py,sha256=QemD6rvdtZqqCfQb82stcc18hdjgRDQyRv6fQ8whZXE,2860
19
+ passvault_tui/widgets/credential_panel.py,sha256=HaBeVrqDP5yVqcdQwLAjYAkk6sFnbCKhLjYYGuzhZ7E,2772
20
+ passvault_tui/widgets/delete_vault_panel.py,sha256=BDdYdtbVbmRKhAZNx3mWoCq5IfL8zrT8WvWp33tiilY,2103
21
+ passvault_tui/widgets/export_vault_panel.py,sha256=ft3VxoNmOzp1assCYaOrg3ToSk8ihwEIu_ysPXBGt-0,2659
22
+ passvault_tui/widgets/import_vault_panel.py,sha256=9IyxRezlBLdsd4AK2AoID6Pdfm_SF9644Z5B0W8orUk,2533
23
+ passvault_tui/widgets/master_password_panel.py,sha256=0n73mf2PU9ANrv2sZCuFQHnVKs7WYZc-TsOdKbj4D7g,1733
24
+ utils/__init__.py,sha256=lDUdAJlZTwKkgvMRjw0d3cAvV_oYJgO95QYul5h-LjM,103
25
+ utils/logger.py,sha256=r35P1GXAfF3XHqHwIfRQOoKfXNMpNGtXSbMP0K5oLFA,1657
26
+ passvault_cli_tool-1.0.0.dist-info/METADATA,sha256=Ey9gCjjtoW7zvrzKAM0Zk-EynTn-cwMvXSK0DlZw7Bw,4360
27
+ passvault_cli_tool-1.0.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
28
+ passvault_cli_tool-1.0.0.dist-info/entry_points.txt,sha256=HvJbHFdYFzY8vYunXj2e5QdgGdWhOIlRcFujpiOUhhw,52
29
+ passvault_cli_tool-1.0.0.dist-info/top_level.txt,sha256=pqQ5wohWBQk7yB3d6sK_QV32_oBiyCiJQTGYc0WeYXI,35
30
+ passvault_cli_tool-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ passvault = passvault_tui.app:run
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Senume
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,3 @@
1
+ passvault_core
2
+ passvault_tui
3
+ utils
@@ -0,0 +1,206 @@
1
+ """
2
+ Clipboard management module.
3
+
4
+ Provides functionality to copy data to and from system clipboard
5
+ with manual clearing control.
6
+ """
7
+
8
+ import subprocess
9
+ import threading
10
+ import logging
11
+ from typing import Optional
12
+ from contextlib import contextmanager
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class ClipboardError(Exception):
18
+ """Raised when clipboard operations fail."""
19
+ pass
20
+
21
+
22
+ class ClipboardManager:
23
+ """
24
+ Manages clipboard operations with thread-safe access.
25
+
26
+ Provides thread-safe copy, clear, and read operations for
27
+ system clipboard access. All clearing is manual.
28
+ """
29
+
30
+ def __init__(self):
31
+ """
32
+ Initialize the clipboard manager.
33
+
34
+ Raises:
35
+ ValueError: If initialization fails.
36
+ """
37
+ self._lock = threading.RLock()
38
+ self._clipboard_content: Optional[str] = None
39
+ self._is_managed = False
40
+
41
+ def copy(self, text: str) -> None:
42
+ """
43
+ Copy text to clipboard.
44
+
45
+ Args:
46
+ text: The text to copy to clipboard.
47
+
48
+ Raises:
49
+ ClipboardError: If clipboard operation fails.
50
+ ValueError: If text is None or empty.
51
+ """
52
+ if not text or not isinstance(text, str):
53
+ raise ValueError("Text must be a non-empty string")
54
+
55
+ with self._lock:
56
+ try:
57
+ # Copy to system clipboard
58
+ self._write_to_clipboard(text)
59
+ self._clipboard_content = text
60
+ self._is_managed = True
61
+
62
+ logger.info(f"Copied {len(text)} characters to clipboard")
63
+
64
+ except Exception as e:
65
+ self._is_managed = False
66
+ logger.error(f"Failed to copy to clipboard: {e}")
67
+ raise ClipboardError(f"Failed to copy to clipboard: {e}") from e
68
+
69
+ def clear(self) -> None:
70
+ """
71
+ Manually clear the clipboard.
72
+
73
+ Raises:
74
+ ClipboardError: If clipboard clear operation fails.
75
+ """
76
+ with self._lock:
77
+ try:
78
+ # Clear clipboard
79
+ self._write_to_clipboard("")
80
+ self._clipboard_content = None
81
+ self._is_managed = False
82
+
83
+ logger.info("Clipboard cleared")
84
+
85
+ except Exception as e:
86
+ logger.error(f"Failed to clear clipboard: {e}")
87
+ raise ClipboardError(f"Failed to clear clipboard: {e}") from e
88
+
89
+ def is_managed(self) -> bool:
90
+ """
91
+ Check if clipboard is currently managed (content copied by this manager).
92
+
93
+ Returns:
94
+ True if clipboard content is managed, False otherwise.
95
+ """
96
+ with self._lock:
97
+ return self._is_managed
98
+
99
+ @contextmanager
100
+ def temporary_copy(self, text: str):
101
+ """
102
+ Context manager for temporary clipboard operations.
103
+
104
+ Ensures clipboard is cleared when exiting the context.
105
+
106
+ Args:
107
+ text: Text to copy to clipboard.
108
+
109
+ Example:
110
+ with clipboard_manager.temporary_copy("password123"):
111
+ # clipboard contains "password123"
112
+ pass
113
+ # clipboard is now cleared
114
+ """
115
+ try:
116
+ self.copy(text)
117
+ yield
118
+ finally:
119
+ self.clear()
120
+
121
+ @staticmethod
122
+ def _write_to_clipboard(text: str) -> None:
123
+ """
124
+ Write text to system clipboard using xclip.
125
+
126
+ Args:
127
+ text: The text to write.
128
+
129
+ Raises:
130
+ ClipboardError: If clipboard write fails.
131
+ """
132
+ try:
133
+ process = subprocess.Popen(
134
+ ["xclip", "-selection", "clipboard"],
135
+ stdin=subprocess.PIPE,
136
+ stdout=subprocess.PIPE,
137
+ stderr=subprocess.PIPE,
138
+ )
139
+ # Use input parameter and timeout to avoid hanging
140
+ stdout, stderr = process.communicate(
141
+ input=text.encode("utf-8"),
142
+ timeout=1
143
+ )
144
+
145
+ if process.returncode != 0:
146
+ raise ClipboardError(f"xclip failed: {stderr.decode('utf-8', errors='ignore')}")
147
+
148
+ except subprocess.TimeoutExpired:
149
+ process.kill()
150
+ except FileNotFoundError:
151
+ raise ClipboardError(
152
+ "xclip not found. Install it with: sudo apt-get install xclip"
153
+ )
154
+ except Exception as e:
155
+ raise ClipboardError(f"Clipboard write failed: {e}") from e
156
+
157
+ @staticmethod
158
+ def _read_from_clipboard() -> str:
159
+ """
160
+ Read text from system clipboard using xclip.
161
+
162
+ Returns:
163
+ The clipboard content as a string.
164
+
165
+ Raises:
166
+ ClipboardError: If clipboard read fails.
167
+ """
168
+ try:
169
+ process = subprocess.Popen(
170
+ ["xclip", "-selection", "clipboard", "-o"],
171
+ stdout=subprocess.PIPE,
172
+ stderr=subprocess.PIPE,
173
+ )
174
+ stdout, stderr = process.communicate(timeout=5)
175
+
176
+ if process.returncode != 0:
177
+ raise ClipboardError(f"xclip failed: {stderr.decode('utf-8', errors='ignore')}")
178
+
179
+ return stdout.decode("utf-8")
180
+
181
+ except subprocess.TimeoutExpired:
182
+ process.kill()
183
+ raise ClipboardError("xclip operation timed out (5 seconds)")
184
+ except FileNotFoundError:
185
+ raise ClipboardError(
186
+ "xclip not found. Install it with: sudo apt-get install xclip"
187
+ )
188
+ except Exception as e:
189
+ raise ClipboardError(f"Clipboard read failed: {e}") from e
190
+
191
+
192
+ # Global clipboard manager instance
193
+ _clipboard_manager: Optional[ClipboardManager] = None
194
+
195
+
196
+ def get_clipboard_manager() -> ClipboardManager:
197
+ """
198
+ Get or create the global clipboard manager instance.
199
+
200
+ Returns:
201
+ The global ClipboardManager instance.
202
+ """
203
+ global _clipboard_manager
204
+ if _clipboard_manager is None:
205
+ _clipboard_manager = ClipboardManager()
206
+ return _clipboard_manager
@@ -0,0 +1,59 @@
1
+ import os
2
+ import json
3
+ import base64
4
+ from typing import Tuple
5
+
6
+ try:
7
+ from argon2.low_level import hash_secret_raw, Type
8
+ except Exception as e:
9
+ hash_secret_raw = None # type: ignore
10
+
11
+ try:
12
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
13
+ except Exception as e:
14
+ AESGCM = None # type: ignore
15
+
16
+ from .errors import DecryptionError
17
+
18
+
19
+ DEFAULT_TIME = 2
20
+ DEFAULT_MEMORY = 65536 # KiB (64 MiB)
21
+ DEFAULT_PARALLELISM = 1
22
+ DEFAULT_KEY_LEN = 32
23
+
24
+
25
+ def derive_key(password: str, salt: bytes, time_cost: int = DEFAULT_TIME, memory_cost: int = DEFAULT_MEMORY, parallelism: int = DEFAULT_PARALLELISM, key_len: int = DEFAULT_KEY_LEN) -> bytes:
26
+ """Derive a binary key from password and salt using Argon2id.
27
+
28
+ Requires `argon2-cffi` to be installed. Raises ImportError if not available.
29
+ """
30
+ if hash_secret_raw is None:
31
+ raise ImportError("argon2.low_level.hash_secret_raw is required (install argon2-cffi)")
32
+ if isinstance(password, str):
33
+ password = password.encode("utf-8")
34
+ return hash_secret_raw(password, salt, time_cost, memory_cost, parallelism, key_len, Type.ID)
35
+
36
+
37
+ def encrypt(key: bytes, plaintext: bytes) -> Tuple[bytes, bytes]:
38
+ """Encrypt plaintext with AES-GCM. Returns (nonce, ciphertext).
39
+
40
+ Requires `cryptography` package.
41
+ """
42
+ if AESGCM is None:
43
+ raise ImportError("cryptography AESGCM is required (install cryptography)")
44
+ aesgcm = AESGCM(key)
45
+ nonce = os.urandom(12)
46
+ ct = aesgcm.encrypt(nonce, plaintext, associated_data=None)
47
+ return nonce, ct
48
+
49
+
50
+ def decrypt(key: bytes, nonce: bytes, ciphertext: bytes) -> bytes:
51
+ """Decrypt AES-GCM ciphertext. Raises DecryptionError on auth failure."""
52
+ if AESGCM is None:
53
+ raise ImportError("cryptography AESGCM is required (install cryptography)")
54
+ aesgcm = AESGCM(key)
55
+ try:
56
+ pt = aesgcm.decrypt(nonce, ciphertext, associated_data=None)
57
+ except Exception as e:
58
+ raise DecryptionError("decryption failed") from e
59
+ return pt
@@ -0,0 +1,11 @@
1
+ """Custom exceptions for passvault_core."""
2
+
3
+
4
+ class DecryptionError(Exception):
5
+ """Raised when decryption fails or authentication check fails."""
6
+ pass
7
+
8
+
9
+ class ClipboardError(Exception):
10
+ """Raised when clipboard operations fail."""
11
+ pass
@@ -0,0 +1,96 @@
1
+ import json
2
+ from pydantic import BaseModel
3
+ from typing import List, Optional
4
+
5
+ class CredentialSchema(BaseModel):
6
+ username: str
7
+ password: str
8
+
9
+ def to_dict(self) -> dict:
10
+ return {
11
+ "username": self.username,
12
+ "password": self.password,
13
+ }
14
+
15
+ def to_str(self) -> str:
16
+ return json.dumps(self.to_dict())
17
+
18
+ @classmethod
19
+ def from_str(cls, data: str):
20
+ """Create a CredentialSchema from a JSON string."""
21
+ dict_data = json.loads(data)
22
+ return cls(**dict_data)
23
+
24
+ class KDFParamsSchema(BaseModel):
25
+ time_cost: int = 2
26
+ memory_cost: int = 65536
27
+ parallelism: int = 1
28
+
29
+ def to_dict(self) -> dict:
30
+ return {
31
+ "time_cost": self.time_cost,
32
+ "memory_cost": self.memory_cost,
33
+ "parallelism": self.parallelism,
34
+ }
35
+
36
+ class PointerSchema(BaseModel):
37
+ id: str
38
+ vault_id: str
39
+ nonce: bytes
40
+
41
+ def to_dict(self) -> dict:
42
+ return {
43
+ "id": self.id,
44
+ "vault_id": self.vault_id,
45
+ "nonce": self.nonce,
46
+ }
47
+
48
+ @classmethod
49
+ def from_dict(cls, data: dict):
50
+ """Create a PointerSchema from a plain dict.
51
+
52
+ This helper prefers the Pydantic v2 API (`model_validate`) when
53
+ available, falls back to v1 (`parse_obj`) and finally to direct
54
+ construction. It will raise the appropriate Pydantic
55
+ ValidationError on invalid input.
56
+ """
57
+ # Pydantic v2
58
+ if hasattr(cls, "model_validate"):
59
+ return cls.model_validate(data)
60
+ # Pydantic v1
61
+ if hasattr(cls, "parse_obj"):
62
+ return cls.parse_obj(data)
63
+ # Fallback
64
+ return cls(**data)
65
+
66
+ class VaultSchema(BaseModel):
67
+ id: str
68
+ salt: bytes
69
+ encrypted_pointers: List[Optional[PointerSchema]] = []
70
+ kdf_params: KDFParamsSchema = KDFParamsSchema()
71
+
72
+ def to_dict(self) -> dict:
73
+ return {
74
+ "id": self.id,
75
+ "salt": self.salt,
76
+ "encrypted_pointers": [p.to_dict() if p else None for p in self.encrypted_pointers],
77
+ "kdf_params": self.kdf_params.to_dict(),
78
+ }
79
+
80
+ @classmethod
81
+ def from_dict(self, data: dict):
82
+ """Create a VaultSchema from a plain dict.
83
+
84
+ This helper prefers the Pydantic v2 API (`model_validate`) when
85
+ available, falls back to v1 (`parse_obj`) and finally to direct
86
+ construction. It will raise the appropriate Pydantic
87
+ ValidationError on invalid input.
88
+ """
89
+ # Pydantic v2
90
+ if hasattr(self, "model_validate"):
91
+ return self.model_validate(data)
92
+ # Pydantic v1
93
+ if hasattr(self, "parse_obj"):
94
+ return self.parse_obj(data)
95
+ # Fallback
96
+ return self(**data)