diff --git a/.sops.yaml b/.sops.yaml index 17b644f..b005f0a 100644 --- a/.sops.yaml +++ b/.sops.yaml @@ -7,7 +7,8 @@ # # To edit an encrypted file: sops secrets/ # To encrypt a new file: sops --encrypt --in-place secrets/ -# To decrypt to stdout (inspect): sops -d secrets/ +# Attended private recovery: make sops-decrypt FILE=secrets/.yaml OUTPUT=/ +# Run through the custody unlock helper; never decrypt into a transcript. # To add a recipient: update .sops.yaml + sops --rotate --in-place secrets/ creation_rules: diff --git a/Makefile b/Makefile index cc79174..e70e609 100644 --- a/Makefile +++ b/Makefile @@ -50,19 +50,8 @@ hooks-test: ## Test that the pre-commit hook blocks plaintext secrets @rm -rf sso-mfa/bootstrap/secrets/_hooktest # ── SOPS / age ──────────────────────────────────────────────────────────────── -sops-setup: ## Legacy dev-local setup: copy age key to SOPS default path - @mkdir -p ~/.config/sops/age - @if [[ -f ~/.config/age/key.txt ]]; then \ - cp -n ~/.config/age/key.txt ~/.config/sops/age/keys.txt || true; \ - chmod 600 ~/.config/sops/age/keys.txt; \ - echo "✔ SOPS key ready at ~/.config/sops/age/keys.txt"; \ - elif [[ -f ~/.config/sops/age/keys.txt ]]; then \ - echo "✔ SOPS key already present at ~/.config/sops/age/keys.txt"; \ - else \ - echo "ERROR: age key not found at ~/.config/age/key.txt"; \ - echo " Generate one with: age-keygen -o ~/.config/age/key.txt"; \ - exit 1; \ - fi +sops-setup: ## Validate the registered public age recipient; never install a private key + @python3 sso-mfa/bootstrap/sops-private.py setup sops-edit: ## Edit an encrypted file with SOPS: make sops-edit FILE=secrets/foo.yaml @[[ -n "$(FILE)" ]] || (echo "Usage: make sops-edit FILE=secrets/path/to/file.yaml"; exit 1) @@ -73,9 +62,10 @@ sops-encrypt: ## Encrypt a file in place: make sops-encrypt FILE=secrets/foo.yam sops --encrypt --in-place $(FILE) @echo "✔ Encrypted $(FILE)" -sops-decrypt: ## Decrypt a file to stdout (never writes plaintext to disk): make sops-decrypt FILE=secrets/foo.yaml - @[[ -n "$(FILE)" ]] || (echo "Usage: make sops-decrypt FILE=secrets/path/to/file.yaml"; exit 1) - sops -d $(FILE) +sops-decrypt: export NK_SOPS_FILE = $(FILE) +sops-decrypt: export NK_SOPS_OUTPUT = $(OUTPUT) +sops-decrypt: ## Decrypt age-only JSON/YAML to new private OUTPUT outside Git; explicit custody required + @python3 sso-mfa/bootstrap/sops-private.py decrypt sops-rotate: ## Rotate SOPS recipients after updating .sops.yaml: make sops-rotate FILE=secrets/foo.yaml @[[ -n "$(FILE)" ]] || (echo "Usage: make sops-rotate FILE=secrets/path/to/file.yaml"; exit 1) @@ -123,18 +113,16 @@ creds-init: sops-setup hooks ## One-time setup: verify prerequisites, configure @command -v kubectl >/dev/null 2>&1 \ && echo "✔ kubectl found" \ || echo "WARN: kubectl not found (required for creds-apply/verify)" - @test -f ~/.config/sops/age/keys.txt \ - || (echo "ERROR: age key not found — run 'make sops-setup' after placing your key at ~/.config/age/key.txt"; exit 1) @echo "" @echo "✔ creds-init complete. Next: make creds-generate" -creds-generate: ## Generate all service secrets and print KeePassXC entry guide +creds-generate: ## Generate private service files for public-recipient encryption @command -v openssl >/dev/null 2>&1 || (echo "ERROR: openssl not found"; exit 1) cd sso-mfa/bootstrap && bash gen-secrets.sh @sed -i "s|^generated_at: .*|generated_at: $$(date -Iseconds)|" sso-mfa/bootstrap/creds-state.yaml @echo "" @echo "State: generated_at updated in sso-mfa/bootstrap/creds-state.yaml" - @echo "Next : enter values into KeePassXC, then set keepass_confirmed: true" + @echo "Next : encrypt to the registered public recipient; never copy values into transcripts." @echo " Run 'make creds-bundle' to create an encrypted offsite backup." creds-bundle: ## Age-encrypt the ops bundle for offsite storage diff --git a/docs/security-bootstrap-age-custody.md b/docs/security-bootstrap-age-custody.md index fdd1943..7729618 100644 --- a/docs/security-bootstrap-age-custody.md +++ b/docs/security-bootstrap-age-custody.md @@ -174,8 +174,53 @@ its trap while a foreground child runs; this is not a process-group supervisor, SIGKILL cleanup guarantee or a runtime authority boundary. Temporary key cleanup on command return does not certify that an arbitrary child retained no copies. -Remaining concrete caller defects stay in NK-WP-0049-T02: Make `sops-setup` / -`creds-init` still install or require permanent HOME keys; Make `sops-decrypt` -still emits plaintext; legacy rotation and privacyIDEA bootstrap helpers contain -secret-bearing output. Do not use those legacy paths as the recovery procedure +Remaining concrete caller defects stay in NK-WP-0049-T02: legacy rotation and +privacyIDEA bootstrap helpers contain secret-bearing output; direct editor, +rotate and delegated apply/init paths still need their own custody/output audit. Do not use those legacy paths as the recovery procedure or treat these narrower helper repairs as closure of the whole custody surface. + +## Make recovery entry points — 2026-10-12 + +`make sops-setup` now validates only `keys/age.pub`. It never copies or generates +a private key; `creds-init` likewise has no permanent HOME key requirement. +Existing permanent files are not deleted or represented as remediated copies. + +`make sops-decrypt FILE=... OUTPUT=...` requires the custody helper's explicit +`NETKINGDOM_CUSTODY_AGE_KEY_FILE`. The owned private regular key file must match +the registered public recipient. Output must be a new file in an owned private +parent outside Git worktrees (including linked-worktree markers and bare repos). +No destination file/symlink is overwritten. Partial tool output is removed on +failure; successful publication is atomic and mode 0600. The operator removes +successful plaintext after the approved operation. This does not promise cleanup +after SIGKILL, hostile same-UID interference or arbitrary child copies. + +For the repository's age custody profile, the wrapper accepts SOPS JSON/YAML, +and rejects non-age recipients, age plugins, key groups, unknown metadata and +duplicate keys before decryption. +Other formats/backends require their own reviewed adapter; they are not silently +sent through a different credential source. YAML input requires PyYAML. + +SOPS runs with an isolated HOME/XDG configuration and a minimal environment, +using an inherited descriptor for the explicit key. This prevents SOPS from +combining it with HOME/SSH keys, inline keys, key commands, proxies or remote key +services. Raw parser/tool diagnostics and plaintext stdout never enter the +terminal transcript. This wrapper assumes trusted installed `age`, `age-keygen` +and `sops` binaries; it is not an OS sandbox or task-authorization grant. + +Use from the operator's approved custody terminal, with a private directory +created outside all repositories (for example using `mktemp -d`): + +```bash +# OUTPUT's parent must exist and be owned/private; select a new filename. +make sops-custody-run COMMAND='make sops-decrypt FILE=secrets/example.yaml OUTPUT=/private/recovery/new-example.yaml' +``` + +The command string carries paths only. Never put a credential in FILE, OUTPUT, +COMMAND, shell history or Make variables. Tests exercise real SOPS 3.13.3 with +disposable keys; no production key or encrypted secret was accessed. The test +binary checksum was checked against the official release checksum; no detached +signature verification or installed-toolchain promotion is claimed. + +Source basis: [SOPS age identity loading](https://github.com/getsops/sops/blob/v3.13.3/age/keysource.go) +loads explicit and ambient identities together, so setting `SOPS_AGE_KEY_FILE` +alone is insufficient to exclude ambient credentials. diff --git a/sso-mfa/bootstrap/sops-private.py b/sso-mfa/bootstrap/sops-private.py new file mode 100644 index 0000000..6df141a --- /dev/null +++ b/sso-mfa/bootstrap/sops-private.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +"""Age-only SOPS recovery to a new private file; no plaintext process output.""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import re +import shutil +import signal +import stat +import subprocess +import sys +import tempfile +import uuid + +ROOT = Path(__file__).resolve().parents[2] +RECIPIENT = re.compile(r'age1[023456789acdefghjklmnpqrstuvwxyz]{58}\Z') + + +class Refused(Exception): + pass + + +def public_recipient(): + lines = (ROOT / 'keys/age.pub').read_text().splitlines() + values = [line.strip() for line in lines if line.strip() and not line.startswith('#')] + if len(values) != 1 or not RECIPIENT.fullmatch(values[0]): + raise Refused('registered public recipient is invalid') + result = subprocess.run(['age', '-r', values[0]], input=b'', stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, timeout=10) + if result.returncode: + raise Refused('registered public recipient is invalid') + return values[0] + + +def unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise Refused('duplicate encrypted document key') + result[key] = value + return result + + +def age_document(source, recipient): + # Inspect ciphertext metadata before giving a credential to any subprocess. + # This repository's admitted SOPS profile is age-only, not arbitrary cloud + # KMS, key services, SSH identities or executable age plugins. + with source.open("rb") as encrypted: + raw = encrypted.read(8 * 1024 * 1024 + 1) + if len(raw) > 8 * 1024 * 1024: + raise Refused('encrypted input is too large') + if source.suffix == '.json': + data = json.loads(raw, object_pairs_hook=unique_object) + kind = 'json' + elif source.suffix in {'.yaml', '.yml'}: + import yaml + class UniqueLoader(yaml.SafeLoader): + def construct_mapping(self, node, deep=False): + self.flatten_mapping(node) + return unique_object((self.construct_object(key, deep=deep), + self.construct_object(value, deep=deep)) + for key, value in node.value) + try: + data = yaml.load(raw, Loader=UniqueLoader) + except yaml.YAMLError: + raise Refused('invalid encrypted YAML input') from None + kind = 'yaml' + else: + raise Refused('age recovery supports SOPS JSON/YAML input') + metadata = data.get('sops') if isinstance(data, dict) else None + if not isinstance(metadata, dict): + raise Refused('SOPS metadata is missing') + known = {'age', 'kms', 'gcp_kms', 'azure_kv', 'hc_vault', 'hckms', 'pgp', + 'key_groups', 'shamir_threshold', 'lastmodified', 'mac', 'version', + 'unencrypted_suffix', 'encrypted_suffix', 'unencrypted_regex', + 'encrypted_regex', 'unencrypted_comment_regex', 'encrypted_comment_regex', + 'mac_only_encrypted'} + if set(metadata) - known or metadata.get('key_groups'): + raise Refused('unsupported SOPS custody profile') + if any(metadata.get(key) for key in ('kms', 'gcp_kms', 'azure_kv', 'hc_vault', 'hckms', 'pgp')): + raise Refused('recovery requires age-only metadata') + recipients = metadata.get('age') + if not isinstance(recipients, list) or not recipients: + raise Refused('age recipient metadata is missing') + if any(not isinstance(row, dict) or not RECIPIENT.fullmatch(str(row.get('recipient', ''))) + for row in recipients): + raise Refused('unsupported age recipient') + if recipient not in [row['recipient'] for row in recipients]: + raise Refused('document does not include the registered recipient') + return raw, kind + + +def private_key_fd(): + name = os.environ.get('NETKINGDOM_CUSTODY_AGE_KEY_FILE', '') + if not name: + raise Refused('explicit temporary custody key required') + fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW) + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077 or info.st_size > 16384: + os.close(fd) + raise Refused('custody key must be an owned private regular file') + return fd + + +def decrypt(recipient): + source_name = os.environ.get('NK_SOPS_FILE', '') + output_name = os.environ.get('NK_SOPS_OUTPUT', '') + if not source_name or not output_name: + raise Refused('supply FILE and OUTPUT; plaintext stdout is disabled') + source = Path(source_name).resolve(strict=True) + output = Path(output_name).absolute() + parent = output.parent.resolve(strict=True) + if any((p / '.git').is_file() or (p / '.git/HEAD').is_file() + or ((p / 'HEAD').is_file() and (p / 'objects').is_dir() and (p / 'refs').is_dir()) + for p in [parent, *parent.parents]): + raise Refused('plaintext output must be outside Git worktrees') + raw, kind = age_document(source, recipient) + binary = shutil.which('sops') + if binary is None: + raise Refused('sops executable is unavailable') + directory = os.open(parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + temporary = '.netkingdom-sops-' + uuid.uuid4().hex + keyfd = None + try: + info = os.fstat(directory) + if info.st_uid != os.getuid() or info.st_mode & 0o077: + raise Refused('OUTPUT requires an owned private parent directory') + try: + os.stat(output.name, dir_fd=directory, follow_symlinks=False) + except FileNotFoundError: + pass + else: + raise Refused('OUTPUT already exists') + keyfd = private_key_fd() + derived = subprocess.run(['age-keygen', '-y', f'/proc/self/fd/{keyfd}'], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + pass_fds=(keyfd,), timeout=10) + if derived.returncode or derived.stdout.strip() != recipient.encode(): + raise Refused('custody key does not match the registered recipient') + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o600, dir_fd=directory) + with os.fdopen(fd, 'wb') as plain, tempfile.TemporaryDirectory(prefix='netkingdom-sops-home-') as isolated: + # HOME/XDG isolation matters: SOPS loads ambient keys in addition to + # an explicit key file. No inline keys, SSH keys, key commands, + # proxies or remote keyservice configuration are inherited. + env = {'PATH': os.environ.get('PATH', os.defpath), 'HOME': isolated, + 'XDG_CONFIG_HOME': isolated, 'GNUPGHOME': isolated, + 'SOPS_AGE_KEY_FILE': f'/proc/self/fd/{keyfd}', + 'AWS_EC2_METADATA_DISABLED': 'true'} + result = subprocess.run([binary, '--decrypt', '--input-type', kind, + '--output-type', kind, '/dev/stdin'], + input=raw, stdout=plain, stderr=subprocess.DEVNULL, + env=env, cwd=isolated, pass_fds=(keyfd,), timeout=30) + if result.returncode: + raise Refused('SOPS decryption failed; no plaintext published') + plain.flush() + os.fsync(plain.fileno()) + # Atomic no-overwrite publication within the private directory. + os.link(temporary, output.name, src_dir_fd=directory, dst_dir_fd=directory, + follow_symlinks=False) + os.unlink(temporary, dir_fd=directory) + os.fsync(directory) + finally: + if keyfd is not None: + os.close(keyfd) + try: + os.unlink(temporary, dir_fd=directory) + except FileNotFoundError: + pass + os.close(directory) + print('Decrypted to the requested private file. Remove it after the approved operation.') + + +def interrupted(_signum, _frame): + raise Refused('operation interrupted') + + +def main(): + for sig in (signal.SIGTERM, signal.SIGHUP, signal.SIGINT): + signal.signal(sig, interrupted) + try: + if len(sys.argv) != 2 or sys.argv[1] not in {'setup', 'decrypt'}: + raise Refused('unsupported custody operation') + recipient = public_recipient() + if sys.argv[1] == 'setup': + print('Registered public recipient validated. No private key was read, copied or generated.') + else: + decrypt(recipient) + return 0 + except Refused as exc: + print('ERROR: ' + str(exc), file=sys.stderr) + except (OSError, ValueError, TypeError, ImportError, subprocess.SubprocessError): + # Parser, filesystem and tool errors can include user data. Never echo. + print('ERROR: custody operation failed; input and tool diagnostics suppressed.', file=sys.stderr) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/test_sops_private.py b/tests/test_sops_private.py new file mode 100644 index 0000000..7f059a3 --- /dev/null +++ b/tests/test_sops_private.py @@ -0,0 +1,227 @@ +"""Use real SOPS with disposable age custody; no repository secret inputs.""" +import json +import os +from pathlib import Path +import shutil +import subprocess + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SOPS = shutil.which('sops') +pytestmark = pytest.mark.skipif(SOPS is None, reason='SOPS required for real crypto regression') + + +@pytest.fixture +def case(tmp_path): + repo = tmp_path / 'repo'; (repo / 'sso-mfa/bootstrap').mkdir(parents=True) + shutil.copyfile(ROOT / 'Makefile', repo / 'Makefile') + shutil.copyfile(ROOT / 'sso-mfa/bootstrap/sops-private.py', repo / 'sso-mfa/bootstrap/sops-private.py') + (repo / 'keys').mkdir() + key = tmp_path / 'disposable.key' + subprocess.run(['age-keygen', '-o', str(key)], capture_output=True, check=True) + public = subprocess.check_output(['age-keygen', '-y', str(key)], text=True).strip() + (repo / 'keys/age.pub').write_text(public + '\n') + home = tmp_path / 'home'; home.mkdir(mode=0o700) + env = {'HOME': str(home), 'PATH': os.environ['PATH'], 'NETKINGDOM_CUSTODY_AGE_KEY_FILE': str(key)} + clear = tmp_path / 'input.json'; clear.write_text(json.dumps({'credential': 'synthetic-secret-payload'})) + cipher = repo / 'cipher.json' + encrypted = subprocess.run([SOPS, '--encrypt', '--age', public, str(clear)], cwd=tmp_path, + env=env, capture_output=True, check=True) + cipher.write_bytes(encrypted.stdout) + output_dir = tmp_path / 'private-output'; output_dir.mkdir(mode=0o700) + return repo, key, env, cipher, output_dir / 'restored.json' + + +def make(case, target='sops-decrypt', *, env=None, source=None, output=None): + repo, _, default_env, cipher, destination = case + return subprocess.run(['make', '-s', target, 'FILE=' + str(source or cipher), + 'OUTPUT=' + str(output or destination)], + env=env or default_env, cwd=repo, capture_output=True, text=True, timeout=15) + + +def ambient_key(case): + _, key, env, _, _ = case + path = Path(env['HOME']) / '.config/sops/age/keys.txt' + path.parent.mkdir(parents=True); path.write_bytes(key.read_bytes()); path.chmod(0o600) + + +def test_setup_never_installs_private_key(case): + _, key, env, _, _ = case + legacy = Path(env['HOME']) / '.config/age/key.txt' + legacy.parent.mkdir(parents=True); legacy.write_bytes(key.read_bytes()) + result = make(case, 'sops-setup') + assert result.returncode == 0 + assert not (Path(env['HOME']) / '.config/sops').exists() + + +def test_decrypt_real_sops_to_private_output_without_transcript(case): + ambient_key(case) # Old target succeeds through ambient discovery and leaks. + result = make(case) + assert result.returncode == 0 + assert 'synthetic-secret-payload' not in result.stdout + result.stderr + assert json.loads(case[4].read_text()) == {'credential': 'synthetic-secret-payload'} + assert case[4].stat().st_mode & 0o077 == 0 + assert not list(case[4].parent.glob('.netkingdom-sops-*')) + + +def test_wrong_explicit_key_never_falls_back_to_home_or_inline_keys(case, tmp_path): + ambient_key(case) + wrong = tmp_path / 'wrong.key' + subprocess.run(['age-keygen', '-o', str(wrong)], capture_output=True, check=True) + env = {**case[2], 'NETKINGDOM_CUSTODY_AGE_KEY_FILE': str(wrong), 'SOPS_AGE_KEY': case[1].read_text()} + result = make(case, env=env) + assert result.returncode != 0 + assert 'synthetic-secret-payload' not in result.stdout + result.stderr + assert not case[4].exists() + + +def test_absent_explicit_custody_refused_even_if_home_has_key(case): + ambient_key(case) + env = dict(case[2]); del env['NETKINGDOM_CUSTODY_AGE_KEY_FILE'] + assert make(case, env=env).returncode != 0 + assert not case[4].exists() + + +@pytest.mark.parametrize('kind', ['file', 'symlink']) +def test_existing_output_is_not_replaced(case, kind): + ambient_key(case) + if kind == 'file': case[4].write_bytes(b'original-output') + else: case[4].symlink_to(case[4].parent / 'absent') + assert make(case).returncode != 0 + if kind == 'file': assert case[4].read_bytes() == b'original-output' + else: assert case[4].is_symlink() + + +def test_git_worktree_output_is_refused(case): + ambient_key(case) + subprocess.run(['git', 'init', str(case[0])], env=case[2], capture_output=True, check=True) + assert make(case, output=case[0] / 'plaintext.json').returncode != 0 + assert not (case[0] / 'plaintext.json').exists() + + +def test_shared_output_directory_is_refused(case): + ambient_key(case) + case[4].parent.chmod(0o755) + assert make(case).returncode != 0 + assert not case[4].exists() + + +def noisy_tool(case, tmp_path): + bin_dir = tmp_path / 'bin'; bin_dir.mkdir() + tool = bin_dir / 'sops' + tool.write_text('#!/bin/sh\necho synthetic-tool-secret\necho synthetic-error-secret >&2\nexit 23\n') + tool.chmod(0o755) + return {**case[2], 'PATH': str(bin_dir) + ':' + case[2]['PATH']} + + +def test_child_output_and_errors_are_not_exposed_on_failure(case, tmp_path): + result = make(case, env=noisy_tool(case, tmp_path)) + assert result.returncode != 0 + assert 'synthetic-tool-secret' not in result.stdout + result.stderr + assert 'synthetic-error-secret' not in result.stdout + result.stderr + assert not case[4].exists() + assert not list(case[4].parent.glob('.netkingdom-sops-*')) + + +def test_non_age_metadata_refuses_before_tool(case, tmp_path): + data = json.loads(case[3].read_text()); data['sops']['kms'] = [{'arn': 'synthetic'}] + case[3].write_text(json.dumps(data)) + result = make(case, env=noisy_tool(case, tmp_path)) + assert result.returncode != 0 + assert 'age-only metadata' in result.stderr + assert 'synthetic-tool-secret' not in result.stdout + + +def test_filename_is_not_shell_code(case): + ambient_key(case) + odd = case[0] / 'cipher; touch SHELL_EXECUTED; #.json' + shutil.copyfile(case[3], odd) + result = make(case, source=odd) + assert result.returncode == 0 + assert not (case[0] / 'SHELL_EXECUTED').exists() + assert case[4].exists() + + +def test_yaml_roundtrip(case, tmp_path): + public = (case[0] / 'keys/age.pub').read_text().strip() + clear = tmp_path / 'input.yaml'; clear.write_text('credential: synthetic-yaml-secret\n') + cipher = case[0] / 'cipher.yaml' + result = subprocess.run([SOPS, '--encrypt', '--age', public, str(clear)], cwd=tmp_path, + env=case[2], capture_output=True, check=True) + cipher.write_bytes(result.stdout) + result = make(case, source=cipher) + assert result.returncode == 0 + assert 'synthetic-yaml-secret' not in result.stdout + result.stderr + assert 'synthetic-yaml-secret' in case[4].read_text() + + +def test_parser_error_never_echoes_input(case): + case[3].write_text('synthetic-malformed-secret') + result = make(case) + assert result.returncode != 0 + assert 'synthetic-malformed-secret' not in result.stdout + result.stderr + assert not case[4].exists() + + +def test_shared_custody_key_is_refused(case): + case[1].chmod(0o644) + result = make(case) + assert result.returncode != 0 + assert not case[4].exists() + + +def test_creds_init_has_no_permanent_key_requirement(case): + subprocess.run(['git', 'init', str(case[0])], env=case[2], capture_output=True, check=True) + hooks = case[0] / '.githooks'; hooks.mkdir() + (hooks / 'pre-commit').write_text('#!/bin/sh\nexit 0\n') + result = make(case, 'creds-init') + assert result.returncode == 0 + assert not (Path(case[2]['HOME']) / '.config/sops').exists() + + +def test_real_sops_does_not_execute_ambient_key_commands(case, tmp_path): + marker = tmp_path / 'ambient-command-executed' + command = tmp_path / 'ambient-key-command' + command.write_text('#!/bin/sh\ntouch ' + str(marker) + '\nexit 1\n') + command.chmod(0o755) + env = {**case[2], 'SOPS_AGE_KEY_CMD': str(command), 'SOPS_AGE_SSH_PRIVATE_KEY_CMD': str(command)} + result = make(case, env=env) + assert result.returncode == 0 + assert not marker.exists() + + +def test_yaml_parser_error_is_sanitized(case): + bad = case[0] / 'broken.yaml' + bad.write_text('secret: [synthetic-malformed-yaml-secret\n') + result = make(case, source=bad) + assert result.returncode != 0 + assert 'synthetic-malformed-yaml-secret' not in result.stdout + result.stderr + assert 'Traceback' not in result.stderr + + +@pytest.mark.parametrize('kind', ['json', 'yaml']) +def test_duplicate_metadata_refused_before_sops(case, kind): + source = case[0] / ('duplicate.' + kind) + source.write_text('{"sops": {}, "sops": {}}' if kind == 'json' else 'sops: {}\nsops: {}\n') + result = make(case, source=source) + assert result.returncode != 0 + assert 'duplicate' in result.stderr + + +@pytest.mark.parametrize('kind', ['bare', 'linked']) +def test_bare_and_linked_git_outputs_are_refused(case, kind): + location = case[4].parent / 'git-output' + if kind == 'bare': + subprocess.run(['git', 'init', '--bare', str(location)], env=case[2], capture_output=True, check=True) + else: + subprocess.run(['git', 'init', str(case[0])], env=case[2], capture_output=True, check=True) + subprocess.run(['git', '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', + 'commit', '--allow-empty', '-m', 'fixture'], cwd=case[0], env=case[2], + capture_output=True, check=True) + subprocess.run(['git', 'worktree', 'add', '--detach', str(location)], cwd=case[0], env=case[2], + capture_output=True, check=True) + location.chmod(0o700) + result = make(case, output=location / 'plaintext.json') + assert result.returncode != 0 + assert not (location / 'plaintext.json').exists() diff --git a/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md b/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md index 22c8bd9..ea04c5e 100644 --- a/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md +++ b/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md @@ -74,8 +74,6 @@ signal deferral and arbitrary-child copy retention are explicitly not solved by this helper. Evidence: Custodian `custody-entrypoints.json`. Concrete uncovered callers remain (T02 stays progress): -- `Makefile` sops-setup/creds-init copy or require permanent HOME private keys. -- `Makefile` sops-decrypt sends plaintext to stdout. - `sso-mfa/bootstrap/creds-rotate.sh` logs newly generated values and embeds a password in a printed SQL command. Its source can be repaired without executing founder-deferred rotations; no such rotation has been run. @@ -83,6 +81,18 @@ Concrete uncovered callers remain (T02 stays progress): - Delegated create/apply helpers still need full tracing/error/Secret-annotation output review; transport capture and native effect evidence remain mandatory. +2026-10-12 Make caller repair: sops-setup now validates only the public recipient; +creds-init no longer requires or installs a permanent private key. sops-decrypt +uses the new age-only SOPS recovery adapter: explicit matching private custody +file, isolated HOME/XDG/environment, pinned file descriptor, sanitized diagnostics, +private no-overwrite output outside Git, atomic publication and failure cleanup. +FILE/OUTPUT are passed as environment data instead of interpolated shell code. +The old targets failed all 11 initial real-SOPS regressions. Additional format, +parser, environment-command and credential-mode tests cover the final adapter. +Existing HOME copies were not deleted; native recovery and the remaining direct +editor/rotate/delegated paths are unverified. Evidence is Custodian +`sops-make-entrypoints.json`; T02 remains progress, not source-surface closure. + ## Verify owner-approved recovery and current native callers ```task