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.
- hushvault_cli-0.1.0.dist-info/METADATA +159 -0
- hushvault_cli-0.1.0.dist-info/RECORD +15 -0
- hushvault_cli-0.1.0.dist-info/WHEEL +5 -0
- hushvault_cli-0.1.0.dist-info/entry_points.txt +2 -0
- hushvault_cli-0.1.0.dist-info/top_level.txt +1 -0
- vault/__init__.py +0 -0
- vault/__main__.py +6 -0
- vault/cipher.py +150 -0
- vault/cli.py +192 -0
- vault/keychain.py +132 -0
- vault/runner.py +28 -0
- vault/store.py +61 -0
- vault/ui/__init__.py +0 -0
- vault/ui/server.py +139 -0
- vault/ui/static/index.html +464 -0
|
@@ -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 @@
|
|
|
1
|
+
vault
|
vault/__init__.py
ADDED
|
File without changes
|
vault/__main__.py
ADDED
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>
|