keyward 0.0.1__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.
- keyward/__init__.py +7 -0
- keyward/__main__.py +4 -0
- keyward/agent.py +93 -0
- keyward/cli.py +269 -0
- keyward/config.py +27 -0
- keyward/daemon.py +190 -0
- keyward/discovery.py +40 -0
- keyward/inject.py +71 -0
- keyward/py.typed +0 -0
- keyward/store.py +157 -0
- keyward-0.0.1.dist-info/METADATA +222 -0
- keyward-0.0.1.dist-info/RECORD +15 -0
- keyward-0.0.1.dist-info/WHEEL +4 -0
- keyward-0.0.1.dist-info/entry_points.txt +2 -0
- keyward-0.0.1.dist-info/licenses/LICENSE +21 -0
keyward/__init__.py
ADDED
keyward/__main__.py
ADDED
keyward/agent.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import plistlib
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
LABEL = "com.keyward.daemon"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def plist_path() -> Path:
|
|
12
|
+
return Path.home() / "Library" / "LaunchAgents" / f"{LABEL}.plist"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def log_dir() -> Path:
|
|
16
|
+
return Path.home() / "Library" / "Logs" / "keyward"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _uid() -> int:
|
|
20
|
+
if not hasattr(os, "getuid"):
|
|
21
|
+
raise RuntimeError("LaunchAgent install is macOS-only; not supported on this OS.")
|
|
22
|
+
return os.getuid()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _bootout() -> None:
|
|
26
|
+
# Idempotent: non-zero exit when not loaded is fine.
|
|
27
|
+
subprocess.run(
|
|
28
|
+
["launchctl", "bootout", f"gui/{_uid()}/{LABEL}"],
|
|
29
|
+
check=False,
|
|
30
|
+
capture_output=True,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _bootstrap(plist: Path) -> None:
|
|
35
|
+
result = subprocess.run(
|
|
36
|
+
["launchctl", "bootstrap", f"gui/{_uid()}", str(plist)],
|
|
37
|
+
check=False,
|
|
38
|
+
capture_output=True,
|
|
39
|
+
)
|
|
40
|
+
if result.returncode != 0:
|
|
41
|
+
raise RuntimeError(
|
|
42
|
+
f"launchctl bootstrap failed ({result.returncode}): "
|
|
43
|
+
f"{result.stderr.decode().strip() or result.stdout.decode().strip()}"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _write_plist(plist: Path, python_executable: str) -> None:
|
|
48
|
+
plist.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
log_dir().mkdir(parents=True, exist_ok=True)
|
|
50
|
+
contents = {
|
|
51
|
+
"Label": LABEL,
|
|
52
|
+
"ProgramArguments": [python_executable, "-m", "keyward.daemon"],
|
|
53
|
+
"RunAtLoad": True,
|
|
54
|
+
"KeepAlive": True,
|
|
55
|
+
"StandardOutPath": str(log_dir() / "daemon.out"),
|
|
56
|
+
"StandardErrorPath": str(log_dir() / "daemon.err"),
|
|
57
|
+
# Don't run faster than once every 5s if the daemon crash-loops.
|
|
58
|
+
"ThrottleInterval": 5,
|
|
59
|
+
}
|
|
60
|
+
with plist.open("wb") as f:
|
|
61
|
+
plistlib.dump(contents, f)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def install(python_executable: str) -> Path:
|
|
65
|
+
"""Write the LaunchAgent plist and load it. Idempotent."""
|
|
66
|
+
path = plist_path()
|
|
67
|
+
_write_plist(path, python_executable)
|
|
68
|
+
_bootout()
|
|
69
|
+
_bootstrap(path)
|
|
70
|
+
return path
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def uninstall() -> bool:
|
|
74
|
+
"""Unload and remove the LaunchAgent. Returns True if a plist was removed."""
|
|
75
|
+
path = plist_path()
|
|
76
|
+
_bootout()
|
|
77
|
+
if path.exists():
|
|
78
|
+
path.unlink()
|
|
79
|
+
return True
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def restart() -> None:
|
|
84
|
+
"""Kickstart the LaunchAgent, forcing it to reload config and secret cache."""
|
|
85
|
+
subprocess.run(
|
|
86
|
+
["launchctl", "kickstart", "-k", f"gui/{_uid()}/{LABEL}"],
|
|
87
|
+
check=False,
|
|
88
|
+
capture_output=True,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def is_installed() -> bool:
|
|
93
|
+
return plist_path().exists()
|
keyward/cli.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from keyward import __version__, agent, store
|
|
15
|
+
from keyward.config import daemon_file, ensure_dirs
|
|
16
|
+
from keyward.discovery import live_daemon_info
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(
|
|
19
|
+
name="keyward",
|
|
20
|
+
help="Local secret broker. Keeps API keys out of files AI agents can read.",
|
|
21
|
+
no_args_is_help=True,
|
|
22
|
+
add_completion=False,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _version_cb(value: bool) -> None:
|
|
27
|
+
if value:
|
|
28
|
+
typer.echo(f"keyward {__version__}")
|
|
29
|
+
raise typer.Exit()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.callback()
|
|
33
|
+
def _root(
|
|
34
|
+
version: bool = typer.Option(
|
|
35
|
+
False,
|
|
36
|
+
"--version",
|
|
37
|
+
callback=_version_cb,
|
|
38
|
+
is_eager=True,
|
|
39
|
+
help="Show version and exit.",
|
|
40
|
+
),
|
|
41
|
+
) -> None:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _hint_restart_if_running() -> None:
|
|
46
|
+
if live_daemon_info() is not None:
|
|
47
|
+
typer.echo("hint: a daemon is running. Run 'keyward restart' to reload the change.")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.command()
|
|
51
|
+
def init(
|
|
52
|
+
uninstall: bool = typer.Option(False, "--uninstall", help="Remove the login agent."),
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Create config/state dirs and install the daemon as a login agent."""
|
|
55
|
+
ensure_dirs()
|
|
56
|
+
|
|
57
|
+
if uninstall:
|
|
58
|
+
if platform.system() != "Darwin":
|
|
59
|
+
typer.echo("uninstalling a login agent is only wired up for macOS in v0.2", err=True)
|
|
60
|
+
raise typer.Exit(1)
|
|
61
|
+
removed = agent.uninstall()
|
|
62
|
+
typer.echo("removed LaunchAgent" if removed else "no LaunchAgent was installed")
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
typer.echo(f"config dir ready: {daemon_file().parent}")
|
|
66
|
+
|
|
67
|
+
system = platform.system()
|
|
68
|
+
if system == "Darwin":
|
|
69
|
+
try:
|
|
70
|
+
path = agent.install(sys.executable)
|
|
71
|
+
except RuntimeError as e:
|
|
72
|
+
typer.echo(f"error: {e}", err=True)
|
|
73
|
+
raise typer.Exit(1) from None
|
|
74
|
+
typer.echo(f"installed LaunchAgent at {path}")
|
|
75
|
+
typer.echo("daemon will start at login (and is running now).")
|
|
76
|
+
elif system == "Linux":
|
|
77
|
+
typer.echo("TODO: systemd user-unit install (Linux) lands in v0.2.1")
|
|
78
|
+
elif system == "Windows":
|
|
79
|
+
typer.echo("TODO: scheduled-task install (Windows) lands in v0.2.1")
|
|
80
|
+
else:
|
|
81
|
+
typer.echo(f"unsupported platform: {system}", err=True)
|
|
82
|
+
raise typer.Exit(1)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.command()
|
|
86
|
+
def restart() -> None:
|
|
87
|
+
"""Restart the daemon to reload config and refresh the secret cache."""
|
|
88
|
+
if platform.system() == "Darwin" and agent.is_installed():
|
|
89
|
+
agent.restart()
|
|
90
|
+
typer.echo("kickstarted LaunchAgent daemon")
|
|
91
|
+
return
|
|
92
|
+
info = live_daemon_info()
|
|
93
|
+
if info is None:
|
|
94
|
+
typer.echo("no daemon is running")
|
|
95
|
+
return
|
|
96
|
+
try:
|
|
97
|
+
os.kill(info["pid"], signal.SIGTERM)
|
|
98
|
+
typer.echo("sent SIGTERM to ephemeral daemon; next 'keyward run' will start a fresh one")
|
|
99
|
+
except ProcessLookupError:
|
|
100
|
+
typer.echo("daemon not running")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.command()
|
|
104
|
+
def add(
|
|
105
|
+
name: str = typer.Argument(..., help="Short name for this key, e.g. openai."),
|
|
106
|
+
endpoint: str = typer.Option(..., "--endpoint", help="Allowlisted host, e.g. api.openai.com."),
|
|
107
|
+
env: list[str] = typer.Option(
|
|
108
|
+
None,
|
|
109
|
+
"--env",
|
|
110
|
+
help="Env var to set to the token (repeatable). Defaults to <NAME>_API_KEY.",
|
|
111
|
+
),
|
|
112
|
+
base_url_env: str | None = typer.Option(
|
|
113
|
+
None,
|
|
114
|
+
"--base-url-env",
|
|
115
|
+
help="Env var for the base URL pointing at the daemon. Defaults to <NAME>_BASE_URL.",
|
|
116
|
+
),
|
|
117
|
+
auth_style: str = typer.Option(
|
|
118
|
+
"bearer",
|
|
119
|
+
"--auth-style",
|
|
120
|
+
help="Upstream auth style: 'bearer' (OpenAI-style) or 'x-api-key' (Anthropic-style).",
|
|
121
|
+
),
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Store a secret in the OS keychain and mint a token for it."""
|
|
124
|
+
if auth_style not in store.AUTH_STYLES:
|
|
125
|
+
typer.echo(
|
|
126
|
+
f"error: --auth-style must be one of {store.AUTH_STYLES}, got {auth_style!r}",
|
|
127
|
+
err=True,
|
|
128
|
+
)
|
|
129
|
+
raise typer.Exit(2)
|
|
130
|
+
secret = typer.prompt("secret", hide_input=True)
|
|
131
|
+
env_vars = list(env) if env else [f"{name.upper()}_API_KEY"]
|
|
132
|
+
if base_url_env is None:
|
|
133
|
+
base_url_env = f"{name.upper()}_BASE_URL"
|
|
134
|
+
try:
|
|
135
|
+
entry = store.add_key(name, secret, endpoint, env_vars, base_url_env, auth_style)
|
|
136
|
+
except KeyError as e:
|
|
137
|
+
typer.echo(f"error: {e}", err=True)
|
|
138
|
+
raise typer.Exit(1) from None
|
|
139
|
+
typer.echo(f"added '{entry.name}' -> {entry.endpoint} ({entry.auth_style})")
|
|
140
|
+
typer.echo(f" token: {entry.token}")
|
|
141
|
+
typer.echo(f" env vars: {', '.join(entry.env_vars)}")
|
|
142
|
+
if entry.base_url_env:
|
|
143
|
+
typer.echo(f" base url: {entry.base_url_env}")
|
|
144
|
+
_hint_restart_if_running()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@app.command()
|
|
148
|
+
def rotate(name: str) -> None:
|
|
149
|
+
"""Replace the secret for an existing token. Token stays the same."""
|
|
150
|
+
secret = typer.prompt("new secret", hide_input=True)
|
|
151
|
+
entry = store.rotate_secret(name, secret)
|
|
152
|
+
if entry is None:
|
|
153
|
+
typer.echo(f"error: no key named '{name}'", err=True)
|
|
154
|
+
raise typer.Exit(1)
|
|
155
|
+
typer.echo(f"rotated secret for '{name}' (token unchanged: {entry.token})")
|
|
156
|
+
_hint_restart_if_running()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@app.command("rm")
|
|
160
|
+
def remove(
|
|
161
|
+
name: str,
|
|
162
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."),
|
|
163
|
+
) -> None:
|
|
164
|
+
"""Delete a secret and revoke its token."""
|
|
165
|
+
if store.get_key(name) is None:
|
|
166
|
+
typer.echo(f"error: no key named '{name}'", err=True)
|
|
167
|
+
raise typer.Exit(1)
|
|
168
|
+
if not yes and not typer.confirm(f"remove '{name}' and delete its secret?"):
|
|
169
|
+
raise typer.Exit(0)
|
|
170
|
+
store.remove_key(name)
|
|
171
|
+
typer.echo(f"removed '{name}'")
|
|
172
|
+
_hint_restart_if_running()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@app.command("list")
|
|
176
|
+
def list_() -> None:
|
|
177
|
+
"""Print registered keys, tokens, and endpoints."""
|
|
178
|
+
entries = store.list_keys()
|
|
179
|
+
if not entries:
|
|
180
|
+
typer.echo("(no keys registered; run 'keyward add <name> --endpoint <host>')")
|
|
181
|
+
return
|
|
182
|
+
width = max(len(e.name) for e in entries)
|
|
183
|
+
for e in entries:
|
|
184
|
+
style = f"[{e.auth_style}]" if e.auth_style != "bearer" else ""
|
|
185
|
+
typer.echo(f"{e.name.ljust(width)} {e.token} -> {e.endpoint} {style}".rstrip())
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@app.command()
|
|
189
|
+
def approve(
|
|
190
|
+
name: str = typer.Argument(..., help="Key name."),
|
|
191
|
+
host: str = typer.Argument(..., help="Host to add to this key's allowlist."),
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Add a new host to a key's allowlist. (Multi-endpoint support lands in v0.3.)"""
|
|
194
|
+
typer.echo(f"TODO: approve {host} for '{name}' (single-endpoint only in v0.2)")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@app.command()
|
|
198
|
+
def log(
|
|
199
|
+
since: str = typer.Option("1h", "--since", help="Time window, e.g. 10m, 2h, 1d."),
|
|
200
|
+
key: str | None = typer.Option(None, "--key", help="Filter to one key name."),
|
|
201
|
+
) -> None:
|
|
202
|
+
"""Tail the audit log."""
|
|
203
|
+
typer.echo(f"TODO: show audit log since={since} key={key}")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _wait_for_daemon(timeout: float = 2.0) -> dict | None:
|
|
207
|
+
deadline = time.time() + timeout
|
|
208
|
+
while time.time() < deadline:
|
|
209
|
+
if daemon_file().exists():
|
|
210
|
+
try:
|
|
211
|
+
return json.loads(daemon_file().read_text())
|
|
212
|
+
except json.JSONDecodeError:
|
|
213
|
+
pass
|
|
214
|
+
time.sleep(0.05)
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@app.command(
|
|
219
|
+
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
|
|
220
|
+
)
|
|
221
|
+
def run(ctx: typer.Context) -> None:
|
|
222
|
+
"""Run a command with daemon env vars injected.
|
|
223
|
+
|
|
224
|
+
Example: keyward run -- python app.py
|
|
225
|
+
"""
|
|
226
|
+
argv = list(ctx.args)
|
|
227
|
+
if not argv:
|
|
228
|
+
typer.echo("error: provide a command after --, e.g. keyward run -- echo hi", err=True)
|
|
229
|
+
raise typer.Exit(2)
|
|
230
|
+
|
|
231
|
+
ensure_dirs()
|
|
232
|
+
|
|
233
|
+
info = live_daemon_info()
|
|
234
|
+
daemon_proc: subprocess.Popen | None = None
|
|
235
|
+
if info is None:
|
|
236
|
+
daemon_proc = subprocess.Popen([sys.executable, "-m", "keyward.daemon"])
|
|
237
|
+
info = _wait_for_daemon()
|
|
238
|
+
if info is None:
|
|
239
|
+
daemon_proc.terminate()
|
|
240
|
+
typer.echo("error: daemon did not start in time", err=True)
|
|
241
|
+
raise typer.Exit(1)
|
|
242
|
+
|
|
243
|
+
daemon_url = f"http://{info['host']}:{info['port']}"
|
|
244
|
+
env = {**os.environ, "KEYWARD_DAEMON": daemon_url}
|
|
245
|
+
for entry in store.list_keys():
|
|
246
|
+
for var in entry.env_vars:
|
|
247
|
+
env[var] = entry.token
|
|
248
|
+
if entry.base_url_env:
|
|
249
|
+
env[entry.base_url_env] = daemon_url
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
exit_code = subprocess.run(argv, env=env).returncode
|
|
253
|
+
finally:
|
|
254
|
+
if daemon_proc is not None:
|
|
255
|
+
# This process started the daemon; clean it up. An existing long-running
|
|
256
|
+
# daemon (e.g. the LaunchAgent) is left alone.
|
|
257
|
+
with contextlib.suppress(ProcessLookupError):
|
|
258
|
+
os.kill(info["pid"], signal.SIGTERM)
|
|
259
|
+
try:
|
|
260
|
+
daemon_proc.wait(timeout=3.0)
|
|
261
|
+
except subprocess.TimeoutExpired:
|
|
262
|
+
daemon_proc.kill()
|
|
263
|
+
daemon_proc.wait(timeout=1.0)
|
|
264
|
+
|
|
265
|
+
raise typer.Exit(exit_code)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
if __name__ == "__main__":
|
|
269
|
+
app()
|
keyward/config.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def config_dir() -> Path:
|
|
8
|
+
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
|
9
|
+
return Path(base) / "keyward"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def state_dir() -> Path:
|
|
13
|
+
base = os.environ.get("XDG_STATE_HOME") or str(Path.home() / ".local" / "state")
|
|
14
|
+
return Path(base) / "keyward"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def daemon_file() -> Path:
|
|
18
|
+
return config_dir() / "daemon.json"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def audit_log() -> Path:
|
|
22
|
+
return state_dir() / "audit.log"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def ensure_dirs() -> None:
|
|
26
|
+
config_dir().mkdir(parents=True, exist_ok=True)
|
|
27
|
+
state_dir().mkdir(parents=True, exist_ok=True)
|
keyward/daemon.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import signal
|
|
8
|
+
import socket
|
|
9
|
+
import sys
|
|
10
|
+
from typing import NamedTuple
|
|
11
|
+
|
|
12
|
+
from aiohttp import ClientError, ClientSession, web
|
|
13
|
+
|
|
14
|
+
from keyward import store
|
|
15
|
+
from keyward.config import daemon_file, ensure_dirs
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("keyward.daemon")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CacheEntry(NamedTuple):
|
|
21
|
+
name: str
|
|
22
|
+
endpoint: str
|
|
23
|
+
secret: str
|
|
24
|
+
auth_style: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
CACHE_KEY: web.AppKey[dict[str, CacheEntry]] = web.AppKey("cache", dict[str, CacheEntry])
|
|
28
|
+
CLIENT_KEY: web.AppKey[ClientSession] = web.AppKey("client", ClientSession)
|
|
29
|
+
|
|
30
|
+
# Hop-by-hop headers (RFC 2616) plus auth headers we rewrite ourselves.
|
|
31
|
+
# x-api-key is stripped so a client's fake token never reaches upstream.
|
|
32
|
+
HOP_BY_HOP = {
|
|
33
|
+
"connection",
|
|
34
|
+
"keep-alive",
|
|
35
|
+
"proxy-authenticate",
|
|
36
|
+
"proxy-authorization",
|
|
37
|
+
"te",
|
|
38
|
+
"trailers",
|
|
39
|
+
"transfer-encoding",
|
|
40
|
+
"upgrade",
|
|
41
|
+
"content-length",
|
|
42
|
+
"host",
|
|
43
|
+
"authorization",
|
|
44
|
+
"x-api-key",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _extract_token(request: web.Request) -> str | None:
|
|
49
|
+
auth = request.headers.get("Authorization", "")
|
|
50
|
+
if auth.startswith("Bearer "):
|
|
51
|
+
candidate = auth[len("Bearer ") :].strip()
|
|
52
|
+
if candidate.startswith("kw_"):
|
|
53
|
+
return candidate
|
|
54
|
+
candidate = request.headers.get("x-api-key", "").strip()
|
|
55
|
+
if candidate.startswith("kw_"):
|
|
56
|
+
return candidate
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def handle(request: web.Request) -> web.StreamResponse:
|
|
61
|
+
token = _extract_token(request)
|
|
62
|
+
if token is None:
|
|
63
|
+
return web.Response(
|
|
64
|
+
status=401,
|
|
65
|
+
text="missing keyward token (expected Authorization: Bearer kw_... or x-api-key: kw_...)\n",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
cache = request.app[CACHE_KEY]
|
|
69
|
+
entry = cache.get(token)
|
|
70
|
+
if entry is None:
|
|
71
|
+
return web.Response(status=403, text="unknown token\n")
|
|
72
|
+
|
|
73
|
+
base = entry.endpoint if "://" in entry.endpoint else f"https://{entry.endpoint}"
|
|
74
|
+
if not (base.startswith("https://") or base.startswith("http://")):
|
|
75
|
+
# Defense in depth: reject unexpected schemes even if config.toml was tampered with.
|
|
76
|
+
logger.error("rejecting non-http(s) endpoint for '%s'", entry.name)
|
|
77
|
+
return web.Response(status=502, text="invalid upstream scheme\n")
|
|
78
|
+
target_url = f"{base}{request.path_qs}"
|
|
79
|
+
|
|
80
|
+
out_headers = {k: v for k, v in request.headers.items() if k.lower() not in HOP_BY_HOP}
|
|
81
|
+
if entry.auth_style == "x-api-key":
|
|
82
|
+
out_headers["x-api-key"] = entry.secret
|
|
83
|
+
else:
|
|
84
|
+
out_headers["Authorization"] = f"Bearer {entry.secret}"
|
|
85
|
+
|
|
86
|
+
body = await request.read()
|
|
87
|
+
|
|
88
|
+
logger.info("%s %s -> %s [%s]", request.method, request.path, entry.endpoint, entry.name)
|
|
89
|
+
|
|
90
|
+
client = request.app[CLIENT_KEY]
|
|
91
|
+
try:
|
|
92
|
+
upstream_cm = client.request(
|
|
93
|
+
request.method,
|
|
94
|
+
target_url,
|
|
95
|
+
headers=out_headers,
|
|
96
|
+
data=body if body else None,
|
|
97
|
+
allow_redirects=False,
|
|
98
|
+
)
|
|
99
|
+
except ClientError as e:
|
|
100
|
+
logger.warning("upstream connect error: %s", e)
|
|
101
|
+
return web.Response(status=502, text=f"upstream error: {e}\n")
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
async with upstream_cm as upstream:
|
|
105
|
+
resp_headers = {
|
|
106
|
+
k: v for k, v in upstream.headers.items() if k.lower() not in HOP_BY_HOP
|
|
107
|
+
}
|
|
108
|
+
resp = web.StreamResponse(status=upstream.status, headers=resp_headers)
|
|
109
|
+
await resp.prepare(request)
|
|
110
|
+
# iter_any yields as soon as bytes arrive; important for SSE streams.
|
|
111
|
+
async for chunk in upstream.content.iter_any():
|
|
112
|
+
await resp.write(chunk)
|
|
113
|
+
await resp.write_eof()
|
|
114
|
+
return resp
|
|
115
|
+
except ClientError as e:
|
|
116
|
+
logger.warning("upstream stream error: %s", e)
|
|
117
|
+
return web.Response(status=502, text=f"upstream error: {e}\n")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def build_cache() -> dict[str, CacheEntry]:
|
|
121
|
+
cache: dict[str, CacheEntry] = {}
|
|
122
|
+
for entry in store.list_keys():
|
|
123
|
+
s = store.read_secret(entry.name)
|
|
124
|
+
if s is None:
|
|
125
|
+
logger.warning("no keychain entry for '%s'; skipping", entry.name)
|
|
126
|
+
continue
|
|
127
|
+
cache[entry.token] = CacheEntry(entry.name, entry.endpoint, s, entry.auth_style)
|
|
128
|
+
return cache
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def create_app(cache: dict[str, CacheEntry]) -> web.Application:
|
|
132
|
+
app = web.Application()
|
|
133
|
+
app[CACHE_KEY] = cache
|
|
134
|
+
|
|
135
|
+
async def _on_startup(app: web.Application) -> None:
|
|
136
|
+
app[CLIENT_KEY] = ClientSession()
|
|
137
|
+
|
|
138
|
+
async def _on_cleanup(app: web.Application) -> None:
|
|
139
|
+
await app[CLIENT_KEY].close()
|
|
140
|
+
|
|
141
|
+
app.on_startup.append(_on_startup)
|
|
142
|
+
app.on_cleanup.append(_on_cleanup)
|
|
143
|
+
app.router.add_route("*", "/{path:.*}", handle)
|
|
144
|
+
return app
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def _run() -> None:
|
|
148
|
+
ensure_dirs()
|
|
149
|
+
|
|
150
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
151
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
152
|
+
sock.bind(("127.0.0.1", 0))
|
|
153
|
+
host, port = sock.getsockname()[:2]
|
|
154
|
+
|
|
155
|
+
cache = build_cache()
|
|
156
|
+
app = create_app(cache)
|
|
157
|
+
|
|
158
|
+
runner = web.AppRunner(app)
|
|
159
|
+
await runner.setup()
|
|
160
|
+
site = web.SockSite(runner, sock)
|
|
161
|
+
await site.start()
|
|
162
|
+
|
|
163
|
+
daemon_file().write_text(json.dumps({"host": host, "port": port, "pid": os.getpid()}))
|
|
164
|
+
logger.info("listening on %s:%d (%d keys cached)", host, port, len(cache))
|
|
165
|
+
|
|
166
|
+
stop = asyncio.Event()
|
|
167
|
+
loop = asyncio.get_running_loop()
|
|
168
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
169
|
+
loop.add_signal_handler(sig, stop.set)
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
await stop.wait()
|
|
173
|
+
finally:
|
|
174
|
+
daemon_file().unlink(missing_ok=True)
|
|
175
|
+
await runner.cleanup()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def main() -> None:
|
|
179
|
+
logging.basicConfig(
|
|
180
|
+
level=logging.INFO,
|
|
181
|
+
format="%(asctime)s %(name)s %(levelname)s %(message)s",
|
|
182
|
+
)
|
|
183
|
+
try:
|
|
184
|
+
asyncio.run(_run())
|
|
185
|
+
except KeyboardInterrupt:
|
|
186
|
+
sys.exit(0)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
if __name__ == "__main__":
|
|
190
|
+
main()
|
keyward/discovery.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Daemon discovery: read daemon.json and check whether the registered process is alive."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from keyward.config import daemon_file
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def live_daemon_info() -> dict[str, Any] | None:
|
|
13
|
+
"""Return {host, port, pid} if a daemon is registered and its PID is alive, else None."""
|
|
14
|
+
path = daemon_file()
|
|
15
|
+
if not path.exists():
|
|
16
|
+
return None
|
|
17
|
+
try:
|
|
18
|
+
info = json.loads(path.read_text())
|
|
19
|
+
except (json.JSONDecodeError, OSError):
|
|
20
|
+
return None
|
|
21
|
+
pid = info.get("pid")
|
|
22
|
+
if not isinstance(pid, int):
|
|
23
|
+
return None
|
|
24
|
+
try:
|
|
25
|
+
os.kill(pid, 0)
|
|
26
|
+
except (ProcessLookupError, PermissionError):
|
|
27
|
+
return None
|
|
28
|
+
return info
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def live_daemon_url() -> str | None:
|
|
32
|
+
"""Return http://host:port for the live daemon, else None."""
|
|
33
|
+
info = live_daemon_info()
|
|
34
|
+
if info is None:
|
|
35
|
+
return None
|
|
36
|
+
host = info.get("host")
|
|
37
|
+
port = info.get("port")
|
|
38
|
+
if not host or not port:
|
|
39
|
+
return None
|
|
40
|
+
return f"http://{host}:{port}"
|
keyward/inject.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Runtime activation: rewrite env vars holding keyward tokens so SDKs talk to the daemon."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from keyward.discovery import live_daemon_url
|
|
9
|
+
from keyward.store import list_keys
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DaemonNotRunning(RuntimeError):
|
|
13
|
+
"""Raised by activate(strict=True) when no live keyward daemon is registered."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class ActivateResult:
|
|
18
|
+
"""Outcome of a keyward.activate() call.
|
|
19
|
+
|
|
20
|
+
Truthy when at least one key was activated, so existing ``if keyward.activate():``
|
|
21
|
+
checks keep working without modification.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
activated: list[str] = field(default_factory=list)
|
|
25
|
+
"""Keys whose base_url_env was rewritten to point at the daemon."""
|
|
26
|
+
|
|
27
|
+
skipped_no_env: list[str] = field(default_factory=list)
|
|
28
|
+
"""Keys whose token was not found in any of their configured env_vars."""
|
|
29
|
+
|
|
30
|
+
skipped_no_base_url: list[str] = field(default_factory=list)
|
|
31
|
+
"""Keys that have no base_url_env configured — nothing to rewrite."""
|
|
32
|
+
|
|
33
|
+
def __bool__(self) -> bool:
|
|
34
|
+
return bool(self.activated)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def activate(*, strict: bool = True) -> ActivateResult:
|
|
38
|
+
"""Point SDKs at the local keyward daemon for any env var holding a keyward token.
|
|
39
|
+
|
|
40
|
+
For each registered key, if any of its env_vars is set in os.environ to the
|
|
41
|
+
entry's token, set the entry's base_url_env to the daemon URL. Real keys
|
|
42
|
+
(values that don't match a registered token) and unset vars are left alone.
|
|
43
|
+
|
|
44
|
+
Returns an ActivateResult with three lists: activated, skipped_no_env,
|
|
45
|
+
skipped_no_base_url. The result is truthy when at least one key was activated.
|
|
46
|
+
|
|
47
|
+
With strict=True (default), raises DaemonNotRunning if no live daemon is
|
|
48
|
+
registered. With strict=False, returns an empty result and leaves env untouched.
|
|
49
|
+
"""
|
|
50
|
+
url = live_daemon_url()
|
|
51
|
+
if url is None:
|
|
52
|
+
if strict:
|
|
53
|
+
raise DaemonNotRunning(
|
|
54
|
+
"keyward: no live daemon. Run 'keyward init' to install the login "
|
|
55
|
+
"agent, or wrap the process with 'keyward run'."
|
|
56
|
+
)
|
|
57
|
+
return ActivateResult()
|
|
58
|
+
|
|
59
|
+
result = ActivateResult()
|
|
60
|
+
for entry in list_keys():
|
|
61
|
+
if not entry.base_url_env:
|
|
62
|
+
result.skipped_no_base_url.append(entry.name)
|
|
63
|
+
continue
|
|
64
|
+
if not any(os.environ.get(var) == entry.token for var in entry.env_vars):
|
|
65
|
+
result.skipped_no_env.append(entry.name)
|
|
66
|
+
continue
|
|
67
|
+
os.environ[entry.base_url_env] = url
|
|
68
|
+
result.activated.append(entry.name)
|
|
69
|
+
|
|
70
|
+
os.environ.setdefault("KEYWARD_DAEMON", url)
|
|
71
|
+
return result
|
keyward/py.typed
ADDED
|
File without changes
|
keyward/store.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import datetime as dt
|
|
5
|
+
import secrets
|
|
6
|
+
import tomllib
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import keyring
|
|
12
|
+
import keyring.errors
|
|
13
|
+
import tomli_w
|
|
14
|
+
|
|
15
|
+
from keyward.config import config_dir, ensure_dirs
|
|
16
|
+
|
|
17
|
+
KEYCHAIN_SERVICE = "keyward"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def config_file() -> Path:
|
|
21
|
+
return config_dir() / "config.toml"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
AUTH_STYLES = ("bearer", "x-api-key")
|
|
25
|
+
ALLOWED_SCHEMES = ("http", "https")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _validate_endpoint(endpoint: str) -> None:
|
|
29
|
+
if "://" in endpoint:
|
|
30
|
+
scheme = endpoint.split("://", 1)[0].lower()
|
|
31
|
+
if scheme not in ALLOWED_SCHEMES:
|
|
32
|
+
raise ValueError(f"endpoint scheme must be one of {ALLOWED_SCHEMES}, got {scheme!r}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class KeyEntry:
|
|
37
|
+
name: str
|
|
38
|
+
token: str
|
|
39
|
+
endpoint: str
|
|
40
|
+
env_vars: list[str] = field(default_factory=list)
|
|
41
|
+
base_url_env: str | None = None
|
|
42
|
+
auth_style: str = "bearer"
|
|
43
|
+
created: str = ""
|
|
44
|
+
|
|
45
|
+
def to_dict(self) -> dict[str, Any]:
|
|
46
|
+
d: dict[str, Any] = {
|
|
47
|
+
"token": self.token,
|
|
48
|
+
"endpoint": self.endpoint,
|
|
49
|
+
"env_vars": self.env_vars,
|
|
50
|
+
"auth_style": self.auth_style,
|
|
51
|
+
"created": self.created,
|
|
52
|
+
}
|
|
53
|
+
if self.base_url_env:
|
|
54
|
+
d["base_url_env"] = self.base_url_env
|
|
55
|
+
return d
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_dict(cls, name: str, d: dict[str, Any]) -> KeyEntry:
|
|
59
|
+
return cls(
|
|
60
|
+
name=name,
|
|
61
|
+
token=d["token"],
|
|
62
|
+
endpoint=d["endpoint"],
|
|
63
|
+
env_vars=list(d.get("env_vars", [])),
|
|
64
|
+
base_url_env=d.get("base_url_env"),
|
|
65
|
+
# default to bearer for configs written before auth_style existed
|
|
66
|
+
auth_style=d.get("auth_style", "bearer"),
|
|
67
|
+
created=d.get("created", ""),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def mint_token() -> str:
|
|
72
|
+
return "kw_" + secrets.token_hex(8)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _load_raw() -> dict[str, Any]:
|
|
76
|
+
path = config_file()
|
|
77
|
+
if not path.exists():
|
|
78
|
+
return {}
|
|
79
|
+
with path.open("rb") as f:
|
|
80
|
+
return tomllib.load(f)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _save_raw(data: dict[str, Any]) -> None:
|
|
84
|
+
ensure_dirs()
|
|
85
|
+
with config_file().open("wb") as f:
|
|
86
|
+
tomli_w.dump(data, f)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def list_keys() -> list[KeyEntry]:
|
|
90
|
+
data = _load_raw()
|
|
91
|
+
return [KeyEntry.from_dict(n, d) for n, d in data.get("keys", {}).items()]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def get_key(name: str) -> KeyEntry | None:
|
|
95
|
+
data = _load_raw()
|
|
96
|
+
d = data.get("keys", {}).get(name)
|
|
97
|
+
return KeyEntry.from_dict(name, d) if d else None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_key_by_token(token: str) -> KeyEntry | None:
|
|
101
|
+
for k in list_keys():
|
|
102
|
+
if k.token == token:
|
|
103
|
+
return k
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def add_key(
|
|
108
|
+
name: str,
|
|
109
|
+
secret: str,
|
|
110
|
+
endpoint: str,
|
|
111
|
+
env_vars: list[str],
|
|
112
|
+
base_url_env: str | None,
|
|
113
|
+
auth_style: str = "bearer",
|
|
114
|
+
) -> KeyEntry:
|
|
115
|
+
if auth_style not in AUTH_STYLES:
|
|
116
|
+
raise ValueError(f"auth_style must be one of {AUTH_STYLES}, got {auth_style!r}")
|
|
117
|
+
_validate_endpoint(endpoint)
|
|
118
|
+
if get_key(name) is not None:
|
|
119
|
+
raise KeyError(f"key '{name}' already exists; use 'keyward rotate' to change its secret")
|
|
120
|
+
keyring.set_password(KEYCHAIN_SERVICE, name, secret)
|
|
121
|
+
entry = KeyEntry(
|
|
122
|
+
name=name,
|
|
123
|
+
token=mint_token(),
|
|
124
|
+
endpoint=endpoint,
|
|
125
|
+
env_vars=env_vars,
|
|
126
|
+
base_url_env=base_url_env,
|
|
127
|
+
auth_style=auth_style,
|
|
128
|
+
created=dt.datetime.now(dt.UTC).isoformat(timespec="seconds"),
|
|
129
|
+
)
|
|
130
|
+
data = _load_raw()
|
|
131
|
+
data.setdefault("keys", {})[name] = entry.to_dict()
|
|
132
|
+
_save_raw(data)
|
|
133
|
+
return entry
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def remove_key(name: str) -> bool:
|
|
137
|
+
data = _load_raw()
|
|
138
|
+
keys = data.get("keys", {})
|
|
139
|
+
if name not in keys:
|
|
140
|
+
return False
|
|
141
|
+
del keys[name]
|
|
142
|
+
_save_raw(data)
|
|
143
|
+
with contextlib.suppress(keyring.errors.PasswordDeleteError):
|
|
144
|
+
keyring.delete_password(KEYCHAIN_SERVICE, name)
|
|
145
|
+
return True
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def rotate_secret(name: str, new_secret: str) -> KeyEntry | None:
|
|
149
|
+
entry = get_key(name)
|
|
150
|
+
if entry is None:
|
|
151
|
+
return None
|
|
152
|
+
keyring.set_password(KEYCHAIN_SERVICE, name, new_secret)
|
|
153
|
+
return entry
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def read_secret(name: str) -> str | None:
|
|
157
|
+
return keyring.get_password(KEYCHAIN_SERVICE, name)
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: keyward
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Local secret broker that keeps API keys out of files AI agents can read.
|
|
5
|
+
Project-URL: Homepage, https://github.com/sumedhrasal/keyward
|
|
6
|
+
Project-URL: Repository, https://github.com/sumedhrasal/keyward
|
|
7
|
+
Author: sumedh
|
|
8
|
+
License: MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2026 sumedh
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Keywords: ai-agents,api-keys,proxy,secrets,security
|
|
31
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
32
|
+
Classifier: Environment :: Console
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Operating System :: MacOS
|
|
36
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
37
|
+
Classifier: Programming Language :: Python :: 3
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
41
|
+
Classifier: Topic :: Security
|
|
42
|
+
Requires-Python: >=3.11
|
|
43
|
+
Requires-Dist: aiohttp>=3.9
|
|
44
|
+
Requires-Dist: keyring>=24.0
|
|
45
|
+
Requires-Dist: tomli-w>=1.0
|
|
46
|
+
Requires-Dist: typer>=0.12
|
|
47
|
+
Description-Content-Type: text/markdown
|
|
48
|
+
|
|
49
|
+
# keyward
|
|
50
|
+
|
|
51
|
+
A local secret broker for developers who run AI coding agents on their own machines.
|
|
52
|
+
|
|
53
|
+
## The goal
|
|
54
|
+
|
|
55
|
+
Keep API keys out of any file an AI agent, co-pilot, or third-party tool can read,
|
|
56
|
+
without adding friction to normal development.
|
|
57
|
+
|
|
58
|
+
Your code never contains real keys. It contains opaque tokens like `kw_ab12cd34`.
|
|
59
|
+
A local daemon swaps the token for the real key only when the outbound request
|
|
60
|
+
goes to an allowlisted endpoint, and records every use.
|
|
61
|
+
|
|
62
|
+
If an agent reads your code, config, or environment, it sees tokens. Tokens are
|
|
63
|
+
useless off-host: they only resolve inside the daemon, which will not forward
|
|
64
|
+
them to destinations you have not explicitly approved.
|
|
65
|
+
|
|
66
|
+
## Why this is not just encryption
|
|
67
|
+
|
|
68
|
+
"One-way encryption you can decrypt" does not exist. What this package actually
|
|
69
|
+
provides is **tokenization plus a scoped, audited forward proxy**. The security
|
|
70
|
+
properties that matter are:
|
|
71
|
+
|
|
72
|
+
- real secrets live in the OS keychain, never on disk in plaintext
|
|
73
|
+
- code and config contain only tokens
|
|
74
|
+
- the daemon forwards to an allowlist, so a leaked token cannot exfiltrate data to a new host
|
|
75
|
+
- every resolution is logged
|
|
76
|
+
- new destinations require explicit user approval
|
|
77
|
+
|
|
78
|
+
## Intended user experience
|
|
79
|
+
|
|
80
|
+
Onboarding is the product. If any step feels heavier than `export KEY=...`,
|
|
81
|
+
it has failed its design goal.
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
# one-time setup
|
|
85
|
+
pip install keyward
|
|
86
|
+
keyward init
|
|
87
|
+
|
|
88
|
+
# add a key (prompts for the secret; never passed on the command line)
|
|
89
|
+
keyward add openai --endpoint api.openai.com
|
|
90
|
+
|
|
91
|
+
# run any program with tokens injected as env vars
|
|
92
|
+
keyward run -- python app.py
|
|
93
|
+
keyward run -- pytest
|
|
94
|
+
keyward run -- npm start
|
|
95
|
+
|
|
96
|
+
# rotate a key in place; tokens stay the same so no code changes
|
|
97
|
+
keyward rotate openai
|
|
98
|
+
|
|
99
|
+
# list, remove, inspect
|
|
100
|
+
keyward list
|
|
101
|
+
keyward rm openai
|
|
102
|
+
keyward log --since 1h
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Your code stays boring:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
import os, openai
|
|
109
|
+
client = openai.OpenAI() # reads OPENAI_API_KEY and OPENAI_BASE_URL from env
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Under `keyward run`, those variables point at the local daemon with a token.
|
|
113
|
+
Outside `keyward run`, they are not set at all.
|
|
114
|
+
|
|
115
|
+
## Activating from inside your app
|
|
116
|
+
|
|
117
|
+
If you don't want to wrap every command with `keyward run`, call
|
|
118
|
+
`keyward.activate()` once near the top of your app. With the daemon installed
|
|
119
|
+
as a login agent (`keyward init`), this is all you need:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
# .env (or your normal env-loading mechanism)
|
|
123
|
+
# OPENAI_API_KEY=kw_ab12cd34
|
|
124
|
+
import os
|
|
125
|
+
from dotenv import load_dotenv
|
|
126
|
+
load_dotenv()
|
|
127
|
+
|
|
128
|
+
import keyward
|
|
129
|
+
keyward.activate() # rewrites OPENAI_BASE_URL to point at the daemon
|
|
130
|
+
|
|
131
|
+
from openai import OpenAI
|
|
132
|
+
client = OpenAI() # transparently goes through keyward
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`activate()` looks at every registered key, and for each one whose `env_vars`
|
|
136
|
+
already hold its token in `os.environ`, sets the matching `base_url_env` to the
|
|
137
|
+
daemon URL. Real keys are left alone. It also exports `KEYWARD_DAEMON` as a
|
|
138
|
+
stable signal you can check from your code (`if "KEYWARD_DAEMON" in os.environ:
|
|
139
|
+
...`) to confirm activation.
|
|
140
|
+
|
|
141
|
+
It returns a `keyward.ActivateResult` with three lists so you can see exactly
|
|
142
|
+
what happened:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
result = keyward.activate(strict=False)
|
|
146
|
+
if result.skipped_no_env:
|
|
147
|
+
print(f"token not found in env for: {result.skipped_no_env}")
|
|
148
|
+
print("Did you load your .env file before calling activate()?")
|
|
149
|
+
# result.activated — keys that are now routing through the daemon
|
|
150
|
+
# result.skipped_no_env — keys whose token was not found in any env var
|
|
151
|
+
# result.skipped_no_base_url — keys with no base_url_env configured
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
If no daemon is running, `activate()` raises `keyward.DaemonNotRunning`. Pass
|
|
155
|
+
`strict=False` to return an empty result instead — useful for code that should
|
|
156
|
+
work both with and without keyward installed.
|
|
157
|
+
|
|
158
|
+
## What works today (v0.2)
|
|
159
|
+
|
|
160
|
+
| Area | Status |
|
|
161
|
+
|-----------------------|------------------------------------------------------------------------|
|
|
162
|
+
| CLI commands | `init`, `add`, `list`, `rm`, `rotate`, `restart`, `run` all functional |
|
|
163
|
+
| Keychain storage | macOS Keychain, Windows Credential Manager, Linux libsecret via `keyring` |
|
|
164
|
+
| Proxy forwarding | Authorization: Bearer and x-api-key, on both ingress and egress |
|
|
165
|
+
| Streaming | Server-Sent Events forwarded without buffering |
|
|
166
|
+
| Login agent | macOS LaunchAgent install/uninstall/kickstart via `keyward init` |
|
|
167
|
+
| Daemon reuse | `keyward run` reuses a live daemon; else spawns ephemeral |
|
|
168
|
+
| Audit log | Stub only (prints TODO; no log is written yet) |
|
|
169
|
+
| Endpoint enforcement | Each token is bound to one host at `keyward add` time; the daemon ignores the request host and always forwards to the stored endpoint — so a token cannot be used against a different host |
|
|
170
|
+
| Multi-endpoint allowlist + approval flow | Not yet — v0.3 scope; see ARCHITECTURE.md |
|
|
171
|
+
| Linux systemd / Windows scheduled task | Not wired up yet |
|
|
172
|
+
| Websocket proxying | Returns 501; HTTP only for now |
|
|
173
|
+
| Request body streaming| Buffered; fine for LLM chat, not for large uploads |
|
|
174
|
+
| Caller attestation | Trust-anything on localhost; see ARCHITECTURE.md |
|
|
175
|
+
|
|
176
|
+
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full design, threat
|
|
177
|
+
model, and the list of deferred items.
|
|
178
|
+
|
|
179
|
+
## Verifying the key swap
|
|
180
|
+
|
|
181
|
+
The sharpest test is to point keyward at a request-echoing endpoint and look
|
|
182
|
+
for your raw secret (and the absence of the token) in the response.
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
# pick a distinctive fake secret so you can spot it in the echo
|
|
186
|
+
keyward add echotest --endpoint httpbin.org
|
|
187
|
+
# at the prompt, enter: sk-fake-secret-12345
|
|
188
|
+
|
|
189
|
+
keyward restart # only needed if a LaunchAgent daemon is already running
|
|
190
|
+
|
|
191
|
+
keyward run -- curl -s "$ECHOTEST_BASE_URL/anything" \
|
|
192
|
+
-H "Authorization: Bearer $ECHOTEST_API_KEY"
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
In the JSON response, under `headers.Authorization`:
|
|
196
|
+
- `Bearer sk-fake-secret-12345` means the swap worked.
|
|
197
|
+
- Anything starting with `Bearer kw_` means the swap did not happen (bug).
|
|
198
|
+
|
|
199
|
+
For the Anthropic-style (x-api-key):
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
keyward add echotestx --endpoint httpbin.org --auth-style x-api-key
|
|
203
|
+
keyward restart
|
|
204
|
+
keyward run -- curl -s "$ECHOTESTX_BASE_URL/anything" \
|
|
205
|
+
-H "x-api-key: $ECHOTESTX_API_KEY"
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Check `headers.X-Api-Key` in the response.
|
|
209
|
+
|
|
210
|
+
Clean up with `keyward rm echotest -y && keyward rm echotestx -y`.
|
|
211
|
+
|
|
212
|
+
There is also a Python equivalent that uses `keyward.activate()`:
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
keyward add echotest --endpoint httpbin.org
|
|
216
|
+
keyward restart
|
|
217
|
+
uv run python scripts/verify_swap.py echotest
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## License
|
|
221
|
+
|
|
222
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
keyward/__init__.py,sha256=c-Ho30xFe6WSf6otERWOV40Kg1ke_11yrQW0nQk8b2Q,250
|
|
2
|
+
keyward/__main__.py,sha256=rfe4NvMNbr529NYVRRzK7zla8KyTJnGX9yKq-fnhb1Q,66
|
|
3
|
+
keyward/agent.py,sha256=BF7I1-mXAGuaLOZhkUYbE5YXEaIytMIdDiY-qkDcHUs,2508
|
|
4
|
+
keyward/cli.py,sha256=Q1uahOxzFbpGw8Z-Vz-FnIOHfI2FVlj0t662Ior-qAA,8828
|
|
5
|
+
keyward/config.py,sha256=ng0FCwvs5elCI-IG7sVH7zDbyC8u1KYjKTNTo7ddEs8,621
|
|
6
|
+
keyward/daemon.py,sha256=nw7pJ16MBU3uHvPg22Pw1qNZWYFshOudTSwOTBG_IpA,5786
|
|
7
|
+
keyward/discovery.py,sha256=qOrBEQiOPq1R72E3T-gO2I5JrNxzoekehjBYeSVHOQs,1061
|
|
8
|
+
keyward/inject.py,sha256=TUKhHOxz3ZSnZGqs33pc8fEBotfr7zgJNPz5WqfK4-8,2589
|
|
9
|
+
keyward/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
keyward/store.py,sha256=dY2majUn6SgvFm3O0V32sJwOZCLt-gDYQ5K2UMwLiio,4152
|
|
11
|
+
keyward-0.0.1.dist-info/METADATA,sha256=thmGi-3RsiCcTfzMQuOafHhEt6dZGV7m5KyYjTzD3OI,8958
|
|
12
|
+
keyward-0.0.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
13
|
+
keyward-0.0.1.dist-info/entry_points.txt,sha256=JM_NpweffKRbcGU8oQuSuPTnnmoxhyokKTfmKS2mXlw,44
|
|
14
|
+
keyward-0.0.1.dist-info/licenses/LICENSE,sha256=SKD37zOb0Yc0gnPgr3IhnIu3cJMeer2FBrjDc9IwTWI,1063
|
|
15
|
+
keyward-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 sumedh
|
|
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.
|