mystack-rig 1.0.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.
cli/vault_keys.py ADDED
@@ -0,0 +1,451 @@
1
+ """
2
+ Identity + master-key wrapping for rig data keys.
3
+
4
+ Personal master key is never stored. It unwraps:
5
+ - rig_identity_data_key → /tmp/.rig/.personal-data-key
6
+ - rig_shared_data_key → /tmp/.rig/.shared-data-key
7
+
8
+ Both wraps live in ``.rig/vars.{identity}.yml``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ import getpass
15
+ import hashlib
16
+ import os
17
+ import re
18
+ import secrets
19
+ from typing import Callable, Optional, Tuple
20
+
21
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
22
+ from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
23
+
24
+ # Ephemeral Ansible vault passwords (tmpfs; cleared on reboot).
25
+ TMP_RIG_DIR = "/tmp/.rig"
26
+ PERSONAL_DATA_KEY_PATH = f"{TMP_RIG_DIR}/.personal-data-key"
27
+ SHARED_DATA_KEY_PATH = f"{TMP_RIG_DIR}/.shared-data-key"
28
+ # Legacy alias used by older code paths.
29
+ DEFAULT_DATA_KEY_PATH = f"{TMP_RIG_DIR}/.data-key"
30
+
31
+ IDENTITY_DATA_KEY_VAR = "rig_identity_data_key"
32
+ SHARED_DATA_KEY_VAR = "rig_shared_data_key"
33
+ WRAP_FIELD_NAMES = frozenset({IDENTITY_DATA_KEY_VAR, SHARED_DATA_KEY_VAR})
34
+
35
+ _IDENTITY_RE = re.compile(r"^[a-z][a-z0-9_-]{0,63}$")
36
+ _WRAP_PREFIX = "rig1"
37
+
38
+
39
+ def sanitize_identity(raw: str) -> str:
40
+ """Normalize an identity to a safe filename fragment."""
41
+ value = (raw or "").strip().lower()
42
+ value = re.sub(r"[^a-z0-9_-]+", "-", value)
43
+ value = value.strip("-_")
44
+ if not value:
45
+ raise ValueError("Identity is empty after sanitization")
46
+ if value[0].isdigit():
47
+ value = f"u-{value}"
48
+ if not _IDENTITY_RE.match(value):
49
+ raise ValueError(f"Invalid identity {raw!r}")
50
+ return value
51
+
52
+
53
+ def default_os_identity() -> str:
54
+ try:
55
+ return sanitize_identity(getpass.getuser())
56
+ except Exception:
57
+ return "user"
58
+
59
+
60
+ def resolve_identity(
61
+ explicit: Optional[str] = None,
62
+ config=None,
63
+ ) -> str:
64
+ """Resolve identity: explicit → RIG_IDENTITY → config → OS user."""
65
+ if explicit:
66
+ return sanitize_identity(explicit)
67
+ env = os.getenv("RIG_IDENTITY", "").strip()
68
+ if env:
69
+ return sanitize_identity(env)
70
+ if config is None:
71
+ try:
72
+ from cli.config import RigConfig
73
+ config = RigConfig()
74
+ except Exception:
75
+ config = None
76
+ if config is not None:
77
+ stored = getattr(config, "get_identity", lambda: None)()
78
+ if stored:
79
+ return sanitize_identity(stored)
80
+ return default_os_identity()
81
+
82
+
83
+ def personal_vars_filename(identity: str) -> str:
84
+ return f"vars.{sanitize_identity(identity)}.yml"
85
+
86
+
87
+ def personal_vars_path(project_dir: str, identity: Optional[str] = None) -> str:
88
+ ident = resolve_identity(identity)
89
+ return os.path.join(os.path.abspath(project_dir), ".rig", personal_vars_filename(ident))
90
+
91
+
92
+ def shared_vars_path(project_dir: str) -> str:
93
+ return os.path.join(os.path.abspath(project_dir), ".rig", "vars.yml")
94
+
95
+
96
+ def generate_data_key() -> str:
97
+ return secrets.token_urlsafe(32)
98
+
99
+
100
+ def _derive_key(master: str, salt: bytes) -> bytes:
101
+ kdf = Scrypt(salt=salt, length=32, n=2**14, r=8, p=1)
102
+ return kdf.derive(master.encode("utf-8"))
103
+
104
+
105
+ def wrap_with_master_key(plaintext: str, master_key: str) -> str:
106
+ """Return an opaque ``rig1$…`` blob decryptable only with master_key."""
107
+ if not plaintext:
108
+ raise ValueError("Data key plaintext is required")
109
+ if not master_key:
110
+ raise ValueError("Master key is required")
111
+ salt = os.urandom(16)
112
+ nonce = os.urandom(12)
113
+ key = _derive_key(master_key, salt)
114
+ aes = AESGCM(key)
115
+ ciphertext = aes.encrypt(nonce, plaintext.encode("utf-8"), None)
116
+ parts = [
117
+ _WRAP_PREFIX,
118
+ base64.urlsafe_b64encode(salt).decode("ascii").rstrip("="),
119
+ base64.urlsafe_b64encode(nonce).decode("ascii").rstrip("="),
120
+ base64.urlsafe_b64encode(ciphertext).decode("ascii").rstrip("="),
121
+ ]
122
+ return "$".join(parts)
123
+
124
+
125
+ def unwrap_with_master_key(blob: str, master_key: str) -> str:
126
+ if not blob or not master_key:
127
+ raise ValueError("Wrap blob and master key are required")
128
+ parts = blob.strip().split("$")
129
+ if len(parts) != 4 or parts[0] != _WRAP_PREFIX:
130
+ raise ValueError("Unrecognized key wrap format")
131
+
132
+ def _pad(s: str) -> str:
133
+ return s + "=" * (-len(s) % 4)
134
+
135
+ salt = base64.urlsafe_b64decode(_pad(parts[1]))
136
+ nonce = base64.urlsafe_b64decode(_pad(parts[2]))
137
+ ciphertext = base64.urlsafe_b64decode(_pad(parts[3]))
138
+ key = _derive_key(master_key, salt)
139
+ aes = AESGCM(key)
140
+ try:
141
+ plaintext = aes.decrypt(nonce, ciphertext, None)
142
+ except Exception as exc:
143
+ raise ValueError("Incorrect master key or corrupt wrap") from exc
144
+ return plaintext.decode("utf-8")
145
+
146
+
147
+ def read_data_key_file(path: str) -> Optional[str]:
148
+ if not os.path.exists(path):
149
+ return None
150
+ with open(path, "r", encoding="utf-8") as handle:
151
+ key = handle.read()
152
+ if key.endswith("\n"):
153
+ key = key[:-1]
154
+ return key or None
155
+
156
+
157
+ def write_data_key_file(data_key: str, path: str) -> None:
158
+ directory = os.path.dirname(path)
159
+ if directory:
160
+ os.makedirs(directory, mode=0o700, exist_ok=True)
161
+ with open(path, "w", encoding="utf-8") as handle:
162
+ handle.write(data_key)
163
+ handle.write("\n")
164
+ try:
165
+ os.chmod(path, 0o600)
166
+ if directory:
167
+ os.chmod(directory, 0o700)
168
+ except OSError:
169
+ pass
170
+
171
+
172
+ def materialize_data_keys(identity_key: str, shared_key: str) -> Tuple[str, str]:
173
+ """Write both data keys under /tmp/.rig and return their paths."""
174
+ write_data_key_file(identity_key, PERSONAL_DATA_KEY_PATH)
175
+ write_data_key_file(shared_key, SHARED_DATA_KEY_PATH)
176
+ # Keep legacy path as a copy of the identity key for older call sites.
177
+ write_data_key_file(identity_key, DEFAULT_DATA_KEY_PATH)
178
+ return PERSONAL_DATA_KEY_PATH, SHARED_DATA_KEY_PATH
179
+
180
+
181
+ def tmp_data_keys_present() -> bool:
182
+ return bool(
183
+ read_data_key_file(PERSONAL_DATA_KEY_PATH)
184
+ and read_data_key_file(SHARED_DATA_KEY_PATH)
185
+ )
186
+
187
+
188
+ def read_wrap_fields(identity_vars_path: str) -> dict:
189
+ """Parse wrap fields from an identity vars file without decrypting vaults."""
190
+ if not os.path.exists(identity_vars_path):
191
+ return {}
192
+ with open(identity_vars_path, "r", encoding="utf-8") as handle:
193
+ content = handle.read()
194
+ found = {}
195
+ for name in WRAP_FIELD_NAMES:
196
+ match = re.search(
197
+ rf"^{re.escape(name)}:\s*[\"']?({re.escape(_WRAP_PREFIX)}\$[^\s\"']+)[\"']?\s*$",
198
+ content,
199
+ re.MULTILINE,
200
+ )
201
+ if match:
202
+ found[name] = match.group(1)
203
+ return found
204
+
205
+
206
+ def upsert_plain_yaml_string(path: str, name: str, value: str) -> None:
207
+ """Insert or replace a top-level scalar string in a YAML vars file."""
208
+ directory = os.path.dirname(path)
209
+ if directory:
210
+ os.makedirs(directory, mode=0o700, exist_ok=True)
211
+ existing = ""
212
+ if os.path.exists(path):
213
+ with open(path, "r", encoding="utf-8") as handle:
214
+ existing = handle.read()
215
+ pattern = re.compile(
216
+ rf"^{re.escape(name)}:.*?(?=^\S|\Z)",
217
+ re.MULTILINE | re.DOTALL,
218
+ )
219
+ cleaned = pattern.sub("", existing)
220
+ cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).rstrip()
221
+ line = f'{name}: "{value}"\n'
222
+ new_content = (cleaned + "\n" + line) if cleaned else line
223
+ with open(path, "w", encoding="utf-8") as handle:
224
+ handle.write(new_content if new_content.endswith("\n") else new_content + "\n")
225
+ try:
226
+ os.chmod(path, 0o600)
227
+ except OSError:
228
+ pass
229
+
230
+
231
+ def write_key_wraps(
232
+ identity_vars_path: str,
233
+ *,
234
+ identity_data_key: str,
235
+ shared_data_key: str,
236
+ master_key: str,
237
+ ) -> None:
238
+ upsert_plain_yaml_string(
239
+ identity_vars_path,
240
+ IDENTITY_DATA_KEY_VAR,
241
+ wrap_with_master_key(identity_data_key, master_key),
242
+ )
243
+ upsert_plain_yaml_string(
244
+ identity_vars_path,
245
+ SHARED_DATA_KEY_VAR,
246
+ wrap_with_master_key(shared_data_key, master_key),
247
+ )
248
+
249
+
250
+ def prompt_master_key(
251
+ *,
252
+ confirm: bool = False,
253
+ input_fn: Optional[Callable[..., str]] = None,
254
+ secret_fn: Optional[Callable[..., str]] = None,
255
+ label: str = "Personal master key",
256
+ ) -> str:
257
+ ask_secret = secret_fn or getpass.getpass
258
+ ask = input_fn or input
259
+ try:
260
+ value = ask_secret(f"{label}: ").strip()
261
+ except Exception:
262
+ value = ask(f"{label}: ").strip()
263
+ if not value:
264
+ raise ValueError("Personal master key is required")
265
+ if confirm:
266
+ try:
267
+ again = ask_secret(f"Confirm {label.lower()}: ").strip()
268
+ except Exception:
269
+ again = ask(f"Confirm {label.lower()}: ").strip()
270
+ if again != value:
271
+ raise ValueError("Master keys do not match")
272
+ return value
273
+
274
+
275
+ def unlock_data_keys(
276
+ project_dir: str,
277
+ *,
278
+ identity: Optional[str] = None,
279
+ master_key: Optional[str] = None,
280
+ input_fn: Optional[Callable[..., str]] = None,
281
+ secret_fn: Optional[Callable[..., str]] = None,
282
+ require_wraps: bool = True,
283
+ ) -> Tuple[str, str]:
284
+ """
285
+ Ensure personal + shared data keys exist under /tmp/.rig.
286
+
287
+ Returns ``(personal_key_path, shared_key_path)``.
288
+ """
289
+ if tmp_data_keys_present():
290
+ return PERSONAL_DATA_KEY_PATH, SHARED_DATA_KEY_PATH
291
+
292
+ ident = resolve_identity(identity)
293
+ id_path = personal_vars_path(project_dir, ident)
294
+ wraps = read_wrap_fields(id_path)
295
+ if IDENTITY_DATA_KEY_VAR not in wraps or SHARED_DATA_KEY_VAR not in wraps:
296
+ if require_wraps:
297
+ raise ValueError(
298
+ f"Missing key wraps in {id_path}. Run 'rig init' to create "
299
+ f"{IDENTITY_DATA_KEY_VAR} / {SHARED_DATA_KEY_VAR}."
300
+ )
301
+ # Legacy / test fallback: one ephemeral key for both vaults.
302
+ legacy = read_data_key_file(DEFAULT_DATA_KEY_PATH) or generate_data_key()
303
+ return materialize_data_keys(legacy, legacy)
304
+
305
+ key = master_key or prompt_master_key(
306
+ confirm=False,
307
+ input_fn=input_fn,
308
+ secret_fn=secret_fn,
309
+ label="Personal master key (unlock)",
310
+ )
311
+ identity_dk = unwrap_with_master_key(wraps[IDENTITY_DATA_KEY_VAR], key)
312
+ shared_dk = unwrap_with_master_key(wraps[SHARED_DATA_KEY_VAR], key)
313
+ return materialize_data_keys(identity_dk, shared_dk)
314
+
315
+
316
+ def shared_vault_exists(project_dir: str) -> bool:
317
+ path = shared_vars_path(project_dir)
318
+ if not os.path.exists(path):
319
+ return False
320
+ with open(path, "r", encoding="utf-8") as handle:
321
+ content = handle.read()
322
+ return "!vault" in content or "$ANSIBLE_VAULT" in content
323
+
324
+
325
+ def bootstrap_identity_keys(
326
+ project_dir: str,
327
+ *,
328
+ identity: Optional[str] = None,
329
+ master_key: Optional[str] = None,
330
+ shared_data_key: Optional[str] = None,
331
+ input_fn: Optional[Callable[..., str]] = None,
332
+ secret_fn: Optional[Callable[..., str]] = None,
333
+ config=None,
334
+ ) -> Tuple[str, str, str]:
335
+ """
336
+ Create wraps for a new identity and materialize keys.
337
+
338
+ Returns ``(identity, personal_key_path, shared_key_path)``.
339
+ """
340
+ from cli.config import RigConfig
341
+
342
+ cfg = config or RigConfig()
343
+ ask = input_fn or input
344
+ default_id = resolve_identity(identity, config=cfg)
345
+ answer = ask(f"Identity [{default_id}]: ").strip()
346
+ ident = sanitize_identity(answer or default_id)
347
+ cfg.set_identity(ident)
348
+
349
+ key = master_key or prompt_master_key(
350
+ confirm=True,
351
+ input_fn=input_fn,
352
+ secret_fn=secret_fn,
353
+ )
354
+
355
+ identity_dk = generate_data_key()
356
+ if shared_data_key:
357
+ shared_dk = shared_data_key
358
+ elif tmp_data_keys_present():
359
+ shared_dk = read_data_key_file(SHARED_DATA_KEY_PATH) or generate_data_key()
360
+ elif shared_vault_exists(project_dir):
361
+ ask_secret = secret_fn or getpass.getpass
362
+ print(
363
+ "Shared secrets already exist. Paste the shared data key "
364
+ "(from a teammate: rig vars export-shared-key)."
365
+ )
366
+ try:
367
+ pasted = ask_secret("Shared data key: ").strip()
368
+ except Exception:
369
+ pasted = ask("Shared data key: ").strip()
370
+ if not pasted:
371
+ raise ValueError("Shared data key is required to join this workspace")
372
+ shared_dk = pasted
373
+ else:
374
+ shared_dk = generate_data_key()
375
+ print("Generated new shared data key for this workspace")
376
+
377
+ id_path = personal_vars_path(project_dir, ident)
378
+ write_key_wraps(
379
+ id_path,
380
+ identity_data_key=identity_dk,
381
+ shared_data_key=shared_dk,
382
+ master_key=key,
383
+ )
384
+ personal_path, shared_path = materialize_data_keys(identity_dk, shared_dk)
385
+ print(f"Wrote key wraps to {id_path}")
386
+ print(f"Materialized data keys under {TMP_RIG_DIR}")
387
+ return ident, personal_path, shared_path
388
+
389
+
390
+ def filter_wrap_fields_from_yaml_text(content: str) -> str:
391
+ """Remove rig_*_data_key wrap lines/blocks from vars YAML text."""
392
+ cleaned = content
393
+ for name in WRAP_FIELD_NAMES:
394
+ cleaned = re.sub(
395
+ rf"^{re.escape(name)}:.*?(?=^\S|\Z)",
396
+ "",
397
+ cleaned,
398
+ flags=re.MULTILINE | re.DOTALL,
399
+ )
400
+ cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
401
+ return cleaned.lstrip("\n")
402
+
403
+
404
+ def write_filtered_vars_copy(src_path: str, dest_path: str) -> Optional[str]:
405
+ """
406
+ Copy vars file without wrap fields for ansible ``-e @file``.
407
+
408
+ Returns ``None`` when the filtered file would be empty (wraps-only identity
409
+ file). Ansible rejects an empty ``-e @file`` with "could not be made into
410
+ a dictionary".
411
+ """
412
+ if not os.path.exists(src_path):
413
+ return None
414
+ with open(src_path, "r", encoding="utf-8") as handle:
415
+ content = handle.read()
416
+ filtered = filter_wrap_fields_from_yaml_text(content).strip()
417
+ if not filtered:
418
+ return None
419
+ if not filtered.endswith("\n"):
420
+ filtered = filtered + "\n"
421
+ directory = os.path.dirname(dest_path)
422
+ if directory:
423
+ os.makedirs(directory, mode=0o700, exist_ok=True)
424
+ with open(dest_path, "w", encoding="utf-8") as handle:
425
+ handle.write(filtered)
426
+ try:
427
+ os.chmod(dest_path, 0o600)
428
+ except OSError:
429
+ pass
430
+ return dest_path
431
+
432
+
433
+ def is_personal_secret_name(name: str) -> bool:
434
+ """Heuristic: PATs and coding agent key are personal."""
435
+ n = (name or "").strip()
436
+ if n in {
437
+ "coding_agent_api_key",
438
+ "opencode_api_key",
439
+ "claude_api_key",
440
+ "cursor_api_key",
441
+ "github_pat",
442
+ "gitlab_pat",
443
+ }:
444
+ return True
445
+ if n.endswith("_git_pat"):
446
+ return True
447
+ return False
448
+
449
+
450
+ def fingerprint_key(data_key: str) -> str:
451
+ return hashlib.sha256(data_key.encode("utf-8")).hexdigest()[:12]