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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ yoink = yoink.cli:main
@@ -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