hushvault-cli 0.1.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,159 @@
1
+ Metadata-Version: 2.4
2
+ Name: hushvault-cli
3
+ Version: 0.1.0
4
+ Summary: A zero-dependency secrets manager built entirely from the Python standard library
5
+ Author: Pranav Salian
6
+ Project-URL: Repository, https://github.com/Pranav-s-salian/Vault-ai
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+
10
+ # Vault
11
+
12
+ A zero-dependency secrets manager. Everything is built from the Python
13
+ standard library — no `requirements.txt`, no pip packages. See
14
+ [STDLIB.md](STDLIB.md) for exactly which stdlib modules replace which
15
+ third-party packages, and [THREAT_MODEL.md](THREAT_MODEL.md) for what
16
+ Vault does and does not protect against.
17
+
18
+ ## Installation
19
+
20
+ Nothing beyond Python 3.10+ is required — clone the repository, then run
21
+ the installer for your OS:
22
+
23
+ macOS/Linux:
24
+
25
+ ```sh
26
+ chmod +x install.sh && ./install.sh
27
+ ```
28
+
29
+ Windows:
30
+
31
+ ```
32
+ install.bat
33
+ ```
34
+
35
+ After installing, `vault` works as a plain command from any directory —
36
+ no need to `cd` into the repo or type `python -m vault` — for example:
37
+
38
+ ```
39
+ vault init
40
+ vault set KEY value
41
+ vault run -- your-command
42
+ vault ui
43
+ ```
44
+
45
+ ### macOS
46
+
47
+ `install.sh` creates a wrapper at `/usr/local/bin/vault` (using `sudo`
48
+ only if that directory isn't already writable by you). The master key is
49
+ stored in the macOS Keychain (service `vault-cli`, account `master-key`)
50
+ via the built-in `security` command-line tool.
51
+
52
+ ### Windows
53
+
54
+ `install.bat` creates `vault.bat` in this project folder and, if the
55
+ folder isn't already on your `PATH`, adds it there via `setx` — reopen
56
+ your terminal afterward for that change to take effect. The master key is
57
+ stored, DPAPI-encrypted, at `%APPDATA%\vault-cli\master.key`. DPAPI ties
58
+ the encryption to your Windows user account, so only that account can
59
+ decrypt it.
60
+
61
+ ### Running without installing
62
+
63
+ You can also run Vault directly from the repo without installing a global
64
+ command — `./run.sh init` (macOS/Linux) or `python -m vault init`
65
+ (Windows), from inside the project directory.
66
+
67
+ ## CLI commands
68
+
69
+ All commands operate on the vault file at `~/.vault/secrets.json` by
70
+ default.
71
+
72
+ ### `vault init`
73
+
74
+ Generate a new 32-byte master key and store it in the OS keychain.
75
+
76
+ ```
77
+ $ vault init
78
+ vault initialized — master key stored in the OS keychain
79
+ ```
80
+
81
+ ### `vault set KEY VALUE`
82
+
83
+ Encrypt and store a secret.
84
+
85
+ ```
86
+ $ vault set DB_PASSWORD hunter2
87
+ stored 'DB_PASSWORD'
88
+ ```
89
+
90
+ ### `vault get KEY`
91
+
92
+ Decrypt and print a secret's value. A warning is printed to stderr first,
93
+ since the value will appear in your terminal (and possibly shell
94
+ history).
95
+
96
+ ```
97
+ $ vault get DB_PASSWORD
98
+ warning: printing a secret to the terminal may be recorded in your shell history
99
+ hunter2
100
+ ```
101
+
102
+ ### `vault list`
103
+
104
+ List secret names only — values are never shown.
105
+
106
+ ```
107
+ $ vault list
108
+ DB_PASSWORD
109
+ TEST_KEY
110
+ ```
111
+
112
+ ### `vault delete KEY`
113
+
114
+ Remove a secret.
115
+
116
+ ```
117
+ $ vault delete DB_PASSWORD
118
+ deleted 'DB_PASSWORD'
119
+ ```
120
+
121
+ ### `vault run -- COMMAND...`
122
+
123
+ Decrypt every secret, inject them into the environment alongside the rest
124
+ of `os.environ`, and run `COMMAND`. Decrypted values live only in memory
125
+ for the duration of the subprocess.
126
+
127
+ ```
128
+ $ vault run -- python3 -c "import os; print(os.environ['TEST_KEY'])"
129
+ hello123
130
+ ```
131
+
132
+ ### `vault ui`
133
+
134
+ Launch the local web UI (see below).
135
+
136
+ ```
137
+ $ vault ui
138
+ vault UI running at http://127.0.0.1:52341/?token=<random-token>
139
+ press Ctrl+C to stop
140
+ ```
141
+
142
+ ## The UI
143
+
144
+ `vault ui` starts a local HTTP server bound to `127.0.0.1` only (never
145
+ `0.0.0.0`), generates a random session token, and opens your browser to
146
+ the vault page with that token pre-filled in the URL. Every API request
147
+ must include the same token or it is rejected with `403`.
148
+
149
+ From the page you can:
150
+
151
+ - see a table of secret names (values are hidden by default),
152
+ - add a new secret via the form,
153
+ - click **Reveal** to briefly show a secret's value (it re-hides itself
154
+ after a few seconds),
155
+ - click **Delete** to remove a secret.
156
+
157
+ No frameworks, no build step, no external CDN — the whole UI is one
158
+ static `index.html` with inline `<style>` and `<script>`, served by
159
+ `http.server` from the stdlib.
@@ -0,0 +1,15 @@
1
+ vault/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ vault/__main__.py,sha256=GXj0jsKoHwK_oG8_lNstlIqvDRDM2watruECr-WKsK8,88
3
+ vault/cipher.py,sha256=WYAdX6eFxJcpn8etmsD-_K7at_afP8mgswUX6IMqMMY,5474
4
+ vault/cli.py,sha256=36VHgdbVY-Hwu0jFuJU4ijaz9O6aYBZPGT4bV8q_sU4,6156
5
+ vault/keychain.py,sha256=sbgSBe6uflaCV7ospkLZYXi1MbYQzSuhWVwPzxWvWKQ,3963
6
+ vault/runner.py,sha256=R9f_3ON24gcZCUJoFso0uOoBf-F_DSX9AGA327L8nMw,880
7
+ vault/store.py,sha256=OCNm2S3MEpoCMgZQSk2u1Y_cE9lGGg1856Nnf4TLNQA,1947
8
+ vault/ui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ vault/ui/server.py,sha256=9PispW3TzkJszea38xMRHXhQ6kdmJYnWkaomT8PGO7Y,5136
10
+ vault/ui/static/index.html,sha256=2FZmK9UguMmbQBUm83z-qjUoJy2JtcSt9R3DvkXNf0I,14289
11
+ hushvault_cli-0.1.0.dist-info/METADATA,sha256=TfnWbwFrfRrparQo9rcjyNyHAE09OY8ufy4p1zH-hjY,4165
12
+ hushvault_cli-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
13
+ hushvault_cli-0.1.0.dist-info/entry_points.txt,sha256=10OpHfWkme8X-4t0O_3Zjfd2BfmUkxGqBBL2hbl6w1g,41
14
+ hushvault_cli-0.1.0.dist-info/top_level.txt,sha256=9e6d_YtmUyWnk8SQbezOngdSMUxhE38mT8uUTWox24o,6
15
+ hushvault_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ vault = vault.cli:main
@@ -0,0 +1 @@
1
+ vault
vault/__init__.py ADDED
File without changes
vault/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from vault.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
vault/cipher.py ADDED
@@ -0,0 +1,150 @@
1
+ """
2
+ vault/cipher.py — Encryption engine for Vault CLI.
3
+
4
+ Zero third-party dependencies. Uses only Python stdlib:
5
+ hashlib, hmac, secrets, base64
6
+
7
+ Design:
8
+ HMAC-derived keystream (CTR-mode analogue) + encrypt-then-MAC.
9
+ Two domain-separated keys are derived per operation — one for
10
+ encryption, one for authentication — so the two operations can
11
+ never interfere.
12
+
13
+ The nonce is ALWAYS generated internally by encrypt(); callers
14
+ never supply it. Reusing a nonce with the same key would break
15
+ the entire scheme.
16
+ """
17
+
18
+ import base64
19
+ import hashlib
20
+ import hmac
21
+ import secrets
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Key derivation
26
+ # ---------------------------------------------------------------------------
27
+
28
+ def derive_keys(master_key: bytes, nonce: bytes) -> tuple[bytes, bytes]:
29
+ """
30
+ Derive two domain-separated 32-byte keys from master_key + nonce.
31
+
32
+ Returns:
33
+ (enc_key, mac_key)
34
+ enc_key — used only to produce the encryption keystream
35
+ mac_key — used only to compute / verify the authentication tag
36
+ """
37
+ enc_key = hmac.new(master_key, b"encrypt" + nonce, hashlib.sha256).digest()
38
+ mac_key = hmac.new(master_key, b"auth" + nonce, hashlib.sha256).digest()
39
+ return enc_key, mac_key
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Keystream generation
44
+ # ---------------------------------------------------------------------------
45
+
46
+ def generate_keystream(enc_key: bytes, nonce: bytes, length: int) -> bytes:
47
+ """
48
+ Produce `length` pseudo-random bytes using HMAC-SHA256 in counter mode.
49
+
50
+ Each 32-byte block is HMAC(enc_key, nonce || counter).
51
+ Blocks are concatenated and truncated to exactly `length` bytes.
52
+
53
+ Args:
54
+ enc_key: 32-byte derived encryption key
55
+ nonce: per-encrypt random value
56
+ length: number of keystream bytes required (may be 0)
57
+
58
+ Returns:
59
+ Exactly `length` bytes of keystream.
60
+ """
61
+ output: bytes = b""
62
+ counter: int = 0
63
+ while len(output) < length:
64
+ block = hmac.new(
65
+ enc_key,
66
+ nonce + counter.to_bytes(4, "big"),
67
+ hashlib.sha256,
68
+ ).digest()
69
+ output += block
70
+ counter += 1
71
+ return output[:length]
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Encrypt
76
+ # ---------------------------------------------------------------------------
77
+
78
+ def encrypt(master_key: bytes, plaintext: bytes) -> dict:
79
+ """
80
+ Encrypt `plaintext` under `master_key`.
81
+
82
+ Generates a fresh random nonce on every call — nonces are NEVER
83
+ reused even if the same key and plaintext are supplied twice.
84
+
85
+ Returns a dict with three base64-encoded string fields:
86
+ "nonce" — 16-byte random value used for this operation
87
+ "ciphertext" — XOR of plaintext with the keystream
88
+ "tag" — HMAC-SHA256 over (nonce || ciphertext), for integrity
89
+
90
+ The tag is computed over the *ciphertext* (encrypt-then-MAC), which
91
+ means tampered bytes are detected before any decryption occurs.
92
+ """
93
+ # Fresh nonce every call — this is non-negotiable.
94
+ nonce: bytes = secrets.token_bytes(16)
95
+
96
+ enc_key, mac_key = derive_keys(master_key, nonce)
97
+
98
+ keystream: bytes = generate_keystream(enc_key, nonce, len(plaintext))
99
+ ciphertext: bytes = bytes(p ^ k for p, k in zip(plaintext, keystream))
100
+
101
+ # Authenticate nonce + ciphertext so that any tampering is detected.
102
+ tag: bytes = hmac.new(mac_key, nonce + ciphertext, hashlib.sha256).digest()
103
+
104
+ return {
105
+ "nonce": base64.b64encode(nonce).decode(),
106
+ "ciphertext": base64.b64encode(ciphertext).decode(),
107
+ "tag": base64.b64encode(tag).decode(),
108
+ }
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # Decrypt
113
+ # ---------------------------------------------------------------------------
114
+
115
+ def decrypt(master_key: bytes, blob: dict) -> bytes:
116
+ """
117
+ Decrypt a blob produced by encrypt().
118
+
119
+ The tag is verified with a constant-time comparison BEFORE any
120
+ decryption happens. If the tag is wrong (tampered ciphertext, wrong
121
+ key, corrupted data) a ValueError is raised immediately and no
122
+ plaintext — not even a partial byte — is returned.
123
+
124
+ Args:
125
+ master_key: same key that was passed to encrypt()
126
+ blob: dict with "nonce", "ciphertext", "tag" keys
127
+
128
+ Returns:
129
+ Original plaintext bytes.
130
+
131
+ Raises:
132
+ ValueError: if the integrity check fails for any reason.
133
+ KeyError: if the blob is missing a required field.
134
+ """
135
+ nonce: bytes = base64.b64decode(blob["nonce"])
136
+ ciphertext: bytes = base64.b64decode(blob["ciphertext"])
137
+ tag: bytes = base64.b64decode(blob["tag"])
138
+
139
+ enc_key, mac_key = derive_keys(master_key, nonce)
140
+
141
+ # Verify integrity first — constant-time to avoid timing attacks.
142
+ expected_tag: bytes = hmac.new(
143
+ mac_key, nonce + ciphertext, hashlib.sha256
144
+ ).digest()
145
+
146
+ if not hmac.compare_digest(tag, expected_tag):
147
+ raise ValueError("tampered or corrupted data — refusing to decrypt")
148
+
149
+ keystream: bytes = generate_keystream(enc_key, nonce, len(ciphertext))
150
+ return bytes(c ^ k for c, k in zip(ciphertext, keystream))
vault/cli.py ADDED
@@ -0,0 +1,192 @@
1
+ """
2
+ vault/cli.py — Command-line interface for Vault.
3
+
4
+ Subcommands:
5
+ vault init
6
+ vault set KEY VALUE
7
+ vault get KEY
8
+ vault list
9
+ vault delete KEY
10
+ vault run -- COMMAND...
11
+ vault ui
12
+ """
13
+
14
+ import argparse
15
+ import pathlib
16
+ import sys
17
+
18
+ from vault import keychain, store
19
+ from vault.runner import run_with_secrets
20
+
21
+ DEFAULT_VAULT_PATH = pathlib.Path.home() / ".vault" / "secrets.json"
22
+
23
+ KNOWN_COMMANDS = ["init", "set", "get", "list", "delete", "run", "ui"]
24
+
25
+ BOLD = "\033[1m"
26
+ CYAN = "\033[36m"
27
+ RESET = "\033[0m"
28
+
29
+
30
+ def _use_color() -> bool:
31
+ return sys.stdout.isatty()
32
+
33
+
34
+ def _supports_unicode_box() -> bool:
35
+ encoding = getattr(sys.stdout, "encoding", None) or ""
36
+ try:
37
+ "┌─┐│└┘—".encode(encoding)
38
+ return True
39
+ except (UnicodeEncodeError, LookupError):
40
+ return False
41
+
42
+
43
+ def print_menu() -> None:
44
+ """Print the friendly no-args / --help welcome screen."""
45
+ bold = BOLD if _use_color() else ""
46
+ cyan = CYAN if _use_color() else ""
47
+ reset = RESET if _use_color() else ""
48
+ unicode_ok = _supports_unicode_box()
49
+
50
+ dash = "─" if unicode_ok else "-"
51
+ top_left, top_right = ("┌", "┐") if unicode_ok else ("+", "+")
52
+ bottom_left, bottom_right = ("└", "┘") if unicode_ok else ("+", "+")
53
+ side = "│" if unicode_ok else "|"
54
+ em_dash = "—" if unicode_ok else "-"
55
+
56
+ title = f"VAULT {em_dash} zero-dependency secrets manager"
57
+ inner_width = len(title) + 4 # 2 spaces of padding on each side
58
+ print(f"{bold}{top_left}{dash * inner_width}{top_right}{reset}")
59
+ print(f"{bold}{side} {title} {side}{reset}")
60
+ print(f"{bold}{bottom_left}{dash * inner_width}{bottom_right}{reset}")
61
+ print()
62
+ print("Usage: vault <command> [arguments]")
63
+ print()
64
+ print("Commands:")
65
+ commands = [
66
+ ("init", "", "Set up the vault and generate a master key"),
67
+ ("set", "<key> <value>", "Store a new secret"),
68
+ ("get", "<key>", "Retrieve a secret's value"),
69
+ ("list", "", "List all stored secret names"),
70
+ ("delete", "<key>", "Remove a secret"),
71
+ ("run", "-- <command>", "Run a command with secrets injected as env vars"),
72
+ ("ui", "", "Launch the local dashboard in your browser"),
73
+ ]
74
+ for name, args_str, desc in commands:
75
+ label = f"{name} {args_str}".rstrip()
76
+ print(f" {cyan}{label:<21}{reset} {desc}")
77
+ print()
78
+ print("Examples:")
79
+ print(" vault init")
80
+ print(" vault set API_KEY sk_live_abc123")
81
+ print(" vault run -- python app.py")
82
+ print(" vault ui")
83
+ print()
84
+ print("Run 'vault <command> --help' for details on a specific command.")
85
+
86
+
87
+ def build_parser() -> argparse.ArgumentParser:
88
+ parser = argparse.ArgumentParser(prog="vault", description="Zero-dependency secrets manager")
89
+ subparsers = parser.add_subparsers(dest="subcommand", required=True)
90
+
91
+ subparsers.add_parser("init", help="generate and store a new master key")
92
+
93
+ set_parser = subparsers.add_parser("set", help="store a secret")
94
+ set_parser.add_argument("name")
95
+ set_parser.add_argument("value")
96
+
97
+ get_parser = subparsers.add_parser("get", help="retrieve a secret")
98
+ get_parser.add_argument("name")
99
+
100
+ subparsers.add_parser("list", help="list secret names")
101
+
102
+ delete_parser = subparsers.add_parser("delete", help="remove a secret")
103
+ delete_parser.add_argument("name")
104
+
105
+ run_parser = subparsers.add_parser("run", help="run a command with secrets injected as env vars")
106
+ run_parser.add_argument("command", nargs=argparse.REMAINDER)
107
+
108
+ subparsers.add_parser("ui", help="launch the local web UI")
109
+
110
+ return parser
111
+
112
+
113
+ def main(argv: list[str] | None = None) -> int:
114
+ if argv is None:
115
+ argv = sys.argv[1:]
116
+
117
+ if len(argv) == 0 or argv[0] in ("-h", "--help"):
118
+ print_menu()
119
+ return 0
120
+
121
+ if argv[0] not in KNOWN_COMMANDS:
122
+ print(f"error: '{argv[0]}' is not a recognized vault command\n", file=sys.stderr)
123
+ print_menu()
124
+ return 1
125
+
126
+ parser = build_parser()
127
+ args = parser.parse_args(argv)
128
+
129
+ try:
130
+ if args.subcommand == "init":
131
+ keychain.init_master_key()
132
+ print("vault initialized — master key stored in the OS keychain")
133
+ return 0
134
+
135
+ if args.subcommand == "set":
136
+ master_key = keychain.get_master_key()
137
+ store.set_secret(DEFAULT_VAULT_PATH, master_key, args.name, args.value)
138
+ print(f"stored '{args.name}'")
139
+ return 0
140
+
141
+ if args.subcommand == "get":
142
+ master_key = keychain.get_master_key()
143
+ value = store.get_secret(DEFAULT_VAULT_PATH, master_key, args.name)
144
+ print(
145
+ "warning: printing a secret to the terminal may be recorded in your "
146
+ "shell history",
147
+ file=sys.stderr,
148
+ )
149
+ print(value)
150
+ return 0
151
+
152
+ if args.subcommand == "list":
153
+ for name in store.list_secrets(DEFAULT_VAULT_PATH):
154
+ print(name)
155
+ return 0
156
+
157
+ if args.subcommand == "delete":
158
+ store.delete_secret(DEFAULT_VAULT_PATH, args.name)
159
+ print(f"deleted '{args.name}'")
160
+ return 0
161
+
162
+ if args.subcommand == "run":
163
+ command = args.command
164
+ # argparse.REMAINDER keeps a leading "--" if present; strip it.
165
+ if command and command[0] == "--":
166
+ command = command[1:]
167
+ if not command:
168
+ print("error: no command given to `vault run`", file=sys.stderr)
169
+ return 1
170
+ master_key = keychain.get_master_key()
171
+ return run_with_secrets(DEFAULT_VAULT_PATH, master_key, command)
172
+
173
+ if args.subcommand == "ui":
174
+ from vault.ui.server import serve
175
+ serve(DEFAULT_VAULT_PATH)
176
+ return 0
177
+
178
+ except KeyError as e:
179
+ print(f"error: {e}", file=sys.stderr)
180
+ return 1
181
+ except RuntimeError as e:
182
+ print(f"error: {e}", file=sys.stderr)
183
+ return 1
184
+ except ValueError as e:
185
+ print(f"error: {e}", file=sys.stderr)
186
+ return 1
187
+
188
+ return 1
189
+
190
+
191
+ if __name__ == "__main__":
192
+ sys.exit(main())
vault/keychain.py ADDED
@@ -0,0 +1,132 @@
1
+ """
2
+ vault/keychain.py — OS-native master key storage.
3
+
4
+ The 32-byte master key never touches the vault's own JSON file. Instead it
5
+ is handed off to the operating system's native secret storage:
6
+
7
+ macOS -> Keychain, via the `security` CLI (generic password item).
8
+ Windows -> DPAPI, via PowerShell's SecureString cmdlets. The DPAPI
9
+ ciphertext (bound to the current Windows user account) is
10
+ written to a file under %APPDATA%/vault-cli/master.key.
11
+
12
+ Only stdlib (subprocess, platform, secrets, pathlib, os) is used — no
13
+ third-party keyring library.
14
+ """
15
+
16
+ import os
17
+ import pathlib
18
+ import platform
19
+ import secrets
20
+ import subprocess
21
+
22
+ SERVICE_NAME = "vault-cli"
23
+ ACCOUNT_NAME = "master-key"
24
+
25
+
26
+ def _windows_key_path() -> pathlib.Path:
27
+ appdata = os.environ.get("APPDATA")
28
+ base = pathlib.Path(appdata) if appdata else pathlib.Path.home() / "AppData" / "Roaming"
29
+ return base / "vault-cli" / "master.key"
30
+
31
+
32
+ def _init_macos(key_hex: str) -> None:
33
+ # -U allows updating an existing item instead of failing if one exists.
34
+ subprocess.run(
35
+ [
36
+ "security", "add-generic-password",
37
+ "-s", SERVICE_NAME,
38
+ "-a", ACCOUNT_NAME,
39
+ "-w", key_hex,
40
+ "-U",
41
+ ],
42
+ check=True,
43
+ capture_output=True,
44
+ )
45
+
46
+
47
+ def _get_macos() -> str:
48
+ result = subprocess.run(
49
+ [
50
+ "security", "find-generic-password",
51
+ "-s", SERVICE_NAME,
52
+ "-a", ACCOUNT_NAME,
53
+ "-w",
54
+ ],
55
+ check=True,
56
+ capture_output=True,
57
+ text=True,
58
+ )
59
+ return result.stdout.strip()
60
+
61
+
62
+ def _init_windows(key_hex: str) -> None:
63
+ key_path = _windows_key_path()
64
+ key_path.parent.mkdir(parents=True, exist_ok=True)
65
+
66
+ script = (
67
+ "$secure = ConvertTo-SecureString -String $env:VAULT_MASTER_KEY_HEX "
68
+ "-AsPlainText -Force; "
69
+ "ConvertFrom-SecureString -SecureString $secure "
70
+ f"| Set-Content -Path '{key_path}' -Encoding ASCII"
71
+ )
72
+ env = dict(os.environ)
73
+ env["VAULT_MASTER_KEY_HEX"] = key_hex
74
+ subprocess.run(
75
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
76
+ check=True,
77
+ capture_output=True,
78
+ text=True,
79
+ env=env,
80
+ )
81
+
82
+
83
+ def _get_windows() -> str:
84
+ key_path = _windows_key_path()
85
+ if not key_path.exists():
86
+ raise RuntimeError("vault not initialized — run `vault init` first")
87
+
88
+ script = (
89
+ f"$encrypted = Get-Content -Path '{key_path}' -Encoding ASCII; "
90
+ "$secure = ConvertTo-SecureString -String $encrypted; "
91
+ "$bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure); "
92
+ "[System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)"
93
+ )
94
+ result = subprocess.run(
95
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
96
+ check=True,
97
+ capture_output=True,
98
+ text=True,
99
+ )
100
+ return result.stdout.strip()
101
+
102
+
103
+ def init_master_key() -> bytes:
104
+ """Generate a fresh 32-byte master key and store it in the OS keychain."""
105
+ key = secrets.token_bytes(32)
106
+ key_hex = key.hex()
107
+
108
+ system = platform.system()
109
+ if system == "Darwin":
110
+ _init_macos(key_hex)
111
+ elif system == "Windows":
112
+ _init_windows(key_hex)
113
+ else:
114
+ raise NotImplementedError(f"vault keychain support is not implemented for '{system}'")
115
+
116
+ return key
117
+
118
+
119
+ def get_master_key() -> bytes:
120
+ """Retrieve the master key previously stored by init_master_key()."""
121
+ system = platform.system()
122
+ if system == "Darwin":
123
+ try:
124
+ key_hex = _get_macos()
125
+ except subprocess.CalledProcessError:
126
+ raise RuntimeError("vault not initialized — run `vault init` first")
127
+ elif system == "Windows":
128
+ key_hex = _get_windows()
129
+ else:
130
+ raise NotImplementedError(f"vault keychain support is not implemented for '{system}'")
131
+
132
+ return bytes.fromhex(key_hex)
vault/runner.py ADDED
@@ -0,0 +1,28 @@
1
+ """
2
+ vault/runner.py — Run a subprocess with vault secrets injected into its
3
+ environment.
4
+
5
+ Decrypted secret values exist only as local variables for the duration of
6
+ this call; they are never written to a file or logged, and the dict holding
7
+ them goes out of scope as soon as the subprocess finishes.
8
+ """
9
+
10
+ import os
11
+ import pathlib
12
+ import subprocess
13
+
14
+ from vault.store import list_secrets, get_secret
15
+
16
+
17
+ def run_with_secrets(vault_path: pathlib.Path, master_key: bytes, command: list[str]) -> int:
18
+ """Decrypt every secret in the vault, merge into the environment, and
19
+ run `command`. Returns the subprocess's exit code."""
20
+ decrypted = {
21
+ name: get_secret(vault_path, master_key, name)
22
+ for name in list_secrets(vault_path)
23
+ }
24
+
25
+ merged_env = {**os.environ, **decrypted}
26
+
27
+ result = subprocess.run(command, env=merged_env)
28
+ return result.returncode
vault/store.py ADDED
@@ -0,0 +1,61 @@
1
+ """
2
+ vault/store.py — Vault file storage layer.
3
+
4
+ Loads and saves the vault's JSON file on disk, and provides secret-level
5
+ operations (set/get/list/delete) built on top of vault/cipher.py.
6
+
7
+ Vault file format:
8
+ {
9
+ "SECRET_NAME": {"nonce": "...", "ciphertext": "...", "tag": "..."},
10
+ ...
11
+ }
12
+ """
13
+
14
+ import json
15
+ import pathlib
16
+
17
+ from vault.cipher import decrypt, encrypt
18
+
19
+
20
+ def load_vault(path: pathlib.Path) -> dict:
21
+ """Load the vault JSON file. Returns {} if it doesn't exist."""
22
+ if not path.exists():
23
+ return {}
24
+ with path.open("r", encoding="utf-8") as f:
25
+ return json.load(f)
26
+
27
+
28
+ def save_vault(path: pathlib.Path, data: dict) -> None:
29
+ """Write the vault as pretty-printed JSON, creating parent dirs if needed."""
30
+ path.parent.mkdir(parents=True, exist_ok=True)
31
+ with path.open("w", encoding="utf-8") as f:
32
+ json.dump(data, f, indent=2, sort_keys=True)
33
+
34
+
35
+ def set_secret(path: pathlib.Path, master_key: bytes, name: str, value: str) -> None:
36
+ """Encrypt `value` and store it under `name`."""
37
+ data = load_vault(path)
38
+ data[name] = encrypt(master_key, value.encode("utf-8"))
39
+ save_vault(path, data)
40
+
41
+
42
+ def get_secret(path: pathlib.Path, master_key: bytes, name: str) -> str:
43
+ """Decrypt and return the value stored under `name`."""
44
+ data = load_vault(path)
45
+ if name not in data:
46
+ raise KeyError(f"no secret named '{name}' in vault")
47
+ return decrypt(master_key, data[name]).decode("utf-8")
48
+
49
+
50
+ def list_secrets(path: pathlib.Path) -> list[str]:
51
+ """Return the names of all secrets in the vault — never their values."""
52
+ return sorted(load_vault(path).keys())
53
+
54
+
55
+ def delete_secret(path: pathlib.Path, name: str) -> None:
56
+ """Remove the entry named `name`. Raises KeyError if it doesn't exist."""
57
+ data = load_vault(path)
58
+ if name not in data:
59
+ raise KeyError(f"no secret named '{name}' in vault")
60
+ del data[name]
61
+ save_vault(path, data)
vault/ui/__init__.py ADDED
File without changes
vault/ui/server.py ADDED
@@ -0,0 +1,139 @@
1
+ """
2
+ vault/ui/server.py — Local web UI for Vault.
3
+
4
+ Uses only http.server from the stdlib (no Flask, no other framework).
5
+ Binds to 127.0.0.1 only. A random per-session token, generated at startup,
6
+ must be supplied on every API request (query param `token` or header
7
+ `X-Vault-Token`) or the request is rejected with 403.
8
+ """
9
+
10
+ import json
11
+ import pathlib
12
+ import secrets
13
+ import threading
14
+ import webbrowser
15
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
16
+ from urllib.parse import parse_qs, urlparse
17
+
18
+ from vault import keychain, store
19
+
20
+ STATIC_DIR = pathlib.Path(__file__).parent / "static"
21
+ HOST = "127.0.0.1"
22
+
23
+
24
+ def _make_handler(vault_path: pathlib.Path, master_key: bytes, token: str):
25
+ class Handler(BaseHTTPRequestHandler):
26
+ def _check_token(self) -> bool:
27
+ parsed = urlparse(self.path)
28
+ query = parse_qs(parsed.query)
29
+ supplied = query.get("token", [None])[0] or self.headers.get("X-Vault-Token")
30
+ return supplied == token
31
+
32
+ def _send_json(self, status: int, payload) -> None:
33
+ body = json.dumps(payload).encode("utf-8")
34
+ self.send_response(status)
35
+ self.send_header("Content-Type", "application/json")
36
+ self.send_header("Content-Length", str(len(body)))
37
+ self.end_headers()
38
+ self.wfile.write(body)
39
+
40
+ def _read_json_body(self) -> dict:
41
+ length = int(self.headers.get("Content-Length", 0))
42
+ raw = self.rfile.read(length) if length else b"{}"
43
+ return json.loads(raw.decode("utf-8"))
44
+
45
+ def do_GET(self):
46
+ parsed = urlparse(self.path)
47
+
48
+ if parsed.path == "/":
49
+ index_html = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
50
+ body = index_html.encode("utf-8")
51
+ self.send_response(200)
52
+ self.send_header("Content-Type", "text/html; charset=utf-8")
53
+ self.send_header("Content-Length", str(len(body)))
54
+ self.end_headers()
55
+ self.wfile.write(body)
56
+ return
57
+
58
+ if parsed.path == "/api/keys":
59
+ if not self._check_token():
60
+ self._send_json(403, {"error": "invalid or missing token"})
61
+ return
62
+ self._send_json(200, store.list_secrets(vault_path))
63
+ return
64
+
65
+ self._send_json(404, {"error": "not found"})
66
+
67
+ def do_POST(self):
68
+ parsed = urlparse(self.path)
69
+
70
+ if not self._check_token():
71
+ self._send_json(403, {"error": "invalid or missing token"})
72
+ return
73
+
74
+ if parsed.path == "/api/keys":
75
+ try:
76
+ payload = self._read_json_body()
77
+ name, value = payload["name"], payload["value"]
78
+ except (json.JSONDecodeError, KeyError):
79
+ self._send_json(400, {"error": "expected JSON body {name, value}"})
80
+ return
81
+ store.set_secret(vault_path, master_key, name, value)
82
+ self._send_json(200, {"ok": True})
83
+ return
84
+
85
+ if parsed.path.startswith("/api/keys/") and parsed.path.endswith("/reveal"):
86
+ name = parsed.path[len("/api/keys/"):-len("/reveal")]
87
+ try:
88
+ value = store.get_secret(vault_path, master_key, name)
89
+ except KeyError as e:
90
+ self._send_json(404, {"error": str(e)})
91
+ return
92
+ self._send_json(200, {"value": value})
93
+ return
94
+
95
+ self._send_json(404, {"error": "not found"})
96
+
97
+ def do_DELETE(self):
98
+ parsed = urlparse(self.path)
99
+
100
+ if not self._check_token():
101
+ self._send_json(403, {"error": "invalid or missing token"})
102
+ return
103
+
104
+ if parsed.path.startswith("/api/keys/"):
105
+ name = parsed.path[len("/api/keys/"):]
106
+ try:
107
+ store.delete_secret(vault_path, name)
108
+ except KeyError as e:
109
+ self._send_json(404, {"error": str(e)})
110
+ return
111
+ self._send_json(200, {"ok": True})
112
+ return
113
+
114
+ self._send_json(404, {"error": "not found"})
115
+
116
+ def log_message(self, format, *args):
117
+ pass # silence default request logging to stderr
118
+
119
+ return Handler
120
+
121
+
122
+ def serve(vault_path: pathlib.Path, port: int = 0) -> None:
123
+ """Start the UI server and open it in the browser. Blocks until Ctrl+C."""
124
+ master_key = keychain.get_master_key()
125
+ token = secrets.token_urlsafe(16)
126
+
127
+ handler_cls = _make_handler(vault_path, master_key, token)
128
+ httpd = ThreadingHTTPServer((HOST, port), handler_cls)
129
+ actual_port = httpd.server_address[1]
130
+ url = f"http://{HOST}:{actual_port}/?token={token}"
131
+
132
+ threading.Thread(target=lambda: webbrowser.open(url), daemon=True).start()
133
+
134
+ print(f"vault UI running at {url}")
135
+ print("press Ctrl+C to stop")
136
+ try:
137
+ httpd.serve_forever()
138
+ except KeyboardInterrupt:
139
+ httpd.shutdown()
@@ -0,0 +1,464 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>Vault</title>
6
+ <style>
7
+ :root {
8
+ color-scheme: light dark;
9
+ --bg: #f4f5f7;
10
+ --bg-elevated: #fbfbfc;
11
+ --panel: #ffffff;
12
+ --panel-hover: #f7f8fa;
13
+ --border: #e3e4e8;
14
+ --border-strong: #d1d3d9;
15
+ --text: #16171a;
16
+ --muted: #6b6d76;
17
+ --muted-2: #9497a1;
18
+ --accent: #2f5eff;
19
+ --accent-hover: #1f4bef;
20
+ --accent-soft: #eaefff;
21
+ --danger: #e0393e;
22
+ --danger-soft: #fdecec;
23
+ --success: #1a9c6e;
24
+ --success-soft: #e9f9f1;
25
+ --shadow: 0 1px 2px rgba(20, 21, 26, 0.04), 0 8px 24px rgba(20, 21, 26, 0.06);
26
+ }
27
+ @media (prefers-color-scheme: dark) {
28
+ :root {
29
+ --bg: #0f1013;
30
+ --bg-elevated: #14151a;
31
+ --panel: #1a1b21;
32
+ --panel-hover: #20212a;
33
+ --border: #2b2d36;
34
+ --border-strong: #3a3c48;
35
+ --text: #f1f2f4;
36
+ --muted: #9a9ca6;
37
+ --muted-2: #6f7180;
38
+ --accent: #5b7fff;
39
+ --accent-hover: #7a97ff;
40
+ --accent-soft: #1c2440;
41
+ --danger: #f0605f;
42
+ --danger-soft: #331e1f;
43
+ --success: #3ecf94;
44
+ --success-soft: #123328;
45
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 12px 32px rgba(0, 0, 0, 0.35);
46
+ }
47
+ }
48
+
49
+ * { box-sizing: border-box; }
50
+
51
+ html, body {
52
+ min-height: 100%;
53
+ }
54
+
55
+ body {
56
+ margin: 0;
57
+ background: var(--bg);
58
+ color: var(--text);
59
+ font-family: -apple-system, "Segoe UI", system-ui, sans-serif;
60
+ -webkit-font-smoothing: antialiased;
61
+ }
62
+
63
+ .topbar {
64
+ position: sticky;
65
+ top: 0;
66
+ z-index: 10;
67
+ display: flex;
68
+ align-items: center;
69
+ justify-content: space-between;
70
+ padding: 1.1rem clamp(1.5rem, 4vw, 3.5rem);
71
+ background: color-mix(in srgb, var(--bg-elevated) 92%, transparent);
72
+ backdrop-filter: blur(8px);
73
+ border-bottom: 1px solid var(--border);
74
+ }
75
+
76
+ .brand { display: flex; align-items: center; gap: 0.65rem; }
77
+
78
+ .brand-mark {
79
+ width: 34px;
80
+ height: 34px;
81
+ border-radius: 9px;
82
+ background: linear-gradient(135deg, var(--accent), #7a97ff);
83
+ display: grid;
84
+ place-items: center;
85
+ box-shadow: 0 4px 14px color-mix(in srgb, var(--accent) 45%, transparent);
86
+ }
87
+ .brand-mark svg { width: 18px; height: 18px; stroke: white; }
88
+
89
+ .brand-name { font-size: 1.05rem; font-weight: 650; letter-spacing: -0.01em; }
90
+
91
+ .topbar-meta {
92
+ display: flex;
93
+ align-items: center;
94
+ gap: 0.5rem;
95
+ color: var(--muted);
96
+ font-size: 0.8rem;
97
+ }
98
+ .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--success); flex-shrink: 0;
99
+ box-shadow: 0 0 0 3px var(--success-soft); }
100
+
101
+ main {
102
+ max-width: 880px;
103
+ margin: 0 auto;
104
+ padding: clamp(1.75rem, 4vw, 3rem) clamp(1.25rem, 4vw, 2rem) 5rem;
105
+ }
106
+
107
+ .page-head { margin-bottom: 1.75rem; }
108
+ .page-head h1 { margin: 0 0 0.35rem; font-size: 1.6rem; letter-spacing: -0.015em; }
109
+ .page-head p { margin: 0; color: var(--muted); font-size: 0.95rem; }
110
+
111
+ .panel {
112
+ background: var(--panel);
113
+ border: 1px solid var(--border);
114
+ border-radius: 16px;
115
+ box-shadow: var(--shadow);
116
+ overflow: hidden;
117
+ margin-bottom: 1.75rem;
118
+ }
119
+
120
+ .panel-header {
121
+ display: flex;
122
+ align-items: center;
123
+ justify-content: space-between;
124
+ padding: 1.15rem 1.5rem;
125
+ border-bottom: 1px solid var(--border);
126
+ }
127
+ .panel-header h2 { margin: 0; font-size: 0.95rem; font-weight: 600; }
128
+ .count-badge {
129
+ font-size: 0.75rem;
130
+ color: var(--muted);
131
+ background: var(--bg);
132
+ border: 1px solid var(--border);
133
+ border-radius: 999px;
134
+ padding: 0.2rem 0.65rem;
135
+ }
136
+
137
+ table { width: 100%; border-collapse: collapse; }
138
+ thead th {
139
+ text-align: left;
140
+ padding: 0.6rem 1.5rem;
141
+ font-size: 0.7rem;
142
+ font-weight: 600;
143
+ letter-spacing: 0.06em;
144
+ text-transform: uppercase;
145
+ color: var(--muted-2);
146
+ background: var(--bg-elevated);
147
+ border-bottom: 1px solid var(--border);
148
+ }
149
+ tbody tr { border-bottom: 1px solid var(--border); transition: background 0.12s ease; }
150
+ tbody tr:last-child { border-bottom: none; }
151
+ tbody tr:hover { background: var(--panel-hover); }
152
+ td { padding: 0.85rem 1.5rem; vertical-align: middle; }
153
+ td.name { font-weight: 550; }
154
+ td.value {
155
+ font-family: "SF Mono", "Cascadia Code", Consolas, monospace;
156
+ font-size: 0.88rem;
157
+ color: var(--muted);
158
+ letter-spacing: 0.03em;
159
+ }
160
+ td.value.revealed { color: var(--accent); }
161
+ td.actions { text-align: right; white-space: nowrap; }
162
+
163
+ .empty-row td { padding: 3rem 1.5rem; text-align: center; }
164
+ .empty-state { color: var(--muted); font-size: 0.9rem; }
165
+ .empty-state .glyph { font-size: 1.6rem; display: block; margin-bottom: 0.5rem; opacity: 0.6; }
166
+
167
+ button {
168
+ display: inline-flex;
169
+ align-items: center;
170
+ gap: 0.4rem;
171
+ border: 1px solid var(--border-strong);
172
+ background: var(--panel);
173
+ color: var(--text);
174
+ border-radius: 8px;
175
+ padding: 0.4rem 0.75rem;
176
+ cursor: pointer;
177
+ font-size: 0.82rem;
178
+ font-weight: 500;
179
+ font-family: inherit;
180
+ transition: border-color 0.12s ease, color 0.12s ease, background 0.12s ease, transform 0.05s ease;
181
+ }
182
+ button svg { width: 14px; height: 14px; }
183
+ button:hover { border-color: var(--accent); color: var(--accent); }
184
+ button:active { transform: translateY(1px); }
185
+ button:disabled { opacity: 0.55; cursor: default; pointer-events: none; }
186
+ button.danger:hover { border-color: var(--danger); color: var(--danger); }
187
+ button.icon-btn { margin-left: 0.5rem; }
188
+ button.icon-btn:first-child { margin-left: 0; }
189
+
190
+ button.primary {
191
+ background: var(--accent);
192
+ border-color: var(--accent);
193
+ color: white;
194
+ padding: 0.55rem 1.1rem;
195
+ font-weight: 600;
196
+ }
197
+ button.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: white; }
198
+
199
+ .add-form {
200
+ display: flex;
201
+ gap: 0.75rem;
202
+ padding: 1.25rem 1.5rem;
203
+ background: var(--bg-elevated);
204
+ }
205
+ .field { flex: 1; display: flex; flex-direction: column; gap: 0.35rem; }
206
+ .field label {
207
+ font-size: 0.7rem;
208
+ font-weight: 600;
209
+ letter-spacing: 0.05em;
210
+ text-transform: uppercase;
211
+ color: var(--muted-2);
212
+ }
213
+ input {
214
+ padding: 0.6rem 0.75rem;
215
+ border: 1px solid var(--border-strong);
216
+ border-radius: 8px;
217
+ background: var(--panel);
218
+ color: var(--text);
219
+ font-size: 0.92rem;
220
+ font-family: inherit;
221
+ outline: none;
222
+ transition: border-color 0.12s ease, box-shadow 0.12s ease;
223
+ }
224
+ input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
225
+ .add-form .submit-field { justify-content: flex-end; }
226
+ .add-form button.primary { align-self: flex-end; height: 2.5rem; }
227
+
228
+ #status {
229
+ display: none;
230
+ align-items: center;
231
+ gap: 0.5rem;
232
+ margin: -0.25rem 0 1.5rem;
233
+ padding: 0.65rem 1rem;
234
+ border-radius: 10px;
235
+ font-size: 0.85rem;
236
+ border: 1px solid transparent;
237
+ }
238
+ #status.visible { display: flex; }
239
+ #status.error { background: var(--danger-soft); color: var(--danger); border-color: color-mix(in srgb, var(--danger) 25%, transparent); }
240
+ #status.success { background: var(--success-soft); color: var(--success); border-color: color-mix(in srgb, var(--success) 25%, transparent); }
241
+
242
+ footer {
243
+ text-align: center;
244
+ color: var(--muted-2);
245
+ font-size: 0.78rem;
246
+ padding-top: 0.5rem;
247
+ }
248
+
249
+ @media (max-width: 560px) {
250
+ .add-form { flex-direction: column; }
251
+ .add-form .submit-field { align-items: stretch; }
252
+ .add-form button.primary { width: 100%; height: auto; }
253
+ td.actions button span.label { display: none; }
254
+ }
255
+ </style>
256
+ </head>
257
+ <body>
258
+ <div class="topbar">
259
+ <div class="brand">
260
+ <div class="brand-mark">
261
+ <svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
262
+ <rect x="4" y="10" width="16" height="10" rx="2"></rect>
263
+ <path d="M8 10V7a4 4 0 0 1 8 0v3"></path>
264
+ </svg>
265
+ </div>
266
+ <span class="brand-name">Vault</span>
267
+ </div>
268
+ <div class="topbar-meta">
269
+ <span class="dot"></span>
270
+ <span>127.0.0.1 · token-protected session</span>
271
+ </div>
272
+ </div>
273
+
274
+ <main>
275
+ <div class="page-head">
276
+ <h1>Secrets</h1>
277
+ <p>Local secrets manager — nothing leaves this machine.</p>
278
+ </div>
279
+
280
+ <div id="status"></div>
281
+
282
+ <div class="panel">
283
+ <div class="panel-header">
284
+ <h2>Stored secrets</h2>
285
+ <span class="count-badge" id="count-badge">0</span>
286
+ </div>
287
+ <table>
288
+ <thead>
289
+ <tr><th>Name</th><th>Value</th><th></th></tr>
290
+ </thead>
291
+ <tbody id="rows"></tbody>
292
+ </table>
293
+ </div>
294
+
295
+ <div class="panel">
296
+ <form id="add-form" class="add-form">
297
+ <div class="field">
298
+ <label for="name-input">Name</label>
299
+ <input id="name-input" placeholder="API_KEY" autocomplete="off" required>
300
+ </div>
301
+ <div class="field">
302
+ <label for="value-input">Value</label>
303
+ <input id="value-input" type="text" placeholder="secret value" autocomplete="off" required>
304
+ </div>
305
+ <div class="field submit-field">
306
+ <button type="submit" class="primary">Add secret</button>
307
+ </div>
308
+ </form>
309
+ </div>
310
+
311
+ <footer>Served locally over HTTP — access requires the session token in the URL.</footer>
312
+ </main>
313
+
314
+ <script>
315
+ const params = new URLSearchParams(window.location.search);
316
+ const TOKEN = params.get("token") || "";
317
+
318
+ const ICONS = {
319
+ reveal: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7Z"/><circle cx="12" cy="12" r="3"/></svg>',
320
+ hide: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3l18 18"/><path d="M10.6 5.1A11 11 0 0 1 12 5c7 0 11 7 11 7a13.5 13.5 0 0 1-3 3.6M6.5 6.7C3.7 8.5 1 12 1 12s4 7 11 7c1.4 0 2.7-.2 3.9-.6"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2"/></svg>',
321
+ trash: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>',
322
+ };
323
+
324
+ let statusTimer = null;
325
+
326
+ function setStatus(msg, kind) {
327
+ const el = document.getElementById("status");
328
+ clearTimeout(statusTimer);
329
+ if (!msg) {
330
+ el.classList.remove("visible", "error", "success");
331
+ return;
332
+ }
333
+ el.textContent = msg;
334
+ el.classList.add("visible");
335
+ el.classList.remove("error", "success");
336
+ el.classList.add(kind === "error" ? "error" : "success");
337
+ if (kind !== "error") {
338
+ statusTimer = setTimeout(() => el.classList.remove("visible"), 3000);
339
+ }
340
+ }
341
+
342
+ async function api(path, options = {}) {
343
+ const url = new URL(path, window.location.origin);
344
+ url.searchParams.set("token", TOKEN);
345
+ const res = await fetch(url, {
346
+ ...options,
347
+ headers: { "X-Vault-Token": TOKEN, "Content-Type": "application/json", ...(options.headers || {}) },
348
+ });
349
+ if (!res.ok) {
350
+ const body = await res.json().catch(() => ({}));
351
+ throw new Error(body.error || `request failed (${res.status})`);
352
+ }
353
+ return res.json();
354
+ }
355
+
356
+ async function loadKeys() {
357
+ try {
358
+ const names = await api("/api/keys");
359
+ renderRows(names);
360
+ } catch (err) {
361
+ setStatus(err.message, "error");
362
+ }
363
+ }
364
+
365
+ function renderRows(names) {
366
+ const tbody = document.getElementById("rows");
367
+ document.getElementById("count-badge").textContent = names.length;
368
+ tbody.innerHTML = "";
369
+
370
+ if (names.length === 0) {
371
+ const tr = document.createElement("tr");
372
+ tr.className = "empty-row";
373
+ const td = document.createElement("td");
374
+ td.colSpan = 3;
375
+ td.innerHTML = '<div class="empty-state"><span class="glyph">🔒</span>No secrets yet — add one below.</div>';
376
+ tr.appendChild(td);
377
+ tbody.appendChild(tr);
378
+ return;
379
+ }
380
+
381
+ for (const name of names) {
382
+ const tr = document.createElement("tr");
383
+
384
+ const nameTd = document.createElement("td");
385
+ nameTd.className = "name";
386
+ nameTd.textContent = name;
387
+
388
+ const valueTd = document.createElement("td");
389
+ valueTd.className = "value";
390
+ valueTd.textContent = "••••••••";
391
+
392
+ const actionsTd = document.createElement("td");
393
+ actionsTd.className = "actions";
394
+
395
+ const revealBtn = document.createElement("button");
396
+ revealBtn.className = "icon-btn";
397
+ revealBtn.innerHTML = `${ICONS.reveal}<span class="label">Reveal</span>`;
398
+ revealBtn.onclick = () => revealValue(name, valueTd, revealBtn);
399
+
400
+ const deleteBtn = document.createElement("button");
401
+ deleteBtn.className = "icon-btn danger";
402
+ deleteBtn.innerHTML = `${ICONS.trash}<span class="label">Delete</span>`;
403
+ deleteBtn.onclick = () => deleteKey(name);
404
+
405
+ actionsTd.appendChild(revealBtn);
406
+ actionsTd.appendChild(deleteBtn);
407
+
408
+ tr.appendChild(nameTd);
409
+ tr.appendChild(valueTd);
410
+ tr.appendChild(actionsTd);
411
+ tbody.appendChild(tr);
412
+ }
413
+ }
414
+
415
+ async function revealValue(name, valueTd, revealBtn) {
416
+ try {
417
+ revealBtn.disabled = true;
418
+ const { value } = await api(`/api/keys/${encodeURIComponent(name)}/reveal`, { method: "POST" });
419
+ valueTd.textContent = value;
420
+ valueTd.classList.add("revealed");
421
+ setTimeout(() => {
422
+ valueTd.textContent = "••••••••";
423
+ valueTd.classList.remove("revealed");
424
+ revealBtn.disabled = false;
425
+ }, 5000);
426
+ } catch (err) {
427
+ setStatus(err.message, "error");
428
+ revealBtn.disabled = false;
429
+ }
430
+ }
431
+
432
+ async function deleteKey(name) {
433
+ try {
434
+ await api(`/api/keys/${encodeURIComponent(name)}`, { method: "DELETE" });
435
+ setStatus(`Deleted '${name}'`, "success");
436
+ await loadKeys();
437
+ } catch (err) {
438
+ setStatus(err.message, "error");
439
+ }
440
+ }
441
+
442
+ document.getElementById("add-form").addEventListener("submit", async (e) => {
443
+ e.preventDefault();
444
+ const nameInput = document.getElementById("name-input");
445
+ const valueInput = document.getElementById("value-input");
446
+ try {
447
+ await api("/api/keys", {
448
+ method: "POST",
449
+ body: JSON.stringify({ name: nameInput.value, value: valueInput.value }),
450
+ });
451
+ setStatus(`Stored '${nameInput.value}'`, "success");
452
+ nameInput.value = "";
453
+ valueInput.value = "";
454
+ nameInput.focus();
455
+ await loadKeys();
456
+ } catch (err) {
457
+ setStatus(err.message, "error");
458
+ }
459
+ });
460
+
461
+ loadKeys();
462
+ </script>
463
+ </body>
464
+ </html>