keystash 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.
- keystash/__init__.py +3 -0
- keystash/__main__.py +6 -0
- keystash/cli.py +602 -0
- keystash/clipboard.py +65 -0
- keystash/gen.py +33 -0
- keystash/model.py +81 -0
- keystash/search.py +47 -0
- keystash/vault.py +143 -0
- keystash-0.1.0.dist-info/METADATA +144 -0
- keystash-0.1.0.dist-info/RECORD +13 -0
- keystash-0.1.0.dist-info/WHEEL +4 -0
- keystash-0.1.0.dist-info/entry_points.txt +2 -0
- keystash-0.1.0.dist-info/licenses/LICENSE +21 -0
keystash/__init__.py
ADDED
keystash/__main__.py
ADDED
keystash/cli.py
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
"""keystash command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import getpass
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import List, Optional
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.panel import Panel
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
|
|
19
|
+
from . import __version__, clipboard
|
|
20
|
+
from .gen import generate
|
|
21
|
+
from .model import Entry, parse_expires
|
|
22
|
+
from .search import search as fuzzy_search
|
|
23
|
+
from .vault import (
|
|
24
|
+
BAD_PASSWORD,
|
|
25
|
+
Vault,
|
|
26
|
+
VaultError,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
app = typer.Typer(
|
|
30
|
+
name="keystash",
|
|
31
|
+
help="Local-first encrypted vault for API keys, tokens and passwords.",
|
|
32
|
+
no_args_is_help=True,
|
|
33
|
+
add_completion=False,
|
|
34
|
+
)
|
|
35
|
+
console = Console()
|
|
36
|
+
err_console = Console(stderr=True)
|
|
37
|
+
|
|
38
|
+
CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class State:
|
|
42
|
+
vault_path: Optional[Path] = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
state = State()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def version_callback(value: bool) -> None:
|
|
49
|
+
if value:
|
|
50
|
+
console.print(f"keystash {__version__}")
|
|
51
|
+
raise typer.Exit()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.callback(context_settings=CONTEXT_SETTINGS)
|
|
55
|
+
def main(
|
|
56
|
+
vault: Optional[Path] = typer.Option(
|
|
57
|
+
None,
|
|
58
|
+
"--vault",
|
|
59
|
+
"-V",
|
|
60
|
+
help="Path to the vault file (default: $KEYSTASH_VAULT or ~/.keystash/vault.json).",
|
|
61
|
+
envvar="KEYSTASH_VAULT",
|
|
62
|
+
),
|
|
63
|
+
version: bool = typer.Option(
|
|
64
|
+
False, "--version", callback=version_callback, is_eager=True
|
|
65
|
+
),
|
|
66
|
+
) -> None:
|
|
67
|
+
state.vault_path = vault
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------- helpers
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def resolve_vault() -> Vault:
|
|
74
|
+
return Vault(state.vault_path)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def ask_password() -> str:
|
|
78
|
+
password = os.environ.get("KEYSTASH_PASSWORD")
|
|
79
|
+
if password is not None:
|
|
80
|
+
return password
|
|
81
|
+
try:
|
|
82
|
+
return getpass.getpass("Master password: ")
|
|
83
|
+
except (EOFError, KeyboardInterrupt):
|
|
84
|
+
raise typer.Exit(1) from None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def fail(message: str, code: int = 1) -> "typer.Exit":
|
|
88
|
+
err_console.print(f"[red]error:[/red] {message}")
|
|
89
|
+
return typer.Exit(code)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_vault() -> Vault:
|
|
93
|
+
vault = resolve_vault()
|
|
94
|
+
try:
|
|
95
|
+
vault.load(ask_password())
|
|
96
|
+
except VaultError as e:
|
|
97
|
+
if e.code == BAD_PASSWORD:
|
|
98
|
+
raise fail(str(e)) from None
|
|
99
|
+
raise fail(str(e)) from None
|
|
100
|
+
return vault
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _mask(secret: str) -> str:
|
|
104
|
+
if len(secret) <= 8:
|
|
105
|
+
return "•" * len(secret)
|
|
106
|
+
return secret[:4] + "•" * (len(secret) - 8) + secret[-4:]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _shell_quote(value: str) -> str:
|
|
110
|
+
return "'" + value.replace("'", "'\\''") + "'"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _expiry_markup(entry: Entry) -> str:
|
|
114
|
+
if entry.expires_at is None:
|
|
115
|
+
return "[dim]—[/dim]"
|
|
116
|
+
left = entry.days_left() or 0
|
|
117
|
+
if entry.is_expired():
|
|
118
|
+
return f"[red]{entry.expires_at} (expired)[/red]"
|
|
119
|
+
if left <= 7:
|
|
120
|
+
return f"[yellow]{entry.expires_at} ({left}d)[/yellow]"
|
|
121
|
+
return str(entry.expires_at)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _parse_tags(raw: Optional[str]) -> List[str]:
|
|
125
|
+
if not raw:
|
|
126
|
+
return []
|
|
127
|
+
return [t.strip() for t in raw.split(",") if t.strip()]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _confirm(message: str) -> bool:
|
|
131
|
+
return typer.confirm(message, default=False)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------------- commands
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@app.command()
|
|
138
|
+
def init() -> None:
|
|
139
|
+
"""Create a new vault with a master password."""
|
|
140
|
+
vault = resolve_vault()
|
|
141
|
+
if vault.exists():
|
|
142
|
+
raise fail(f"Vault already exists at {vault.path}")
|
|
143
|
+
if os.environ.get("KEYSTASH_PASSWORD"):
|
|
144
|
+
password = os.environ["KEYSTASH_PASSWORD"]
|
|
145
|
+
else:
|
|
146
|
+
password = getpass.getpass("Set master password: ")
|
|
147
|
+
confirm = getpass.getpass("Confirm master password: ")
|
|
148
|
+
if password != confirm:
|
|
149
|
+
raise fail("Passwords do not match.")
|
|
150
|
+
if not password:
|
|
151
|
+
raise fail("Master password cannot be empty.")
|
|
152
|
+
vault.create(password)
|
|
153
|
+
console.print(f"[green]Vault created at {vault.path}[/green]")
|
|
154
|
+
console.print(
|
|
155
|
+
"Tip: point [bold]KEYSTASH_VAULT[/bold] at a cloud-synced folder "
|
|
156
|
+
"(iCloud/SeaDrive/Dropbox) to keep multiple machines in sync — the file "
|
|
157
|
+
"is encrypted, so syncing it is safe."
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@app.command()
|
|
162
|
+
def add(
|
|
163
|
+
name: str = typer.Argument(..., help="Entry name, e.g. openai-prod."),
|
|
164
|
+
secret: Optional[str] = typer.Option(
|
|
165
|
+
None, "--secret", "-s", help="The secret value (will prompt if omitted)."
|
|
166
|
+
),
|
|
167
|
+
generate_len: Optional[int] = typer.Option(
|
|
168
|
+
None, "--generate", "-g", help="Generate a random secret of this length instead."
|
|
169
|
+
),
|
|
170
|
+
username: Optional[str] = typer.Option(None, "--username", "-u"),
|
|
171
|
+
url: Optional[str] = typer.Option(None, "--url"),
|
|
172
|
+
tags: Optional[str] = typer.Option(None, "--tags", "-t", help="Comma-separated tags."),
|
|
173
|
+
notes: Optional[str] = typer.Option(None, "--notes", "-n"),
|
|
174
|
+
env_var: Optional[str] = typer.Option(
|
|
175
|
+
None, "--env-var", "-e", help="Env var name used by `run` and `env`."
|
|
176
|
+
),
|
|
177
|
+
expires: Optional[str] = typer.Option(
|
|
178
|
+
None, "--expires", help="Expiry date, YYYY-MM-DD."
|
|
179
|
+
),
|
|
180
|
+
force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing entry."),
|
|
181
|
+
) -> None:
|
|
182
|
+
"""Add a new entry."""
|
|
183
|
+
if expires:
|
|
184
|
+
try:
|
|
185
|
+
parse_expires(expires)
|
|
186
|
+
except ValueError:
|
|
187
|
+
raise fail("--expires must be YYYY-MM-DD") from None
|
|
188
|
+
vault = load_vault()
|
|
189
|
+
if secret is None and generate_len is None:
|
|
190
|
+
secret = getpass.getpass(f"Secret for '{name}': ")
|
|
191
|
+
if generate_len is not None:
|
|
192
|
+
try:
|
|
193
|
+
secret = generate(generate_len)
|
|
194
|
+
except ValueError as e:
|
|
195
|
+
raise fail(str(e)) from None
|
|
196
|
+
if not secret:
|
|
197
|
+
raise fail("Secret cannot be empty.")
|
|
198
|
+
entry = Entry(
|
|
199
|
+
name=name,
|
|
200
|
+
secret=secret,
|
|
201
|
+
username=username or "",
|
|
202
|
+
url=url or "",
|
|
203
|
+
tags=_parse_tags(tags),
|
|
204
|
+
notes=notes or "",
|
|
205
|
+
env_var=env_var or "",
|
|
206
|
+
expires_at=parse_expires(expires),
|
|
207
|
+
)
|
|
208
|
+
try:
|
|
209
|
+
vault.add(entry, overwrite=force)
|
|
210
|
+
except VaultError as e:
|
|
211
|
+
raise fail(str(e)) from None
|
|
212
|
+
vault.save(ask_password())
|
|
213
|
+
console.print(f"[green]Added[/green] {name} [dim]→ env {entry.default_env_var}[/dim]")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@app.command()
|
|
217
|
+
def get(
|
|
218
|
+
name: str = typer.Argument(...),
|
|
219
|
+
copy: bool = typer.Option(False, "--copy", "-c", help="Copy to clipboard instead of printing."),
|
|
220
|
+
reveal: bool = typer.Option(False, "--reveal", "-r", help="Show the full secret."),
|
|
221
|
+
quiet: bool = typer.Option(False, "--quiet", "-q", help="Print only the raw secret (for scripting)."),
|
|
222
|
+
) -> None:
|
|
223
|
+
"""Show / copy an entry's secret."""
|
|
224
|
+
vault = load_vault()
|
|
225
|
+
try:
|
|
226
|
+
entry = vault.get(name)
|
|
227
|
+
except VaultError as e:
|
|
228
|
+
raise fail(str(e), code=3) from None
|
|
229
|
+
if entry.is_expired() and not quiet:
|
|
230
|
+
console.print(
|
|
231
|
+
f"[yellow]warning:[/yellow] '{entry.name}' expired on {entry.expires_at}",
|
|
232
|
+
style="dim",
|
|
233
|
+
)
|
|
234
|
+
if copy:
|
|
235
|
+
if clipboard.copy(entry.secret):
|
|
236
|
+
console.print(
|
|
237
|
+
f"[green]Copied '{entry.name}' to clipboard[/green] "
|
|
238
|
+
f"[dim](auto-clears in {clipboard.CLEAR_AFTER_SECONDS}s)[/dim]"
|
|
239
|
+
)
|
|
240
|
+
else:
|
|
241
|
+
raise fail("No clipboard helper found (pbcopy/wl-copy/xclip/clip).")
|
|
242
|
+
return
|
|
243
|
+
if quiet:
|
|
244
|
+
typer.echo(entry.secret)
|
|
245
|
+
return
|
|
246
|
+
shown = entry.secret if reveal else _mask(entry.secret)
|
|
247
|
+
body = (
|
|
248
|
+
f"[bold]secret:[/bold] {shown}\n"
|
|
249
|
+
f"[bold]env:[/bold] {entry.default_env_var}\n"
|
|
250
|
+
f"[bold]username:[/bold] {entry.username or '—'}\n"
|
|
251
|
+
f"[bold]url:[/bold] {entry.url or '—'}\n"
|
|
252
|
+
f"[bold]tags:[/bold] {', '.join(entry.tags) or '—'}\n"
|
|
253
|
+
f"[bold]expires:[/bold] {entry.expires_at or '—'}\n"
|
|
254
|
+
f"[bold]notes:[/bold] {entry.notes or '—'}"
|
|
255
|
+
)
|
|
256
|
+
console.print(Panel(body, title=entry.name, subtitle=f"updated {entry.updated_at:%Y-%m-%d}"))
|
|
257
|
+
if not reveal:
|
|
258
|
+
console.print("[dim]Use --reveal to show, --copy to copy.[/dim]")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@app.command("ls")
|
|
262
|
+
def list_entries(
|
|
263
|
+
query: Optional[str] = typer.Argument(None, help="Fuzzy search pattern."),
|
|
264
|
+
tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag."),
|
|
265
|
+
json_out: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
266
|
+
) -> None:
|
|
267
|
+
"""List entries, optionally fuzzy-searching."""
|
|
268
|
+
vault = load_vault()
|
|
269
|
+
entries = fuzzy_search(vault.entries, query or "")
|
|
270
|
+
if tag:
|
|
271
|
+
entries = [e for e in entries if tag in e.tags]
|
|
272
|
+
if json_out:
|
|
273
|
+
typer.echo(
|
|
274
|
+
json.dumps(
|
|
275
|
+
[
|
|
276
|
+
{
|
|
277
|
+
"name": e.name,
|
|
278
|
+
"env_var": e.default_env_var,
|
|
279
|
+
"tags": e.tags,
|
|
280
|
+
"expires_at": e.expires_at.isoformat() if e.expires_at else None,
|
|
281
|
+
"expired": e.is_expired(),
|
|
282
|
+
}
|
|
283
|
+
for e in entries
|
|
284
|
+
],
|
|
285
|
+
indent=2,
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
return
|
|
289
|
+
if not entries:
|
|
290
|
+
console.print("[dim]No matching entries.[/dim]")
|
|
291
|
+
return
|
|
292
|
+
table = Table(title=f"{vault.path} · {len(entries)} entry(ies)")
|
|
293
|
+
table.add_column("Name", style="cyan")
|
|
294
|
+
table.add_column("Env var", style="green")
|
|
295
|
+
table.add_column("Tags")
|
|
296
|
+
table.add_column("Expires")
|
|
297
|
+
table.add_column("Updated", style="dim")
|
|
298
|
+
for e in entries:
|
|
299
|
+
table.add_row(
|
|
300
|
+
e.name,
|
|
301
|
+
e.default_env_var,
|
|
302
|
+
", ".join(e.tags),
|
|
303
|
+
_expiry_markup(e),
|
|
304
|
+
f"{e.updated_at:%Y-%m-%d}",
|
|
305
|
+
)
|
|
306
|
+
console.print(table)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@app.command()
|
|
310
|
+
def rm(
|
|
311
|
+
name: str = typer.Argument(...),
|
|
312
|
+
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation."),
|
|
313
|
+
) -> None:
|
|
314
|
+
"""Delete an entry."""
|
|
315
|
+
vault = load_vault()
|
|
316
|
+
try:
|
|
317
|
+
vault.get(name)
|
|
318
|
+
except VaultError as e:
|
|
319
|
+
raise fail(str(e), code=3) from None
|
|
320
|
+
if not force and not _confirm(f"Delete '{name}' permanently?"):
|
|
321
|
+
raise typer.Abort()
|
|
322
|
+
vault.remove(name)
|
|
323
|
+
vault.save(ask_password())
|
|
324
|
+
console.print(f"[green]Deleted[/green] {name}")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@app.command()
|
|
328
|
+
def edit(
|
|
329
|
+
name: str = typer.Argument(...),
|
|
330
|
+
secret: Optional[str] = typer.Option(None, "--secret", "-s", help="Replace the secret."),
|
|
331
|
+
username: Optional[str] = typer.Option(None, "--username", "-u"),
|
|
332
|
+
url: Optional[str] = typer.Option(None, "--url"),
|
|
333
|
+
tags: Optional[str] = typer.Option(None, "--tags", "-t"),
|
|
334
|
+
notes: Optional[str] = typer.Option(None, "--notes", "-n"),
|
|
335
|
+
env_var: Optional[str] = typer.Option(None, "--env-var", "-e"),
|
|
336
|
+
expires: Optional[str] = typer.Option(None, "--expires"),
|
|
337
|
+
clear_expires: bool = typer.Option(False, "--clear-expires"),
|
|
338
|
+
) -> None:
|
|
339
|
+
"""Update fields of an existing entry (only provided fields change)."""
|
|
340
|
+
if expires:
|
|
341
|
+
try:
|
|
342
|
+
parse_expires(expires)
|
|
343
|
+
except ValueError:
|
|
344
|
+
raise fail("--expires must be YYYY-MM-DD") from None
|
|
345
|
+
vault = load_vault()
|
|
346
|
+
try:
|
|
347
|
+
entry = vault.get(name)
|
|
348
|
+
except VaultError as e:
|
|
349
|
+
raise fail(str(e), code=3) from None
|
|
350
|
+
changes = {}
|
|
351
|
+
if secret is not None:
|
|
352
|
+
changes["secret"] = secret
|
|
353
|
+
if username is not None:
|
|
354
|
+
changes["username"] = username
|
|
355
|
+
if url is not None:
|
|
356
|
+
changes["url"] = url
|
|
357
|
+
if tags is not None:
|
|
358
|
+
changes["tags"] = _parse_tags(tags)
|
|
359
|
+
if notes is not None:
|
|
360
|
+
changes["notes"] = notes
|
|
361
|
+
if env_var is not None:
|
|
362
|
+
changes["env_var"] = env_var
|
|
363
|
+
if clear_expires:
|
|
364
|
+
changes["expires_at"] = None
|
|
365
|
+
elif expires is not None:
|
|
366
|
+
changes["expires_at"] = parse_expires(expires)
|
|
367
|
+
if not changes:
|
|
368
|
+
raise fail("Nothing to change — pass at least one field option.")
|
|
369
|
+
vault.add(entry.with_updates(**changes), overwrite=True)
|
|
370
|
+
vault.save(ask_password())
|
|
371
|
+
console.print(f"[green]Updated[/green] {name}")
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@app.command()
|
|
375
|
+
def gen(
|
|
376
|
+
length: int = typer.Argument(24, min=8, help="Secret length."),
|
|
377
|
+
no_symbols: bool = typer.Option(False, "--no-symbols", help="Alphanumeric only."),
|
|
378
|
+
save_name: Optional[str] = typer.Option(
|
|
379
|
+
None, "--save", help="Save the generated secret as a new entry with this name."
|
|
380
|
+
),
|
|
381
|
+
tags: Optional[str] = typer.Option(None, "--tags", "-t"),
|
|
382
|
+
expires: Optional[str] = typer.Option(None, "--expires"),
|
|
383
|
+
) -> None:
|
|
384
|
+
"""Generate a strong random secret (optionally save it)."""
|
|
385
|
+
try:
|
|
386
|
+
value = generate(length, symbols=not no_symbols)
|
|
387
|
+
except ValueError as e:
|
|
388
|
+
raise fail(str(e)) from None
|
|
389
|
+
if not save_name:
|
|
390
|
+
typer.echo(value)
|
|
391
|
+
return
|
|
392
|
+
vault = load_vault()
|
|
393
|
+
entry = Entry(
|
|
394
|
+
name=save_name,
|
|
395
|
+
secret=value,
|
|
396
|
+
tags=_parse_tags(tags),
|
|
397
|
+
expires_at=parse_expires(expires),
|
|
398
|
+
)
|
|
399
|
+
try:
|
|
400
|
+
vault.add(entry, overwrite=False)
|
|
401
|
+
except VaultError as e:
|
|
402
|
+
raise fail(str(e)) from None
|
|
403
|
+
vault.save(ask_password())
|
|
404
|
+
console.print(f"[green]Generated & saved[/green] {save_name} ({length} chars)")
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _select_entries(vault: Vault, names: List[str], tag: Optional[str]) -> List[Entry]:
|
|
408
|
+
if names:
|
|
409
|
+
missing = [n for n in names if n not in vault.entries]
|
|
410
|
+
if missing:
|
|
411
|
+
raise fail(f"No such entr{'y' if len(missing) == 1 else 'ies'}: {', '.join(missing)}", code=3)
|
|
412
|
+
return [vault.entries[n] for n in names]
|
|
413
|
+
if tag:
|
|
414
|
+
selected = [e for e in vault.entries.values() if tag in e.tags]
|
|
415
|
+
if not selected:
|
|
416
|
+
raise fail(f"No entries tagged '{tag}'.", code=3)
|
|
417
|
+
return selected
|
|
418
|
+
raise fail("Specify entry names or --tag.")
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@app.command()
|
|
422
|
+
def env(
|
|
423
|
+
names: List[str] = typer.Argument(None, help="Entry names."),
|
|
424
|
+
tag: Optional[str] = typer.Option(None, "--tag", help="All entries with this tag."),
|
|
425
|
+
) -> None:
|
|
426
|
+
"""Print `export` lines so you can run: eval "$(keystash env openai)". """
|
|
427
|
+
vault = load_vault()
|
|
428
|
+
for entry in _select_entries(vault, list(names or []), tag):
|
|
429
|
+
typer.echo(f"export {entry.default_env_var}={_shell_quote(entry.secret)}")
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
@app.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
|
|
433
|
+
def run(
|
|
434
|
+
ctx: typer.Context,
|
|
435
|
+
name: List[str] = typer.Option(
|
|
436
|
+
None, "--name", "-n", help="Entry to inject (repeatable)."
|
|
437
|
+
),
|
|
438
|
+
tag: Optional[str] = typer.Option(None, "--tag", "-t", help="Inject all entries with this tag."),
|
|
439
|
+
quiet: bool = typer.Option(True, "--quiet/--no-quiet", help="Hide which vars are injected."),
|
|
440
|
+
) -> None:
|
|
441
|
+
"""Run a command with selected secrets injected as env vars.
|
|
442
|
+
|
|
443
|
+
Everything after `--` is the command to run:
|
|
444
|
+
|
|
445
|
+
keystash run -n openai -n anthropic -- python train.py
|
|
446
|
+
keystash run --tag llm -- python train.py
|
|
447
|
+
"""
|
|
448
|
+
cmd = list(ctx.args)
|
|
449
|
+
if cmd and cmd[0] == "--": # defensive: some click versions keep the separator
|
|
450
|
+
cmd = cmd[1:]
|
|
451
|
+
if not cmd:
|
|
452
|
+
raise fail("Usage: keystash run [-n NAME]... [--tag TAG] -- COMMAND [ARGS...]")
|
|
453
|
+
vault = load_vault()
|
|
454
|
+
selected = _select_entries(vault, name, tag)
|
|
455
|
+
environ = dict(os.environ)
|
|
456
|
+
for entry in selected:
|
|
457
|
+
environ[entry.default_env_var] = entry.secret
|
|
458
|
+
if not quiet:
|
|
459
|
+
console.print(f"[dim]+ {entry.default_env_var}[/dim]")
|
|
460
|
+
try:
|
|
461
|
+
completed = subprocess.run(cmd, env=environ, check=False)
|
|
462
|
+
except FileNotFoundError:
|
|
463
|
+
raise fail(f"Command not found: {cmd[0]}") from None
|
|
464
|
+
raise typer.Exit(completed.returncode)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
@app.command("import")
|
|
468
|
+
def import_entries(
|
|
469
|
+
file: Path = typer.Argument(..., exists=True, readable=True, help=".env or JSON file."),
|
|
470
|
+
prefix: str = typer.Option("", "--prefix", help="Strip this prefix from names."),
|
|
471
|
+
tags: Optional[str] = typer.Option("imported", "--tags", "-t"),
|
|
472
|
+
force: bool = typer.Option(False, "--force", "-f"),
|
|
473
|
+
) -> None:
|
|
474
|
+
"""Bulk-import entries from a .env or JSON file (and delete the source file's risk)."""
|
|
475
|
+
text = file.read_text(encoding="utf-8")
|
|
476
|
+
pairs: List[tuple[str, str]] = []
|
|
477
|
+
if file.suffix == ".json":
|
|
478
|
+
try:
|
|
479
|
+
data = json.loads(text)
|
|
480
|
+
except json.JSONDecodeError:
|
|
481
|
+
raise fail("Invalid JSON file.") from None
|
|
482
|
+
items = data.items() if isinstance(data, dict) else None
|
|
483
|
+
if items is None:
|
|
484
|
+
raise fail("JSON must be an object mapping name → secret.")
|
|
485
|
+
for k, v in items:
|
|
486
|
+
if isinstance(v, dict) and "secret" in v:
|
|
487
|
+
pairs.append((str(k), str(v["secret"])))
|
|
488
|
+
else:
|
|
489
|
+
pairs.append((str(k), str(v)))
|
|
490
|
+
else:
|
|
491
|
+
for line in text.splitlines():
|
|
492
|
+
line = line.strip()
|
|
493
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
494
|
+
continue
|
|
495
|
+
key, _, value = line.partition("=")
|
|
496
|
+
value = value.strip().strip("'\"")
|
|
497
|
+
pairs.append((key.strip(), value))
|
|
498
|
+
if prefix:
|
|
499
|
+
pairs = [(k[len(prefix):] if k.startswith(prefix) else k, v) for k, v in pairs]
|
|
500
|
+
vault = load_vault()
|
|
501
|
+
now = datetime.now(timezone.utc)
|
|
502
|
+
count = 0
|
|
503
|
+
for name, secret in pairs:
|
|
504
|
+
if not name or not secret:
|
|
505
|
+
continue
|
|
506
|
+
entry = Entry(
|
|
507
|
+
name=name,
|
|
508
|
+
secret=secret,
|
|
509
|
+
tags=_parse_tags(tags),
|
|
510
|
+
created_at=now,
|
|
511
|
+
updated_at=now,
|
|
512
|
+
)
|
|
513
|
+
vault.add(entry, overwrite=force)
|
|
514
|
+
count += 1
|
|
515
|
+
vault.save(ask_password())
|
|
516
|
+
console.print(f"[green]Imported {count} entr{'y' if count == 1 else 'ies'}[/green]")
|
|
517
|
+
console.print(
|
|
518
|
+
"[yellow]Remember to delete the plaintext source file and remove it from any git history.[/yellow]"
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
@app.command()
|
|
523
|
+
def export(
|
|
524
|
+
out: Optional[Path] = typer.Option(None, "--out", "-o", help="Write to file instead of stdout."),
|
|
525
|
+
format: str = typer.Option("json", "--format", "-f", help="json or dotenv."),
|
|
526
|
+
include_secrets: bool = typer.Option(False, "--with-secrets", help="Include secret values."),
|
|
527
|
+
) -> None:
|
|
528
|
+
"""Export entries (metadata by default, secrets only with --with-secrets)."""
|
|
529
|
+
if format not in ("json", "dotenv"):
|
|
530
|
+
raise fail("--format must be json or dotenv.")
|
|
531
|
+
vault = load_vault()
|
|
532
|
+
entries = sorted(vault.entries.values(), key=lambda e: e.name)
|
|
533
|
+
if format == "dotenv":
|
|
534
|
+
lines = []
|
|
535
|
+
for e in entries:
|
|
536
|
+
if include_secrets:
|
|
537
|
+
lines.append(f"{e.default_env_var}={e.secret}")
|
|
538
|
+
else:
|
|
539
|
+
lines.append(f"{e.default_env_var}=")
|
|
540
|
+
payload = "\n".join(lines) + "\n"
|
|
541
|
+
else:
|
|
542
|
+
payload = json.dumps(
|
|
543
|
+
{
|
|
544
|
+
e.name: {
|
|
545
|
+
**{k: v for k, v in e.to_dict().items() if k != "secret"},
|
|
546
|
+
"secret": e.secret if include_secrets else None,
|
|
547
|
+
}
|
|
548
|
+
for e in entries
|
|
549
|
+
},
|
|
550
|
+
indent=2,
|
|
551
|
+
)
|
|
552
|
+
if out:
|
|
553
|
+
out.write_text(payload, encoding="utf-8")
|
|
554
|
+
console.print(f"[green]Exported {len(entries)} entries to {out}[/green]")
|
|
555
|
+
else:
|
|
556
|
+
typer.echo(payload, nl=False)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
@app.command()
|
|
560
|
+
def status() -> None:
|
|
561
|
+
"""Show vault status and expiring entries."""
|
|
562
|
+
vault = resolve_vault()
|
|
563
|
+
if not vault.exists():
|
|
564
|
+
raise fail(f"No vault at {vault.path} — run `keystash init` first.", code=3)
|
|
565
|
+
console.print(f"[bold]Vault:[/bold] {vault.path}")
|
|
566
|
+
vault.load(ask_password())
|
|
567
|
+
total = len(vault.entries)
|
|
568
|
+
expired = [e for e in vault.entries.values() if e.is_expired()]
|
|
569
|
+
soon = [
|
|
570
|
+
e
|
|
571
|
+
for e in vault.entries.values()
|
|
572
|
+
if not e.is_expired() and e.days_left() is not None and (e.days_left() or 0) <= 7
|
|
573
|
+
]
|
|
574
|
+
console.print(f"[bold]Entries:[/bold] {total}")
|
|
575
|
+
if expired:
|
|
576
|
+
console.print(
|
|
577
|
+
Panel(
|
|
578
|
+
"\n".join(f"• {e.name} — expired {e.expires_at}" for e in expired),
|
|
579
|
+
title="[red]Expired[/red]",
|
|
580
|
+
)
|
|
581
|
+
)
|
|
582
|
+
if soon:
|
|
583
|
+
console.print(
|
|
584
|
+
Panel(
|
|
585
|
+
"\n".join(f"• {e.name} — {e.days_left()}d left" for e in soon),
|
|
586
|
+
title="[yellow]Expiring within 7 days[/yellow]",
|
|
587
|
+
)
|
|
588
|
+
)
|
|
589
|
+
if not expired and not soon:
|
|
590
|
+
console.print("[green]No expired or expiring entries.[/green]")
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def cli() -> None:
|
|
594
|
+
try:
|
|
595
|
+
app()
|
|
596
|
+
except VaultError as e: # defensive: surface as friendly error
|
|
597
|
+
err_console.print(f"[red]error:[/red] {e}")
|
|
598
|
+
sys.exit(1)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
if __name__ == "__main__":
|
|
602
|
+
cli()
|
keystash/clipboard.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Clipboard copy with best-effort delayed clear.
|
|
2
|
+
|
|
3
|
+
The delayed clear runs in a detached child process so it survives this CLI's
|
|
4
|
+
exit. Best-effort by design: if no clipboard helper is found, copy still
|
|
5
|
+
succeeds and we simply skip the auto-clear.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
CLEAR_AFTER_SECONDS = 30
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _copy_cmd() -> list[str] | None:
|
|
18
|
+
if sys.platform == "darwin":
|
|
19
|
+
if shutil.which("pbcopy"):
|
|
20
|
+
return ["pbcopy"]
|
|
21
|
+
return None
|
|
22
|
+
if sys.platform == "win32":
|
|
23
|
+
if shutil.which("clip"):
|
|
24
|
+
return ["clip"]
|
|
25
|
+
return None
|
|
26
|
+
for candidate in ("wl-copy", "xclip", "xsel"):
|
|
27
|
+
if shutil.which(candidate):
|
|
28
|
+
return [candidate]
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _clear_cmd(seconds: int) -> list[str] | None:
|
|
33
|
+
if sys.platform == "darwin" and shutil.which("pbcopy"):
|
|
34
|
+
return ["bash", "-c", f'sleep {seconds}; printf "" | pbcopy']
|
|
35
|
+
if sys.platform == "win32" and shutil.which("clip"):
|
|
36
|
+
return ["cmd", "/c", f"timeout /t {seconds} >nul & cls & echo off | clip"]
|
|
37
|
+
for wl, xs in (("wl-copy", "wl-copy"), ("xclip", "xclip"), ("xsel", "xsel")):
|
|
38
|
+
if shutil.which(wl):
|
|
39
|
+
if xs == "xclip":
|
|
40
|
+
return ["bash", "-c", f'sleep {seconds}; printf "" | xclip -selection clipboard']
|
|
41
|
+
if xs == "xsel":
|
|
42
|
+
return ["bash", "-c", f'sleep {seconds}; printf "" | xsel --clipboard --input']
|
|
43
|
+
return ["bash", "-c", f'sleep {seconds}; printf "" | wl-copy']
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def copy(text: str, *, auto_clear_after: int = CLEAR_AFTER_SECONDS) -> bool:
|
|
48
|
+
cmd = _copy_cmd()
|
|
49
|
+
if cmd is None:
|
|
50
|
+
return False
|
|
51
|
+
subprocess.run(cmd, input=text.encode("utf-8"), check=True)
|
|
52
|
+
if auto_clear_after > 0:
|
|
53
|
+
clear = _clear_cmd(auto_clear_after)
|
|
54
|
+
if clear:
|
|
55
|
+
try:
|
|
56
|
+
subprocess.Popen(
|
|
57
|
+
clear,
|
|
58
|
+
stdin=subprocess.DEVNULL,
|
|
59
|
+
stdout=subprocess.DEVNULL,
|
|
60
|
+
stderr=subprocess.DEVNULL,
|
|
61
|
+
start_new_session=True,
|
|
62
|
+
)
|
|
63
|
+
except OSError:
|
|
64
|
+
pass
|
|
65
|
+
return True
|
keystash/gen.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Random secret generation backed by the `secrets` module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import secrets
|
|
6
|
+
import string
|
|
7
|
+
|
|
8
|
+
AMBIGUOUS = set("Il1O0o|`'\";:,.")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def generate(
|
|
12
|
+
length: int = 24,
|
|
13
|
+
*,
|
|
14
|
+
symbols: bool = True,
|
|
15
|
+
no_ambiguous: bool = True,
|
|
16
|
+
) -> str:
|
|
17
|
+
if length < 8:
|
|
18
|
+
raise ValueError("length must be at least 8")
|
|
19
|
+
pools = [string.ascii_lowercase, string.ascii_uppercase, string.digits]
|
|
20
|
+
if symbols:
|
|
21
|
+
pools.append("!@#$%^&*()-_=+[]{}<>?")
|
|
22
|
+
if no_ambiguous:
|
|
23
|
+
pools = ["".join(c for c in pool if c not in AMBIGUOUS) for pool in pools]
|
|
24
|
+
alphabet = "".join(pools)
|
|
25
|
+
# Guarantee at least one char from every class, then fill randomly.
|
|
26
|
+
chars = [secrets.choice(pool) for pool in pools]
|
|
27
|
+
chars += [secrets.choice(alphabet) for _ in range(length - len(chars))]
|
|
28
|
+
secrets.SystemRandom().shuffle(chars)
|
|
29
|
+
return "".join(chars)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def token_hex_compat(nbytes: int = 32) -> str:
|
|
33
|
+
return secrets.token_hex(nbytes)
|
keystash/model.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Entry model: one stored secret with metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field, replace
|
|
7
|
+
from datetime import date, datetime, timezone
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
ENTRY_VERSION = 1
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_expires(value: Optional[str]) -> Optional[date]:
|
|
14
|
+
"""Parse a user-supplied expiry as YYYY-MM-DD. Returns None for empty input."""
|
|
15
|
+
if not value:
|
|
16
|
+
return None
|
|
17
|
+
return date.fromisoformat(value.strip())
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Entry:
|
|
22
|
+
name: str
|
|
23
|
+
secret: str
|
|
24
|
+
username: str = ""
|
|
25
|
+
url: str = ""
|
|
26
|
+
tags: list[str] = field(default_factory=list)
|
|
27
|
+
notes: str = ""
|
|
28
|
+
env_var: str = ""
|
|
29
|
+
expires_at: Optional[date] = None
|
|
30
|
+
created_at: datetime = field(
|
|
31
|
+
default_factory=lambda: datetime.now(timezone.utc)
|
|
32
|
+
)
|
|
33
|
+
updated_at: datetime = field(
|
|
34
|
+
default_factory=lambda: datetime.now(timezone.utc)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def default_env_var(self) -> str:
|
|
39
|
+
if self.env_var:
|
|
40
|
+
return self.env_var
|
|
41
|
+
cleaned = re.sub(r"[^A-Za-z0-9]+", "_", self.name).strip("_").upper()
|
|
42
|
+
return cleaned or "KEYSTASH_ENTRY"
|
|
43
|
+
|
|
44
|
+
def is_expired(self, today: Optional[date] = None) -> bool:
|
|
45
|
+
if self.expires_at is None:
|
|
46
|
+
return False
|
|
47
|
+
return self.expires_at <= (today or date.today())
|
|
48
|
+
|
|
49
|
+
def days_left(self, today: Optional[date] = None) -> Optional[int]:
|
|
50
|
+
if self.expires_at is None:
|
|
51
|
+
return None
|
|
52
|
+
return (self.expires_at - (today or date.today())).days
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict[str, Any]:
|
|
55
|
+
return {
|
|
56
|
+
"version": ENTRY_VERSION,
|
|
57
|
+
"name": self.name,
|
|
58
|
+
"secret": self.secret,
|
|
59
|
+
"username": self.username,
|
|
60
|
+
"url": self.url,
|
|
61
|
+
"tags": list(self.tags),
|
|
62
|
+
"notes": self.notes,
|
|
63
|
+
"env_var": self.env_var,
|
|
64
|
+
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
|
|
65
|
+
"created_at": self.created_at.isoformat(),
|
|
66
|
+
"updated_at": self.updated_at.isoformat(),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def from_dict(cls, data: dict[str, Any]) -> "Entry":
|
|
71
|
+
known = {f for f in cls.__dataclass_fields__} # noqa: C416
|
|
72
|
+
payload = {k: v for k, v in data.items() if k in known and k != "version"}
|
|
73
|
+
payload["expires_at"] = parse_expires(payload.get("expires_at"))
|
|
74
|
+
payload["created_at"] = datetime.fromisoformat(payload["created_at"])
|
|
75
|
+
payload["updated_at"] = datetime.fromisoformat(payload["updated_at"])
|
|
76
|
+
payload["tags"] = [str(t) for t in payload.get("tags") or []]
|
|
77
|
+
return cls(**payload)
|
|
78
|
+
|
|
79
|
+
def with_updates(self, **changes: Any) -> "Entry":
|
|
80
|
+
changes["updated_at"] = datetime.now(timezone.utc)
|
|
81
|
+
return replace(self, **changes)
|
keystash/search.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Dependency-free fuzzy scoring used by `ls` and selection prompts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from .model import Entry
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _is_subsequence(needle: str, haystack: str) -> bool:
|
|
11
|
+
it = iter(haystack)
|
|
12
|
+
return all(ch in it for ch in needle)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def score_entry(query: str, entry: Entry) -> Optional[int]:
|
|
16
|
+
"""Return a relevance score, or None when the entry does not match at all."""
|
|
17
|
+
q = query.strip().lower()
|
|
18
|
+
if not q:
|
|
19
|
+
return 0
|
|
20
|
+
name = entry.name.lower()
|
|
21
|
+
if name == q:
|
|
22
|
+
return 100
|
|
23
|
+
if name.startswith(q):
|
|
24
|
+
return 90
|
|
25
|
+
if q in name:
|
|
26
|
+
return 80
|
|
27
|
+
for tag in entry.tags:
|
|
28
|
+
if tag.lower() == q:
|
|
29
|
+
return 75
|
|
30
|
+
if q in tag.lower():
|
|
31
|
+
return 70
|
|
32
|
+
for text in (entry.username, entry.url, entry.notes, entry.default_env_var):
|
|
33
|
+
if text and q in text.lower():
|
|
34
|
+
return 50
|
|
35
|
+
if _is_subsequence(q, name):
|
|
36
|
+
return 60
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def search(entries: dict, query: str) -> list[Entry]:
|
|
41
|
+
scored = []
|
|
42
|
+
for entry in entries.values():
|
|
43
|
+
s = score_entry(query, entry)
|
|
44
|
+
if s is not None:
|
|
45
|
+
scored.append((s, entry.name.lower(), entry))
|
|
46
|
+
scored.sort(key=lambda t: (-t[0], t[1]))
|
|
47
|
+
return [entry for _, _, entry in scored]
|
keystash/vault.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Encrypted vault storage.
|
|
2
|
+
|
|
3
|
+
On-disk format (single JSON file, safe to sync through any cloud drive):
|
|
4
|
+
|
|
5
|
+
{
|
|
6
|
+
"version": 1,
|
|
7
|
+
"kdf": {"name": "PBKDF2-HMAC-SHA256", "salt": "<hex>", "iterations": 600000},
|
|
8
|
+
"cipher": "Fernet",
|
|
9
|
+
"payload": "<fernet token>"
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
The Fernet token encrypts a JSON document ``{"entries": {name: entry_dict}}``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import base64
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import stat
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Dict, Optional
|
|
23
|
+
|
|
24
|
+
from cryptography.fernet import Fernet, InvalidToken
|
|
25
|
+
from cryptography.hazmat.primitives import hashes
|
|
26
|
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
27
|
+
|
|
28
|
+
from .model import Entry
|
|
29
|
+
|
|
30
|
+
VAULT_VERSION = 1
|
|
31
|
+
DEFAULT_ITERATIONS = 600_000
|
|
32
|
+
DEFAULT_VAULT_PATH = Path(os.environ.get("KEYSTASH_VAULT", "~/.keystash/vault.json"))
|
|
33
|
+
|
|
34
|
+
VAULT_EXISTS = "vault_exists"
|
|
35
|
+
BAD_PASSWORD = "bad_password"
|
|
36
|
+
NOT_FOUND = "not_found"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class VaultError(Exception):
|
|
40
|
+
"""Raised with a machine-readable ``code`` and a human message."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, code: str, message: str) -> None:
|
|
43
|
+
super().__init__(message)
|
|
44
|
+
self.code = code
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def derive_key(password: str, salt: bytes, iterations: int) -> bytes:
|
|
48
|
+
kdf = PBKDF2HMAC(
|
|
49
|
+
algorithm=hashes.SHA256(), length=32, salt=salt, iterations=iterations
|
|
50
|
+
)
|
|
51
|
+
return base64.urlsafe_b64encode(kdf.derive(password.encode("utf-8")))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Vault:
|
|
55
|
+
def __init__(self, path: Optional[os.PathLike | str] = None) -> None:
|
|
56
|
+
self.path = Path(path or DEFAULT_VAULT_PATH).expanduser()
|
|
57
|
+
self.entries: Dict[str, Entry] = {}
|
|
58
|
+
self.iterations = DEFAULT_ITERATIONS
|
|
59
|
+
|
|
60
|
+
# -- lifecycle ---------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def exists(self) -> bool:
|
|
63
|
+
return self.path.exists()
|
|
64
|
+
|
|
65
|
+
def create(self, password: str) -> None:
|
|
66
|
+
if self.exists():
|
|
67
|
+
raise VaultError(VAULT_EXISTS, f"Vault already exists at {self.path}")
|
|
68
|
+
self._write(password, {})
|
|
69
|
+
|
|
70
|
+
def load(self, password: str) -> None:
|
|
71
|
+
raw = self._read_file()
|
|
72
|
+
salt = bytes.fromhex(raw["kdf"]["salt"])
|
|
73
|
+
self.iterations = int(raw["kdf"].get("iterations", DEFAULT_ITERATIONS))
|
|
74
|
+
key = derive_key(password, salt, self.iterations)
|
|
75
|
+
try:
|
|
76
|
+
plaintext = Fernet(key).decrypt(raw["payload"].encode("ascii"))
|
|
77
|
+
except InvalidToken:
|
|
78
|
+
raise VaultError(BAD_PASSWORD, "Wrong master password.") from None
|
|
79
|
+
data = json.loads(plaintext.decode("utf-8"))
|
|
80
|
+
self.entries = {
|
|
81
|
+
name: Entry.from_dict(d) for name, d in data.get("entries", {}).items()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
def save(self, password: str) -> None:
|
|
85
|
+
if not self.exists():
|
|
86
|
+
raise VaultError(NOT_FOUND, "Vault not initialized — run `keystash init` first.")
|
|
87
|
+
self._write(password, self.entries)
|
|
88
|
+
|
|
89
|
+
# -- entry operations --------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def add(self, entry: Entry, *, overwrite: bool = False) -> None:
|
|
92
|
+
if entry.name in self.entries and not overwrite:
|
|
93
|
+
raise VaultError(
|
|
94
|
+
VAULT_EXISTS, f"Entry '{entry.name}' already exists (use --force to overwrite)."
|
|
95
|
+
)
|
|
96
|
+
self.entries[entry.name] = entry
|
|
97
|
+
|
|
98
|
+
def get(self, name: str) -> Entry:
|
|
99
|
+
if name not in self.entries:
|
|
100
|
+
raise VaultError(NOT_FOUND, f"No entry named '{name}'.")
|
|
101
|
+
return self.entries[name]
|
|
102
|
+
|
|
103
|
+
def remove(self, name: str) -> Entry:
|
|
104
|
+
entry = self.get(name)
|
|
105
|
+
del self.entries[name]
|
|
106
|
+
return entry
|
|
107
|
+
|
|
108
|
+
# -- internals -----------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def _write(self, password: str, entries: Dict[str, Entry]) -> None:
|
|
111
|
+
salt = os.urandom(16)
|
|
112
|
+
key = derive_key(password, salt, DEFAULT_ITERATIONS)
|
|
113
|
+
payload = json.dumps(
|
|
114
|
+
{"entries": {name: e.to_dict() for name, e in entries.items()}}
|
|
115
|
+
).encode("utf-8")
|
|
116
|
+
doc = {
|
|
117
|
+
"version": VAULT_VERSION,
|
|
118
|
+
"kdf": {
|
|
119
|
+
"name": "PBKDF2-HMAC-SHA256",
|
|
120
|
+
"salt": salt.hex(),
|
|
121
|
+
"iterations": DEFAULT_ITERATIONS,
|
|
122
|
+
},
|
|
123
|
+
"cipher": "Fernet",
|
|
124
|
+
"payload": Fernet(key).encrypt(payload).decode("ascii"),
|
|
125
|
+
}
|
|
126
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
tmp = self.path.with_suffix(self.path.suffix + ".tmp")
|
|
128
|
+
tmp.write_text(json.dumps(doc, indent=2), encoding="utf-8")
|
|
129
|
+
try:
|
|
130
|
+
os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR) # 0600
|
|
131
|
+
except OSError:
|
|
132
|
+
pass
|
|
133
|
+
tmp.replace(self.path)
|
|
134
|
+
|
|
135
|
+
def _read_file(self) -> dict:
|
|
136
|
+
if not self.exists():
|
|
137
|
+
raise VaultError(
|
|
138
|
+
NOT_FOUND, f"No vault at {self.path} — run `keystash init` first."
|
|
139
|
+
)
|
|
140
|
+
try:
|
|
141
|
+
return json.loads(self.path.read_text(encoding="utf-8"))
|
|
142
|
+
except json.JSONDecodeError:
|
|
143
|
+
raise VaultError("corrupt", f"Vault file {self.path} is not valid JSON.") from None
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: keystash
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local-first encrypted vault for API keys, tokens and passwords — one file, fuzzy search, env injection, expiry tracking.
|
|
5
|
+
Project-URL: Homepage, https://github.com/thu-lawyer/keystash
|
|
6
|
+
Project-URL: Repository, https://github.com/thu-lawyer/keystash
|
|
7
|
+
Project-URL: Issues, https://github.com/thu-lawyer/keystash/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/thu-lawyer/keystash/releases
|
|
9
|
+
Author: thu-lawyer
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: api-key,cli,developer-tools,password,password-manager,secrets,token,vault
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: MacOS
|
|
18
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
19
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
26
|
+
Classifier: Topic :: Security
|
|
27
|
+
Classifier: Topic :: Security :: Cryptography
|
|
28
|
+
Classifier: Topic :: Utilities
|
|
29
|
+
Requires-Python: >=3.9
|
|
30
|
+
Requires-Dist: cryptography>=41.0
|
|
31
|
+
Requires-Dist: rich>=13.0
|
|
32
|
+
Requires-Dist: typer>=0.12
|
|
33
|
+
Provides-Extra: dev
|
|
34
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
35
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# keystash 🔑
|
|
39
|
+
|
|
40
|
+
**Local-first encrypted vault for API keys, tokens and passwords — one file, fuzzy search, env injection, expiry tracking.**
|
|
41
|
+
|
|
42
|
+
Your LLM API keys, cloud tokens and passwords are scattered across `.env` files, shell
|
|
43
|
+
histories and notes apps. `keystash` puts them in **one encrypted file** that you own:
|
|
44
|
+
no server, no account, no subscription. Sync that file with iCloud / Dropbox / SeaDrive /
|
|
45
|
+
Syncthing — it's ciphertext, so syncing it is safe.
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install keystash
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Why keystash
|
|
52
|
+
|
|
53
|
+
| | keystash | pass / gopass | Bitwarden / 1Password | Infisical / Vault |
|
|
54
|
+
|---|---|---|---|---|
|
|
55
|
+
| Setup | `pip install` + one password | GPG key ceremony | Account + app | Self-host a server |
|
|
56
|
+
| Storage | one encrypted file you own | many GPG files | vendor cloud | server |
|
|
57
|
+
| Offline | ✅ always | ✅ | partial | ❌ |
|
|
58
|
+
| Dev workflow (env injection, `run`) | ✅ built-in | ❌ | ❌ | ✅ (heavy) |
|
|
59
|
+
| Token expiry tracking | ✅ built-in | ❌ | ❌ | enterprise |
|
|
60
|
+
|
|
61
|
+
## Quick start
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
# 1. Create your vault (encrypted with AES-128-CBC + HMAC, PBKDF2-HMAC-SHA256 600k iters)
|
|
65
|
+
keystash init
|
|
66
|
+
|
|
67
|
+
# 2. Store a secret
|
|
68
|
+
keystash add openai --secret sk-... --tags llm,prod --expires 2027-01-31 --env-var OPENAI_API_KEY
|
|
69
|
+
|
|
70
|
+
# 3. Retrieve it
|
|
71
|
+
keystash get openai # masked preview
|
|
72
|
+
keystash get openai -c # → clipboard, auto-clears after 30 s
|
|
73
|
+
keystash get openai -q # raw secret for scripts: export K=$(keystash get openai -q)
|
|
74
|
+
|
|
75
|
+
# 4. Find things (fuzzy)
|
|
76
|
+
keystash ls # everything, expiry warnings included
|
|
77
|
+
keystash ls oprod # fuzzy: matches openai-prod
|
|
78
|
+
keystash ls --tag llm --json
|
|
79
|
+
|
|
80
|
+
# 5. Inject secrets into any command — nothing touches your shell or disk
|
|
81
|
+
keystash run -n openai -n anthropic -- python train.py
|
|
82
|
+
keystash run --tag llm -- python train.py
|
|
83
|
+
eval "$(keystash env --tag llm)" # or export them explicitly
|
|
84
|
+
|
|
85
|
+
# 6. Migrate off plaintext .env files
|
|
86
|
+
keystash import .env # then delete the .env file
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Commands
|
|
90
|
+
|
|
91
|
+
| Command | Purpose |
|
|
92
|
+
|---|---|
|
|
93
|
+
| `init` | Create the vault |
|
|
94
|
+
| `add NAME` | Store a secret (`-s` value, `-g LEN` to generate, `-t` tags, `--expires YYYY-MM-DD`, `--env-var`) |
|
|
95
|
+
| `get NAME` | Show (`-r` reveal), copy (`-c`), raw output (`-q`) |
|
|
96
|
+
| `ls [QUERY]` | List / fuzzy search (`--tag`, `--json`) |
|
|
97
|
+
| `edit NAME` | Update any field in place |
|
|
98
|
+
| `rm NAME` | Delete an entry |
|
|
99
|
+
| `gen [LEN]` | Generate a strong secret (`--save NAME` to store it) |
|
|
100
|
+
| `env NAME…` / `--tag` | Print `export` lines for shell eval |
|
|
101
|
+
| `run -n NAME… -- CMD` | Run a command with secrets injected as env vars (`--tag` selects by tag) |
|
|
102
|
+
| `import FILE` | Bulk-import `.env` or JSON |
|
|
103
|
+
| `export` | Export metadata (or secrets with `--with-secrets`) as JSON / dotenv |
|
|
104
|
+
| `status` | Vault health + expired / expiring-soon report |
|
|
105
|
+
|
|
106
|
+
## Multi-machine sync
|
|
107
|
+
|
|
108
|
+
The vault is a single encrypted file. Point `KEYSTASH_VAULT` at any synced folder:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
export KEYSTASH_VAULT="~/CloudStorage/SeaDrive/vault.json" # or iCloud, Dropbox, …
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Each write re-encrypts with a fresh random salt, so last-writer-wins applies —
|
|
115
|
+
prefer one writer per vault at a time, like any sync file.
|
|
116
|
+
|
|
117
|
+
## Security model
|
|
118
|
+
|
|
119
|
+
- **Cipher:** Fernet (AES-128-CBC + HMAC-SHA256, encrypt-then-MAC) via `cryptography`.
|
|
120
|
+
- **Key derivation:** PBKDF2-HMAC-SHA256, 600 000 iterations, per-save 128-bit random salt.
|
|
121
|
+
- **File mode:** `0600`; nothing is ever written to disk in plaintext.
|
|
122
|
+
- **Clipboard:** copied secrets are auto-cleared after 30 s (best effort, detached process).
|
|
123
|
+
- **No network.** No telemetry. The CLI is fully offline.
|
|
124
|
+
- Secrets live in process memory only while a command runs; Python cannot guarantee
|
|
125
|
+
zeroization after exit — the same is true of any CLI in a GC'd language.
|
|
126
|
+
|
|
127
|
+
## Environment variables
|
|
128
|
+
|
|
129
|
+
| Variable | Purpose |
|
|
130
|
+
|---|---|
|
|
131
|
+
| `KEYSTASH_VAULT` | Vault file path (also `--vault`) |
|
|
132
|
+
| `KEYSTASH_PASSWORD` | Master password (for scripts/CI; prefer the interactive prompt) |
|
|
133
|
+
|
|
134
|
+
## Development
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
git clone https://github.com/thu-lawyer/keystash && cd keystash
|
|
138
|
+
uv pip install -e ".[dev]"
|
|
139
|
+
pytest
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
keystash/__init__.py,sha256=R-dspzH6j4hthcyppwpqypHfLVyU6TwAtfRh_a2wOQA,106
|
|
2
|
+
keystash/__main__.py,sha256=d1PJkDvFKuZeJLHZhFW8W5OO_JTOQqtLlz4cB4rz0Jg,94
|
|
3
|
+
keystash/cli.py,sha256=kY76YnEpHzC9dWJL669VPMXFwD8qIYBVqcawoyz0VYc,20325
|
|
4
|
+
keystash/clipboard.py,sha256=yy9I8UpoCocluJbbPYIM8tAPc5mysEvH-nil_oQU7F0,2179
|
|
5
|
+
keystash/gen.py,sha256=BrGIET7JIpHvUgS78DGRFN8myY7gfciCEIHT5xhX6AQ,977
|
|
6
|
+
keystash/model.py,sha256=tjGY3OxNaRTgk4resbfTirvbNiKOEISHYQacJaxX78U,2764
|
|
7
|
+
keystash/search.py,sha256=JQKTB7M2z5XzyldIPc3ormN8ginRlPo6ojcTkTi2nUE,1298
|
|
8
|
+
keystash/vault.py,sha256=9EL7Zt4c7IIFJ43j8rmyAeihKprWhZ2xE688iv0KRQw,4965
|
|
9
|
+
keystash-0.1.0.dist-info/METADATA,sha256=ImeDftCF73fwTAOdDFsy7h-a6XGCoYVz4Z00udrBWpg,5674
|
|
10
|
+
keystash-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
11
|
+
keystash-0.1.0.dist-info/entry_points.txt,sha256=Rawsg87kLbU7PCS15qYqIrrtxmTzn8n-dn9WvWD7pAE,46
|
|
12
|
+
keystash-0.1.0.dist-info/licenses/LICENSE,sha256=IfKtxamU913_kDwh_BNnyTWw_gcb0ypWPUANmrpe9CY,1067
|
|
13
|
+
keystash-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 thu-lawyer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|