yoink-py 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.
- yoink/__init__.py +1 -0
- yoink/access.py +268 -0
- yoink/cli.py +303 -0
- yoink/crypto.py +64 -0
- yoink/identity.py +90 -0
- yoink/secrets.py +139 -0
- yoink/store.py +142 -0
- yoink_py-0.1.0.dist-info/METADATA +126 -0
- yoink_py-0.1.0.dist-info/RECORD +13 -0
- yoink_py-0.1.0.dist-info/WHEEL +5 -0
- yoink_py-0.1.0.dist-info/entry_points.txt +2 -0
- yoink_py-0.1.0.dist-info/licenses/LICENSE +22 -0
- yoink_py-0.1.0.dist-info/top_level.txt +1 -0
yoink/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
yoink/access.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Access — request workflow and the access editor buffer."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .crypto import reencrypt_file, encrypt_file
|
|
9
|
+
from .identity import Identity, create_identity, create_recovery_identity
|
|
10
|
+
from .store import Manifest, find_enc_files, get_git_username, requests_dir, vault_dir, global_dir
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# Request creation (run by new dev)
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
def create_request(username: str, environments: list[str]) -> tuple[str, str]:
|
|
17
|
+
"""Generate keypair + recovery key, write request JSON.
|
|
18
|
+
|
|
19
|
+
Returns (request_path, recovery_secret_key) so CLI can print the key.
|
|
20
|
+
"""
|
|
21
|
+
from .store import YOINK_DIR
|
|
22
|
+
identity = create_identity(username)
|
|
23
|
+
recovery, recovery_secret = create_recovery_identity(f"{username}-recovery")
|
|
24
|
+
|
|
25
|
+
data = {
|
|
26
|
+
"identity": username,
|
|
27
|
+
"public_key": identity.public_key,
|
|
28
|
+
"recovery_public_key": recovery.public_key,
|
|
29
|
+
"environments": environments,
|
|
30
|
+
"requested_at": datetime.now(timezone.utc).isoformat(),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
rdir = requests_dir()
|
|
34
|
+
rdir.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
path = rdir / f"{username}.json"
|
|
36
|
+
path.write_text(json.dumps(data, indent=2) + "\n")
|
|
37
|
+
|
|
38
|
+
return str(path), recovery_secret
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_requests() -> list[dict]:
|
|
42
|
+
rdir = requests_dir()
|
|
43
|
+
if not rdir.exists():
|
|
44
|
+
return []
|
|
45
|
+
return [
|
|
46
|
+
json.loads(f.read_text()) | {"_path": str(f)}
|
|
47
|
+
for f in sorted(rdir.glob("*.json"))
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Buffer render / parse
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
HEADER = """\
|
|
56
|
+
# Access management
|
|
57
|
+
# members: edit environment list to change access, delete line to revoke
|
|
58
|
+
# requests: move a line above '## requests' to approve, delete to reject
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def render_buffer(manifest: Manifest) -> str:
|
|
63
|
+
lines = [HEADER, "## members"]
|
|
64
|
+
|
|
65
|
+
for name, info in sorted(manifest.get_identities().items()):
|
|
66
|
+
envs = _envs_for_identity(name, manifest)
|
|
67
|
+
lines.append(f"{name} {' '.join(sorted(envs))}")
|
|
68
|
+
|
|
69
|
+
lines += ["", "## requests"]
|
|
70
|
+
|
|
71
|
+
for req in load_requests():
|
|
72
|
+
envs = " ".join(req.get("environments", []))
|
|
73
|
+
lines.append(f"{req['identity']} {envs}")
|
|
74
|
+
|
|
75
|
+
return "\n".join(lines) + "\n"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _envs_for_identity(name: str, manifest: Manifest) -> list[str]:
|
|
79
|
+
pk = manifest.get_identity_key(name)
|
|
80
|
+
if not pk:
|
|
81
|
+
return []
|
|
82
|
+
return [
|
|
83
|
+
env for env, info in manifest.get_envs().items()
|
|
84
|
+
if pk in info.get("recipients", [])
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Parse buffer
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def parse_buffer(text: str) -> tuple[dict[str, list[str]], list[str]]:
|
|
93
|
+
"""Parse access buffer.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
members: {username: [env, ...]}
|
|
97
|
+
rejected: [username, ...] — requests that were deleted
|
|
98
|
+
"""
|
|
99
|
+
members: dict[str, list[str]] = {}
|
|
100
|
+
in_members = False
|
|
101
|
+
in_requests = False
|
|
102
|
+
seen_requests: set[str] = set()
|
|
103
|
+
|
|
104
|
+
for line in text.splitlines():
|
|
105
|
+
stripped = line.strip()
|
|
106
|
+
if not stripped:
|
|
107
|
+
continue
|
|
108
|
+
# Skip comment lines but not section headers
|
|
109
|
+
if stripped.startswith("#") and stripped not in ("## members", "## requests"):
|
|
110
|
+
continue
|
|
111
|
+
if stripped == "## members":
|
|
112
|
+
in_members = True
|
|
113
|
+
in_requests = False
|
|
114
|
+
continue
|
|
115
|
+
if stripped == "## requests":
|
|
116
|
+
in_members = False
|
|
117
|
+
in_requests = True
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
parts = stripped.split()
|
|
121
|
+
if not parts:
|
|
122
|
+
continue
|
|
123
|
+
name = parts[0]
|
|
124
|
+
envs = parts[1:]
|
|
125
|
+
|
|
126
|
+
if in_members:
|
|
127
|
+
members[name] = envs
|
|
128
|
+
elif in_requests:
|
|
129
|
+
seen_requests.add(name)
|
|
130
|
+
|
|
131
|
+
# Requests not present in buffer were deleted → rejected
|
|
132
|
+
all_requests = {r["identity"] for r in load_requests()}
|
|
133
|
+
rejected = [r for r in all_requests if r not in seen_requests and r not in members]
|
|
134
|
+
|
|
135
|
+
return members, rejected
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
# Apply buffer changes
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
def apply_access(
|
|
143
|
+
members: dict[str, list[str]],
|
|
144
|
+
rejected: list[str],
|
|
145
|
+
manifest: Manifest,
|
|
146
|
+
identity: Identity,
|
|
147
|
+
) -> list[str]:
|
|
148
|
+
"""Reconcile parsed buffer against current manifest state.
|
|
149
|
+
|
|
150
|
+
- New member (was a request, now in members) → approve
|
|
151
|
+
- Removed member → revoke
|
|
152
|
+
- Changed envs → re-encrypt accordingly
|
|
153
|
+
- Rejected requests → delete request file
|
|
154
|
+
Returns list of changes made.
|
|
155
|
+
"""
|
|
156
|
+
changes = []
|
|
157
|
+
requests_by_name = {r["identity"]: r for r in load_requests()}
|
|
158
|
+
vdir = vault_dir()
|
|
159
|
+
all_envs = list(manifest.get_envs())
|
|
160
|
+
|
|
161
|
+
# --- Approvals: name in members but not yet in manifest identities ---
|
|
162
|
+
for name, envs in members.items():
|
|
163
|
+
if not manifest.has_identity(name):
|
|
164
|
+
req = requests_by_name.get(name)
|
|
165
|
+
if not req:
|
|
166
|
+
changes.append(f"WARNING: no request found for '{name}', skipping")
|
|
167
|
+
continue
|
|
168
|
+
_approve(name, req, envs, manifest, identity, vdir)
|
|
169
|
+
changes.append(f"approved {name} → {' '.join(envs) or '(no envs)'}")
|
|
170
|
+
|
|
171
|
+
# --- Revocations: name in manifest but not in new members ---
|
|
172
|
+
for name in list(manifest.get_identities()):
|
|
173
|
+
if name not in members:
|
|
174
|
+
_revoke(name, manifest, identity, vdir)
|
|
175
|
+
changes.append(f"revoked {name}")
|
|
176
|
+
|
|
177
|
+
# --- Env changes: name in both, but envs differ ---
|
|
178
|
+
for name, new_envs in members.items():
|
|
179
|
+
if not manifest.has_identity(name):
|
|
180
|
+
continue # just approved above
|
|
181
|
+
old_envs = set(_envs_for_identity(name, manifest))
|
|
182
|
+
new_set = set(new_envs)
|
|
183
|
+
if old_envs == new_set:
|
|
184
|
+
continue
|
|
185
|
+
pk = manifest.get_identity_key(name)
|
|
186
|
+
user_info = manifest.get_identities()[name]
|
|
187
|
+
recovery_key = user_info.get("recovery_key")
|
|
188
|
+
|
|
189
|
+
# Grant access to added envs
|
|
190
|
+
for env in new_set - old_envs:
|
|
191
|
+
recipients = manifest.get_recipients(env)
|
|
192
|
+
if pk not in recipients:
|
|
193
|
+
recipients = recipients + [pk]
|
|
194
|
+
if recovery_key and recovery_key not in recipients:
|
|
195
|
+
recipients.append(recovery_key)
|
|
196
|
+
enc = vdir / env / ".env.enc"
|
|
197
|
+
if enc.exists():
|
|
198
|
+
reencrypt_file(enc, recipients, identity.key_file)
|
|
199
|
+
manifest.set_recipients(env, recipients)
|
|
200
|
+
changes.append(f"granted {name} access to [{env}]")
|
|
201
|
+
|
|
202
|
+
# Revoke access from removed envs
|
|
203
|
+
for env in old_envs - new_set:
|
|
204
|
+
recipients = [
|
|
205
|
+
r for r in manifest.get_recipients(env)
|
|
206
|
+
if r != pk and r != recovery_key
|
|
207
|
+
]
|
|
208
|
+
enc = vdir / env / ".env.enc"
|
|
209
|
+
if enc.exists():
|
|
210
|
+
reencrypt_file(enc, recipients, identity.key_file)
|
|
211
|
+
manifest.set_recipients(env, recipients)
|
|
212
|
+
changes.append(f"revoked {name} from [{env}]")
|
|
213
|
+
|
|
214
|
+
# --- Rejections ---
|
|
215
|
+
for name in rejected:
|
|
216
|
+
req = requests_by_name.get(name)
|
|
217
|
+
if req and req.get("_path"):
|
|
218
|
+
Path(req["_path"]).unlink(missing_ok=True)
|
|
219
|
+
changes.append(f"rejected request from {name}")
|
|
220
|
+
|
|
221
|
+
manifest.save()
|
|
222
|
+
return changes
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _approve(
|
|
226
|
+
name: str,
|
|
227
|
+
req: dict,
|
|
228
|
+
envs: list[str],
|
|
229
|
+
manifest: Manifest,
|
|
230
|
+
identity: Identity,
|
|
231
|
+
vdir: Path,
|
|
232
|
+
) -> None:
|
|
233
|
+
pk = req["public_key"]
|
|
234
|
+
recovery_key = req.get("recovery_public_key")
|
|
235
|
+
|
|
236
|
+
for env in envs:
|
|
237
|
+
recipients = manifest.get_recipients(env)
|
|
238
|
+
if pk not in recipients:
|
|
239
|
+
recipients = recipients + [pk]
|
|
240
|
+
if recovery_key and recovery_key not in recipients:
|
|
241
|
+
recipients.append(recovery_key)
|
|
242
|
+
enc = vdir / env / ".env.enc"
|
|
243
|
+
if enc.exists():
|
|
244
|
+
reencrypt_file(enc, recipients, identity.key_file)
|
|
245
|
+
manifest.set_recipients(env, recipients)
|
|
246
|
+
|
|
247
|
+
manifest.add_identity(name, pk, recovery_key=recovery_key)
|
|
248
|
+
|
|
249
|
+
# Remove request file
|
|
250
|
+
if req.get("_path"):
|
|
251
|
+
Path(req["_path"]).unlink(missing_ok=True)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _revoke(name: str, manifest: Manifest, identity: Identity, vdir: Path) -> None:
|
|
255
|
+
user_info = manifest.get_identities().get(name, {})
|
|
256
|
+
pk = user_info.get("public_key")
|
|
257
|
+
recovery_key = user_info.get("recovery_key")
|
|
258
|
+
|
|
259
|
+
for env, info in manifest.get_envs().items():
|
|
260
|
+
recipients = info.get("recipients", [])
|
|
261
|
+
updated = [r for r in recipients if r != pk and r != recovery_key]
|
|
262
|
+
if updated != recipients:
|
|
263
|
+
enc = vdir / env / ".env.enc"
|
|
264
|
+
if enc.exists():
|
|
265
|
+
reencrypt_file(enc, updated, identity.key_file)
|
|
266
|
+
manifest.set_recipients(env, updated)
|
|
267
|
+
|
|
268
|
+
manifest.remove_identity(name)
|
yoink/cli.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Yoink CLI."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shlex
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .store import (
|
|
14
|
+
Manifest, get_git_username, vault_dir, requests_dir,
|
|
15
|
+
DEFAULT_ENVS, RECOVERY_KEY_COUNT, YOINK_DIR,
|
|
16
|
+
)
|
|
17
|
+
from .identity import create_identity, create_recovery_identity, load_current_identity
|
|
18
|
+
from .crypto import encrypt_file
|
|
19
|
+
from .secrets import render_buffer as render_secrets, parse_buffer as parse_secrets_buf, apply_secrets
|
|
20
|
+
from .access import render_buffer as render_access, parse_buffer as parse_access_buf, apply_access, create_request, load_requests
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Auto-init
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
def _ensure_vault() -> tuple[Manifest, object]:
|
|
28
|
+
"""Load or create the vault. Returns (manifest, identity).
|
|
29
|
+
|
|
30
|
+
Prints recovery keys on first init and exits if setup is incomplete.
|
|
31
|
+
"""
|
|
32
|
+
manifest = Manifest()
|
|
33
|
+
|
|
34
|
+
if not manifest.exists():
|
|
35
|
+
_bootstrap(manifest)
|
|
36
|
+
|
|
37
|
+
manifest.load()
|
|
38
|
+
|
|
39
|
+
identity = load_current_identity()
|
|
40
|
+
if not identity:
|
|
41
|
+
username = get_git_username()
|
|
42
|
+
if not username:
|
|
43
|
+
click.echo("Could not detect git username. Set up git credentials first.", err=True)
|
|
44
|
+
sys.exit(1)
|
|
45
|
+
click.echo(f"Creating identity for {username}...")
|
|
46
|
+
identity = create_identity(username)
|
|
47
|
+
manifest.add_identity(username, identity.public_key)
|
|
48
|
+
manifest.save()
|
|
49
|
+
|
|
50
|
+
return manifest, identity
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _bootstrap(manifest: Manifest) -> None:
|
|
54
|
+
"""First-time vault setup. Prints recovery keys."""
|
|
55
|
+
username = get_git_username()
|
|
56
|
+
if not username:
|
|
57
|
+
click.echo("Could not detect git username. Set up git credentials first.", err=True)
|
|
58
|
+
sys.exit(1)
|
|
59
|
+
|
|
60
|
+
click.echo(f"Initialising vault for {username}...")
|
|
61
|
+
|
|
62
|
+
identity = load_current_identity()
|
|
63
|
+
if not identity:
|
|
64
|
+
identity = create_identity(username)
|
|
65
|
+
|
|
66
|
+
# Vault-wide recovery keys
|
|
67
|
+
recovery_keys = []
|
|
68
|
+
recovery_secrets = []
|
|
69
|
+
for i in range(1, RECOVERY_KEY_COUNT + 1):
|
|
70
|
+
rec, secret = create_recovery_identity(f"recovery-{i}")
|
|
71
|
+
recovery_keys.append(rec.public_key)
|
|
72
|
+
recovery_secrets.append((i, secret))
|
|
73
|
+
|
|
74
|
+
# Create environments
|
|
75
|
+
vdir = vault_dir()
|
|
76
|
+
vdir.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
all_recipients = [identity.public_key] + recovery_keys
|
|
78
|
+
|
|
79
|
+
for env in DEFAULT_ENVS:
|
|
80
|
+
enc = vdir / env / ".env.enc"
|
|
81
|
+
enc.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
encrypt_file(enc, "", all_recipients)
|
|
83
|
+
manifest.add_env(env, all_recipients)
|
|
84
|
+
|
|
85
|
+
manifest.add_identity(username, identity.public_key)
|
|
86
|
+
manifest.set_recovery_keys(recovery_keys)
|
|
87
|
+
manifest.save()
|
|
88
|
+
|
|
89
|
+
# .gitignore
|
|
90
|
+
gitignore = Path.cwd() / ".gitignore"
|
|
91
|
+
entry = "*.key"
|
|
92
|
+
if gitignore.exists():
|
|
93
|
+
if entry not in gitignore.read_text():
|
|
94
|
+
gitignore.write_text(gitignore.read_text().rstrip() + f"\n{entry}\n")
|
|
95
|
+
else:
|
|
96
|
+
gitignore.write_text(f"# yoink — never commit key files\n{entry}\n")
|
|
97
|
+
|
|
98
|
+
click.echo()
|
|
99
|
+
click.echo("⚠️ BACK UP YOUR RECOVERY KEYS — shown once, store in team password manager:")
|
|
100
|
+
click.echo()
|
|
101
|
+
for i, secret in recovery_secrets:
|
|
102
|
+
click.echo(f" recovery-{i}: {secret}")
|
|
103
|
+
click.echo()
|
|
104
|
+
click.echo(f"Vault ready at {YOINK_DIR}/")
|
|
105
|
+
click.echo()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# Editor helper
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def _open_editor(content: str, suffix: str = ".env") -> str | None:
|
|
113
|
+
"""Write content to a temp file, open editor, return edited content.
|
|
114
|
+
|
|
115
|
+
Returns None if the file was unchanged.
|
|
116
|
+
"""
|
|
117
|
+
editor = os.environ.get("EDITOR")
|
|
118
|
+
if not editor:
|
|
119
|
+
try:
|
|
120
|
+
subprocess.run(["code", "--version"], capture_output=True, timeout=5)
|
|
121
|
+
editor = "code --wait"
|
|
122
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
123
|
+
editor = "vi"
|
|
124
|
+
|
|
125
|
+
with tempfile.NamedTemporaryFile(
|
|
126
|
+
mode="w", suffix=suffix, prefix="yoink-", delete=False,
|
|
127
|
+
) as tmp:
|
|
128
|
+
tmp.write(content)
|
|
129
|
+
tmp_path = tmp.name
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
subprocess.run(shlex.split(editor) + [tmp_path], check=True)
|
|
133
|
+
edited = Path(tmp_path).read_text()
|
|
134
|
+
return edited if edited != content else None
|
|
135
|
+
finally:
|
|
136
|
+
Path(tmp_path).unlink(missing_ok=True)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _open_editor_with_retry(content: str, parse_fn, suffix: str = ".env"):
|
|
140
|
+
"""Open editor, re-opening with error annotations on parse failure."""
|
|
141
|
+
current = content
|
|
142
|
+
while True:
|
|
143
|
+
edited = _open_editor(current, suffix=suffix)
|
|
144
|
+
if edited is None:
|
|
145
|
+
return None # No changes
|
|
146
|
+
try:
|
|
147
|
+
result = parse_fn(edited)
|
|
148
|
+
return edited, result
|
|
149
|
+
except ValueError as e:
|
|
150
|
+
# Annotate the buffer and re-open
|
|
151
|
+
current = f"# ERROR: {e}\n# Fix the issue above and save again, or quit without saving.\n\n" + edited
|
|
152
|
+
click.echo(f"Parse error: {e} — re-opening editor...")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
# Commands
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
@click.group(invoke_without_command=True)
|
|
160
|
+
@click.version_option(__version__, prog_name="yoink")
|
|
161
|
+
@click.pass_context
|
|
162
|
+
def cli(ctx):
|
|
163
|
+
"""Yoink — lean, repo-native secrets for developer teams."""
|
|
164
|
+
if ctx.invoked_subcommand is None:
|
|
165
|
+
click.echo(ctx.get_help())
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@cli.command()
|
|
169
|
+
def secrets():
|
|
170
|
+
"""Edit all secrets in $EDITOR."""
|
|
171
|
+
manifest, identity = _ensure_vault()
|
|
172
|
+
content = render_secrets(manifest, identity)
|
|
173
|
+
|
|
174
|
+
result = _open_editor_with_retry(content, parse_secrets_buf)
|
|
175
|
+
if result is None:
|
|
176
|
+
click.echo("No changes.")
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
_, parsed = result
|
|
180
|
+
changes = apply_secrets(parsed, manifest, identity)
|
|
181
|
+
|
|
182
|
+
if changes:
|
|
183
|
+
for c in changes:
|
|
184
|
+
click.echo(f" {c}")
|
|
185
|
+
else:
|
|
186
|
+
click.echo("No changes.")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@cli.group()
|
|
190
|
+
def access():
|
|
191
|
+
"""Manage vault access."""
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@access.command("edit")
|
|
196
|
+
def access_edit():
|
|
197
|
+
"""Review members and requests in $EDITOR."""
|
|
198
|
+
manifest, identity = _ensure_vault()
|
|
199
|
+
content = render_access(manifest)
|
|
200
|
+
|
|
201
|
+
result = _open_editor_with_retry(content, parse_access_buf, suffix=".txt")
|
|
202
|
+
if result is None:
|
|
203
|
+
click.echo("No changes.")
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
_, (members, rejected) = result
|
|
207
|
+
changes = apply_access(members, rejected, manifest, identity)
|
|
208
|
+
|
|
209
|
+
if changes:
|
|
210
|
+
for c in changes:
|
|
211
|
+
click.echo(f" {c}")
|
|
212
|
+
else:
|
|
213
|
+
click.echo("No changes.")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@access.command("request")
|
|
217
|
+
def access_request():
|
|
218
|
+
"""Request access to the vault (run this as a new developer)."""
|
|
219
|
+
username = get_git_username()
|
|
220
|
+
if not username:
|
|
221
|
+
click.echo("Could not detect git username.", err=True)
|
|
222
|
+
sys.exit(1)
|
|
223
|
+
|
|
224
|
+
manifest = Manifest()
|
|
225
|
+
if manifest.exists():
|
|
226
|
+
manifest.load()
|
|
227
|
+
if manifest.has_identity(username):
|
|
228
|
+
click.echo(f"You already have access as '{username}'.")
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
envs = list(manifest.get_envs().keys())
|
|
232
|
+
else:
|
|
233
|
+
click.echo("No vault found in this directory.", err=True)
|
|
234
|
+
sys.exit(1)
|
|
235
|
+
|
|
236
|
+
if not envs:
|
|
237
|
+
click.echo("No environments found in vault.", err=True)
|
|
238
|
+
sys.exit(1)
|
|
239
|
+
|
|
240
|
+
# Let them select environments via editor or just default to all
|
|
241
|
+
click.echo(f"Requesting access to: {', '.join(envs)}")
|
|
242
|
+
if not click.confirm("Request access to all environments?", default=True):
|
|
243
|
+
click.echo("Edit the list (space-separated):")
|
|
244
|
+
raw = click.prompt("Environments", default=" ".join(envs))
|
|
245
|
+
envs = [e.strip() for e in raw.split() if e.strip()]
|
|
246
|
+
|
|
247
|
+
path, recovery_secret = create_request(username, envs)
|
|
248
|
+
|
|
249
|
+
click.echo()
|
|
250
|
+
click.echo(f"Request written to {path}")
|
|
251
|
+
click.echo()
|
|
252
|
+
click.echo("⚠️ Back up your recovery key (shown once):")
|
|
253
|
+
click.echo(f" {recovery_secret}")
|
|
254
|
+
click.echo()
|
|
255
|
+
click.echo("Next steps:")
|
|
256
|
+
click.echo(f" git add {YOINK_DIR}/requests/{username}.json")
|
|
257
|
+
click.echo(f" git commit -m 'access request: {username}'")
|
|
258
|
+
click.echo(f" # open a PR — a maintainer will run: yoink access edit")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@cli.command()
|
|
262
|
+
@click.argument("env")
|
|
263
|
+
@click.argument("command", nargs=-1, required=True)
|
|
264
|
+
def run(env, command):
|
|
265
|
+
"""Run a command with secrets injected as environment variables.
|
|
266
|
+
|
|
267
|
+
Example: yoink run dev -- python app.py
|
|
268
|
+
"""
|
|
269
|
+
manifest, identity = _ensure_vault()
|
|
270
|
+
|
|
271
|
+
from .secrets import parse_env
|
|
272
|
+
from .crypto import decrypt_file
|
|
273
|
+
vdir = vault_dir()
|
|
274
|
+
|
|
275
|
+
enc = vdir / env / ".env.enc"
|
|
276
|
+
if not enc.exists():
|
|
277
|
+
click.echo(f"Environment '{env}' not found.", err=True)
|
|
278
|
+
sys.exit(1)
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
content = decrypt_file(enc, identity.key_file)
|
|
282
|
+
except Exception as e:
|
|
283
|
+
click.echo(f"Could not decrypt {env}: {e}", err=True)
|
|
284
|
+
sys.exit(1)
|
|
285
|
+
|
|
286
|
+
env_vars = parse_env(content)
|
|
287
|
+
full_env = os.environ.copy()
|
|
288
|
+
full_env.update({k: str(v) for k, v in env_vars.items() if v is not None})
|
|
289
|
+
|
|
290
|
+
result = subprocess.run(command, env=full_env)
|
|
291
|
+
sys.exit(result.returncode)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# ---------------------------------------------------------------------------
|
|
295
|
+
# Entry point
|
|
296
|
+
# ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
def main():
|
|
299
|
+
cli()
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
if __name__ == "__main__":
|
|
303
|
+
main()
|
yoink/crypto.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""
|
|
2
|
+
crypto.py — thin wrappers around the age binary.
|
|
3
|
+
|
|
4
|
+
Yoink delegates all cryptography to age (https://github.com/FiloSottile/age).
|
|
5
|
+
It doesn't implement any crypto itself. Install age with: brew install age
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def encrypt(plaintext: str, recipients: list[str]) -> bytes:
|
|
13
|
+
"""Encrypt plaintext for one or more age public key recipients."""
|
|
14
|
+
if not recipients:
|
|
15
|
+
raise ValueError("Cannot encrypt with no recipients")
|
|
16
|
+
args = ["age"]
|
|
17
|
+
for r in recipients:
|
|
18
|
+
args += ["-r", r]
|
|
19
|
+
result = subprocess.run(args, input=plaintext.encode(), capture_output=True, timeout=30)
|
|
20
|
+
if result.returncode != 0:
|
|
21
|
+
raise RuntimeError(f"age encrypt failed: {result.stderr.decode(errors='replace')}")
|
|
22
|
+
return result.stdout
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def decrypt(ciphertext: bytes, key_file: Path) -> str:
|
|
26
|
+
"""Decrypt age-encrypted bytes using a secret key file."""
|
|
27
|
+
result = subprocess.run(
|
|
28
|
+
["age", "-d", "-i", str(key_file)],
|
|
29
|
+
input=ciphertext, capture_output=True, timeout=30,
|
|
30
|
+
)
|
|
31
|
+
if result.returncode != 0:
|
|
32
|
+
raise RuntimeError(f"age decrypt failed: {result.stderr.decode(errors='replace')}")
|
|
33
|
+
return result.stdout.decode()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def encrypt_file(path: Path, plaintext: str, recipients: list[str]) -> None:
|
|
37
|
+
"""Encrypt plaintext and write to path atomically.
|
|
38
|
+
|
|
39
|
+
Writes to a .tmp sibling first, then renames over the target. This means
|
|
40
|
+
a crash or disk-full mid-write leaves the original file intact rather than
|
|
41
|
+
producing a corrupt or zero-byte ciphertext with the plaintext gone.
|
|
42
|
+
"""
|
|
43
|
+
ciphertext = encrypt(plaintext, recipients)
|
|
44
|
+
tmp = path.with_suffix(".tmp")
|
|
45
|
+
try:
|
|
46
|
+
tmp.write_bytes(ciphertext)
|
|
47
|
+
tmp.replace(path) # atomic on POSIX — either old or new, never corrupt
|
|
48
|
+
except Exception:
|
|
49
|
+
tmp.unlink(missing_ok=True)
|
|
50
|
+
raise
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def decrypt_file(path: Path, key_file: Path) -> str:
|
|
54
|
+
"""Read and decrypt a vault file."""
|
|
55
|
+
return decrypt(path.read_bytes(), key_file)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def reencrypt_file(path: Path, new_recipients: list[str], key_file: Path) -> None:
|
|
59
|
+
"""Decrypt and re-encrypt a vault file with a new recipient list.
|
|
60
|
+
|
|
61
|
+
Used when access changes — adding or revoking a member.
|
|
62
|
+
"""
|
|
63
|
+
plaintext = decrypt_file(path, key_file)
|
|
64
|
+
encrypt_file(path, plaintext, new_recipients)
|
yoink/identity.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Identity — age keypairs stored in ~/.yoink/."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .store import global_dir
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Identity:
|
|
10
|
+
def __init__(self, name: str, public_key: str, key_dir: Path | None = None):
|
|
11
|
+
self.name = name
|
|
12
|
+
self.public_key = public_key
|
|
13
|
+
self._key_dir = key_dir or global_dir()
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def key_file(self) -> Path:
|
|
17
|
+
return self._key_dir / f"{self.name}.key"
|
|
18
|
+
|
|
19
|
+
def write(self, secret_key: str) -> None:
|
|
20
|
+
self._key_dir.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
self.key_file.write_text(f"{secret_key}\n")
|
|
22
|
+
self.key_file.chmod(0o600)
|
|
23
|
+
(self._key_dir / f"{self.name}.pub").write_text(f"{self.public_key}\n")
|
|
24
|
+
|
|
25
|
+
def __repr__(self) -> str:
|
|
26
|
+
return f"Identity({self.name}, {self.public_key[:20]}...)"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _keygen(name: str, key_dir: Path | None = None) -> Identity:
|
|
30
|
+
r = subprocess.run(["age-keygen"], capture_output=True, text=True, timeout=10)
|
|
31
|
+
if r.returncode != 0:
|
|
32
|
+
raise RuntimeError("age-keygen failed. Install age: brew install age")
|
|
33
|
+
|
|
34
|
+
public_key = secret_key = None
|
|
35
|
+
for line in r.stdout.strip().splitlines():
|
|
36
|
+
if "public key:" in line.lower():
|
|
37
|
+
public_key = line.split(":", 1)[1].strip()
|
|
38
|
+
elif line.startswith("AGE-SECRET-KEY-"):
|
|
39
|
+
secret_key = line.strip()
|
|
40
|
+
|
|
41
|
+
if not public_key or not secret_key:
|
|
42
|
+
raise RuntimeError("Could not parse age-keygen output")
|
|
43
|
+
|
|
44
|
+
identity = Identity(name, public_key, key_dir=key_dir)
|
|
45
|
+
identity.write(secret_key)
|
|
46
|
+
return identity
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def create_identity(name: str, key_dir: Path | None = None) -> Identity:
|
|
50
|
+
return _keygen(name, key_dir)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_identity(name: str, key_dir: Path | None = None) -> Identity | None:
|
|
54
|
+
kdir = key_dir or global_dir()
|
|
55
|
+
key_file = kdir / f"{name}.key"
|
|
56
|
+
pub_file = kdir / f"{name}.pub"
|
|
57
|
+
|
|
58
|
+
if not key_file.exists():
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
if pub_file.exists():
|
|
62
|
+
public_key = pub_file.read_text().strip()
|
|
63
|
+
else:
|
|
64
|
+
# Fallback: scan key file for public key line
|
|
65
|
+
public_key = next(
|
|
66
|
+
(l.strip() for l in key_file.read_text().splitlines() if l.startswith("age1")),
|
|
67
|
+
None,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if not public_key:
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
return Identity(name, public_key, key_dir=kdir)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load_current_identity() -> Identity | None:
|
|
77
|
+
from .store import get_git_username
|
|
78
|
+
username = get_git_username()
|
|
79
|
+
return load_identity(username) if username else None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def create_recovery_identity(name: str, key_dir: Path | None = None) -> tuple[Identity, str]:
|
|
83
|
+
"""Create a recovery identity. Returns (identity, secret_key)."""
|
|
84
|
+
kdir = key_dir or global_dir()
|
|
85
|
+
identity = _keygen(name, kdir)
|
|
86
|
+
secret_key = next(
|
|
87
|
+
l.strip() for l in identity.key_file.read_text().splitlines()
|
|
88
|
+
if l.startswith("AGE-SECRET-KEY-")
|
|
89
|
+
)
|
|
90
|
+
return identity, secret_key
|
yoink/secrets.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Secrets — render/edit/apply dotenv-style secrets per environment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .crypto import decrypt_file, encrypt_file
|
|
6
|
+
from .identity import Identity
|
|
7
|
+
from .store import Manifest, vault_dir
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
HEADER = """\
|
|
11
|
+
# Secrets management
|
|
12
|
+
# Edit values below. Lines are KEY=value.
|
|
13
|
+
# Section headers are environments.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_env(content: str) -> dict[str, str | None]:
|
|
18
|
+
"""Parse KEY=value lines into a dict.
|
|
19
|
+
|
|
20
|
+
Blank lines and comment lines are ignored.
|
|
21
|
+
"""
|
|
22
|
+
data: dict[str, str | None] = {}
|
|
23
|
+
|
|
24
|
+
for raw in content.splitlines():
|
|
25
|
+
line = raw.strip()
|
|
26
|
+
if not line or line.startswith("#"):
|
|
27
|
+
continue
|
|
28
|
+
if "=" not in line:
|
|
29
|
+
raise ValueError(f"Invalid line (expected KEY=value): {raw}")
|
|
30
|
+
key, value = line.split("=", 1)
|
|
31
|
+
key = key.strip()
|
|
32
|
+
value = value.strip()
|
|
33
|
+
if not key:
|
|
34
|
+
raise ValueError(f"Invalid key in line: {raw}")
|
|
35
|
+
data[key] = value
|
|
36
|
+
|
|
37
|
+
return data
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def dump_env(data: dict[str, str | None]) -> str:
|
|
41
|
+
lines = []
|
|
42
|
+
for key in sorted(data):
|
|
43
|
+
value = data[key]
|
|
44
|
+
lines.append(f"{key}={'' if value is None else value}")
|
|
45
|
+
return "\n".join(lines) + ("\n" if lines else "")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def render_buffer(manifest: Manifest, identity: Identity) -> str:
|
|
49
|
+
"""Render all env secrets into a single editor buffer."""
|
|
50
|
+
vdir = vault_dir()
|
|
51
|
+
chunks = [HEADER.rstrip(), ""]
|
|
52
|
+
|
|
53
|
+
for env in sorted(manifest.get_envs()):
|
|
54
|
+
enc = vdir / env / ".env.enc"
|
|
55
|
+
plaintext = ""
|
|
56
|
+
if enc.exists():
|
|
57
|
+
try:
|
|
58
|
+
plaintext = decrypt_file(enc, identity.key_file).rstrip()
|
|
59
|
+
except Exception as e:
|
|
60
|
+
raise RuntimeError(f"Could not decrypt environment '{env}': {e}") from e
|
|
61
|
+
|
|
62
|
+
chunks.append(f"## {env}")
|
|
63
|
+
if plaintext:
|
|
64
|
+
chunks.append(plaintext)
|
|
65
|
+
chunks.append("")
|
|
66
|
+
|
|
67
|
+
return "\n".join(chunks).rstrip() + "\n"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def parse_buffer(text: str) -> dict[str, dict[str, str | None]]:
|
|
71
|
+
"""Parse editor buffer into {env: {KEY: value}}."""
|
|
72
|
+
result: dict[str, dict[str, str | None]] = {}
|
|
73
|
+
current_env: str | None = None
|
|
74
|
+
current_lines: list[str] = []
|
|
75
|
+
|
|
76
|
+
def flush() -> None:
|
|
77
|
+
nonlocal current_env, current_lines
|
|
78
|
+
if current_env is None:
|
|
79
|
+
return
|
|
80
|
+
result[current_env] = parse_env("\n".join(current_lines))
|
|
81
|
+
current_lines = []
|
|
82
|
+
|
|
83
|
+
for raw in text.splitlines():
|
|
84
|
+
stripped = raw.strip()
|
|
85
|
+
|
|
86
|
+
if stripped.startswith("#") and not stripped.startswith("## "):
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
if stripped.startswith("## "):
|
|
90
|
+
flush()
|
|
91
|
+
current_env = stripped[3:].strip()
|
|
92
|
+
if not current_env:
|
|
93
|
+
raise ValueError("Empty environment header")
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
if current_env is None:
|
|
97
|
+
if not stripped:
|
|
98
|
+
continue
|
|
99
|
+
raise ValueError(f"Content outside an environment section: {raw}")
|
|
100
|
+
|
|
101
|
+
current_lines.append(raw)
|
|
102
|
+
|
|
103
|
+
flush()
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def apply_secrets(
|
|
108
|
+
parsed: dict[str, dict[str, str | None]],
|
|
109
|
+
manifest: Manifest,
|
|
110
|
+
identity: Identity,
|
|
111
|
+
) -> list[str]:
|
|
112
|
+
"""Encrypt and write updated secrets for each environment."""
|
|
113
|
+
vdir = vault_dir()
|
|
114
|
+
changes: list[str] = []
|
|
115
|
+
|
|
116
|
+
for env in sorted(manifest.get_envs()):
|
|
117
|
+
if env not in parsed:
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
enc = vdir / env / ".env.enc"
|
|
121
|
+
recipients = manifest.get_recipients(env)
|
|
122
|
+
new_plaintext = dump_env(parsed[env])
|
|
123
|
+
|
|
124
|
+
old_plaintext = ""
|
|
125
|
+
if enc.exists():
|
|
126
|
+
try:
|
|
127
|
+
old_plaintext = decrypt_file(enc, identity.key_file)
|
|
128
|
+
except Exception as e:
|
|
129
|
+
raise RuntimeError(f"Could not decrypt environment '{env}': {e}") from e
|
|
130
|
+
|
|
131
|
+
if old_plaintext == new_plaintext:
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
enc.parent.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
encrypt_file(enc, new_plaintext, recipients)
|
|
136
|
+
changes.append(f"updated {env}")
|
|
137
|
+
|
|
138
|
+
manifest.save()
|
|
139
|
+
return changes
|
yoink/store.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Store — paths, constants, and manifest read/write."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
# ---------------------------------------------------------------------------
|
|
8
|
+
# Paths & constants
|
|
9
|
+
# ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
YOINK_DIR = ".yoink"
|
|
12
|
+
MANIFEST_FILE = "manifest.json"
|
|
13
|
+
REQUESTS_DIR = "requests"
|
|
14
|
+
DEFAULT_ENVS = ["dev", "staging", "production"]
|
|
15
|
+
RECOVERY_KEY_COUNT = 2
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def vault_dir() -> Path:
|
|
19
|
+
return Path.cwd() / YOINK_DIR
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def manifest_path() -> Path:
|
|
23
|
+
return vault_dir() / MANIFEST_FILE
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def requests_dir() -> Path:
|
|
27
|
+
return vault_dir() / REQUESTS_DIR
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def global_dir() -> Path:
|
|
31
|
+
return Path.home() / ".yoink"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def find_enc_files(env: str | None = None) -> list[Path]:
|
|
35
|
+
vdir = vault_dir()
|
|
36
|
+
if not vdir.exists():
|
|
37
|
+
return []
|
|
38
|
+
files = sorted(vdir.rglob("*.enc"))
|
|
39
|
+
if env:
|
|
40
|
+
files = [f for f in files if f.relative_to(vdir).parts[0] == env]
|
|
41
|
+
return files
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_git_username() -> str | None:
|
|
45
|
+
"""Detect username from gh CLI or git config."""
|
|
46
|
+
try:
|
|
47
|
+
r = subprocess.run(
|
|
48
|
+
["gh", "auth", "status"],
|
|
49
|
+
capture_output=True, text=True, timeout=10,
|
|
50
|
+
)
|
|
51
|
+
for line in (r.stdout + r.stderr).splitlines():
|
|
52
|
+
if "Logged in to" in line and " as " in line:
|
|
53
|
+
return line.split(" as ")[-1].split()[0].strip()
|
|
54
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
r = subprocess.run(
|
|
59
|
+
["git", "config", "user.name"],
|
|
60
|
+
capture_output=True, text=True, timeout=10,
|
|
61
|
+
)
|
|
62
|
+
if r.returncode == 0 and r.stdout.strip():
|
|
63
|
+
return r.stdout.strip()
|
|
64
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Manifest
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
class Manifest:
|
|
75
|
+
"""Thin wrapper around manifest.json."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, data: dict | None = None):
|
|
78
|
+
self.data = data or {
|
|
79
|
+
"version": 1,
|
|
80
|
+
"environments": {}, # {env: {recipients: [...]}}
|
|
81
|
+
"identities": {}, # {name: {public_key, recovery_key?}}
|
|
82
|
+
"recovery": [], # [public_key, ...]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# --- persistence ---
|
|
86
|
+
|
|
87
|
+
def exists(self) -> bool:
|
|
88
|
+
return manifest_path().exists()
|
|
89
|
+
|
|
90
|
+
def load(self) -> "Manifest":
|
|
91
|
+
if not self.exists():
|
|
92
|
+
raise FileNotFoundError("No vault found. Run any yoink command inside a git repo to initialise.")
|
|
93
|
+
self.data = json.loads(manifest_path().read_text())
|
|
94
|
+
return self
|
|
95
|
+
|
|
96
|
+
def save(self) -> None:
|
|
97
|
+
manifest_path().write_text(json.dumps(self.data, indent=2) + "\n")
|
|
98
|
+
|
|
99
|
+
# --- environments ---
|
|
100
|
+
|
|
101
|
+
def get_envs(self) -> dict:
|
|
102
|
+
return self.data.get("environments", {})
|
|
103
|
+
|
|
104
|
+
def get_recipients(self, env: str) -> list[str]:
|
|
105
|
+
return self.data.get("environments", {}).get(env, {}).get("recipients", [])
|
|
106
|
+
|
|
107
|
+
def set_recipients(self, env: str, recipients: list[str]) -> None:
|
|
108
|
+
self.data.setdefault("environments", {})[env] = {"recipients": recipients}
|
|
109
|
+
|
|
110
|
+
def add_env(self, env: str, recipients: list[str]) -> None:
|
|
111
|
+
self.set_recipients(env, recipients)
|
|
112
|
+
|
|
113
|
+
def remove_env(self, env: str) -> None:
|
|
114
|
+
self.data.get("environments", {}).pop(env, None)
|
|
115
|
+
|
|
116
|
+
# --- identities ---
|
|
117
|
+
|
|
118
|
+
def get_identities(self) -> dict:
|
|
119
|
+
return self.data.get("identities", {})
|
|
120
|
+
|
|
121
|
+
def add_identity(self, name: str, public_key: str, recovery_key: str | None = None) -> None:
|
|
122
|
+
entry = {"public_key": public_key}
|
|
123
|
+
if recovery_key:
|
|
124
|
+
entry["recovery_key"] = recovery_key
|
|
125
|
+
self.data.setdefault("identities", {})[name] = entry
|
|
126
|
+
|
|
127
|
+
def remove_identity(self, name: str) -> None:
|
|
128
|
+
self.data.get("identities", {}).pop(name, None)
|
|
129
|
+
|
|
130
|
+
def has_identity(self, name: str) -> bool:
|
|
131
|
+
return name in self.data.get("identities", {})
|
|
132
|
+
|
|
133
|
+
def get_identity_key(self, name: str) -> str | None:
|
|
134
|
+
return self.data.get("identities", {}).get(name, {}).get("public_key")
|
|
135
|
+
|
|
136
|
+
# --- recovery ---
|
|
137
|
+
|
|
138
|
+
def get_recovery_keys(self) -> list[str]:
|
|
139
|
+
return self.data.get("recovery", [])
|
|
140
|
+
|
|
141
|
+
def set_recovery_keys(self, keys: list[str]) -> None:
|
|
142
|
+
self.data["recovery"] = keys
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: yoink-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Proof-of-concept: encrypted repo-native secrets for developer teams
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/jack-kitto/yoink-py
|
|
7
|
+
Project-URL: Issues, https://github.com/jack-kitto/yoink-py/issues
|
|
8
|
+
Keywords: secrets,encryption,age,devtools,dotenv
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Security :: Cryptography
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: click>=8.1
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# Yoink
|
|
25
|
+
|
|
26
|
+
Lean, repo-native secrets for developer teams.
|
|
27
|
+
|
|
28
|
+
Encrypted secrets live in your repo. Developers decrypt only what they have access to.
|
|
29
|
+
|
|
30
|
+
## Requirements
|
|
31
|
+
|
|
32
|
+
- Python 3.9+
|
|
33
|
+
- [age](https://github.com/FiloSottile/age) (`brew install age`)
|
|
34
|
+
- Git
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install yoink-py
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Commands
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
yoink secrets Edit all secrets in $EDITOR
|
|
46
|
+
yoink access edit Review members and requests in $EDITOR
|
|
47
|
+
yoink access request Request access to the vault (new developers)
|
|
48
|
+
yoink run <env> -- <cmd> Run a command with secrets injected
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Quick start
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
cd your-repo
|
|
55
|
+
yoink secrets # bootstraps vault on first run, then opens editor
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The vault is created in `.yoink/` with `dev`, `staging`, and `production` environments.
|
|
59
|
+
Two vault-wide recovery keys are printed once — back them up in your team password manager.
|
|
60
|
+
|
|
61
|
+
## Secrets editor
|
|
62
|
+
|
|
63
|
+
`yoink secrets` opens a buffer like:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
[dev]
|
|
67
|
+
DATABASE_URL=postgres://localhost/mydb
|
|
68
|
+
API_KEY=sk_test_abc
|
|
69
|
+
|
|
70
|
+
[staging]
|
|
71
|
+
DATABASE_URL=postgres://staging/mydb
|
|
72
|
+
|
|
73
|
+
[production]
|
|
74
|
+
DATABASE_URL=postgres://prod/mydb
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- Edit values inline
|
|
78
|
+
- Add a key to add it
|
|
79
|
+
- Delete a line to remove a secret
|
|
80
|
+
- Add a new `[environment]` header to create a new environment
|
|
81
|
+
- Save and quit — changes are applied
|
|
82
|
+
|
|
83
|
+
## Access editor
|
|
84
|
+
|
|
85
|
+
`yoink access edit` opens a buffer like:
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
## members
|
|
89
|
+
jack dev staging production
|
|
90
|
+
sarah dev staging
|
|
91
|
+
|
|
92
|
+
## requests
|
|
93
|
+
bob dev staging
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
- Edit the environment list on a member line to change their access
|
|
97
|
+
- Delete a member line to revoke their access
|
|
98
|
+
- Move a request line above `## requests` to approve it
|
|
99
|
+
- Delete a request line to reject it
|
|
100
|
+
- Save and quit — changes are applied
|
|
101
|
+
|
|
102
|
+
## New developer workflow
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
yoink access request # generates keypair, writes .yoink/requests/<you>.json
|
|
106
|
+
git add .yoink/requests/<you>.json
|
|
107
|
+
git commit -m "access request: <you>"
|
|
108
|
+
# open a PR
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
A maintainer pulls the PR and runs `yoink access edit`. Moving your line above
|
|
112
|
+
`## requests` and saving approves you. The vault files are re-encrypted to include
|
|
113
|
+
your key.
|
|
114
|
+
|
|
115
|
+
## How it works
|
|
116
|
+
|
|
117
|
+
- Secrets are encrypted with [age](https://github.com/FiloSottile/age) and stored as `.enc` files in `.yoink/`
|
|
118
|
+
- Each developer has an identity keypair in `~/.yoink/`
|
|
119
|
+
- The manifest (`manifest.json`) tracks who has access to what
|
|
120
|
+
- Re-encryption happens automatically when access changes
|
|
121
|
+
|
|
122
|
+
## Limitations
|
|
123
|
+
|
|
124
|
+
- Git history is immutable — revoking access doesn't erase past exposure
|
|
125
|
+
- No runtime audit — who decrypted what and when is not tracked
|
|
126
|
+
- Best for small-to-medium teams
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
yoink/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
yoink/access.py,sha256=CxEnA9INxF2tw5bjkAOJew3P85p3eFaRGPM_YgNiz5o,9064
|
|
3
|
+
yoink/cli.py,sha256=A2o6J1uK1ta7a9NzX5LW8Bbuw1Vvyk0IFi8ZO0dwcEU,9433
|
|
4
|
+
yoink/crypto.py,sha256=rt8kaKhKvFiGdwdggXPYrbZiojzYGVHfLx2sgbBtVF4,2321
|
|
5
|
+
yoink/identity.py,sha256=PuGk9hhuzxFLNGpVw6WdHMRbP_qaNinPMFC9CEIfpz4,2866
|
|
6
|
+
yoink/secrets.py,sha256=dfZzn60KST1NTCOxeE8kcw9eEhMExYuyGXNQahSstrY,3996
|
|
7
|
+
yoink/store.py,sha256=-O8QEED95fYLCZK4HMRzHYZbanKuljWja34b6t1ec7U,4339
|
|
8
|
+
yoink_py-0.1.0.dist-info/licenses/LICENSE,sha256=1eYkh3vrojXw0zuRTKjsE4nVlNsqbG32xtJn0XbsH3o,1077
|
|
9
|
+
yoink_py-0.1.0.dist-info/METADATA,sha256=8QJBYFl5y65Y7h-Ia-TJJ9ZIiX64skgo9GzGqCGtwRs,3455
|
|
10
|
+
yoink_py-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
+
yoink_py-0.1.0.dist-info/entry_points.txt,sha256=RsZI0e29CNMwpi3oP04LaeTyMcH29So95gJZrto8QJ8,41
|
|
12
|
+
yoink_py-0.1.0.dist-info/top_level.txt,sha256=NPdtIV5Rwdo2_2a-V1PJIv8NnmJaEfFZkwPpcykQyWc,6
|
|
13
|
+
yoink_py-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) $YEAR yoink contributors
|
|
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.
|
|
22
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
yoink
|