From 8f808577d6b322a2443d9ac922446c4838bf0d5d Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 13 Oct 2026 21:03:24 +0200 Subject: [PATCH] fix(security): constrain SOPS writes and custody argument handoff Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a1346b-72a4-7d40-b8bd-8b8ca346cfa2 --- .sops.yaml | 8 +- Makefile | 46 ++-- docs/security-bootstrap-age-custody.md | 47 +++- sso-mfa/bootstrap/README.md | 11 + sso-mfa/bootstrap/sops-private.py | 33 ++- sso-mfa/bootstrap/sops-write.py | 188 ++++++++++++++ tests/test_sops_write.py | 244 ++++++++++++++++++ ...-bootstrap-secret-free-recovery-handoff.md | 40 ++- 8 files changed, 573 insertions(+), 44 deletions(-) create mode 100644 sso-mfa/bootstrap/sops-write.py create mode 100644 tests/test_sops_write.py diff --git a/.sops.yaml b/.sops.yaml index b005f0a..4df122b 100644 --- a/.sops.yaml +++ b/.sops.yaml @@ -2,14 +2,14 @@ # Any file under a secrets/ directory (at any depth) is encrypted with the operator age key. # Same age keypair as railiance-infra — one key per operator across all repos. # -# Key fingerprint: age1aq8twfd78wvpra0had8cezcnj96tj4q0068edrz5jez8d6xwmflqdepsh4 # Public key stored in: keys/age.pub # -# To edit an encrypted file: sops secrets/ -# To encrypt a new file: sops --encrypt --in-place secrets/ +# To replace encrypted content: make sops-edit FILE=secrets/.yaml INPUT=/.yaml +# To encrypt a private untracked file: make sops-encrypt FILE=secrets/.yaml # 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/ +# To rotate the data key only: make sops-rotate FILE=secrets/.yaml +# Recipient changes require a separately reviewed custody migration; rotate does not apply this config. creation_rules: - path_regex: secrets/.*$ diff --git a/Makefile b/Makefile index 12a43ae..c5f785e 100644 --- a/Makefile +++ b/Makefile @@ -53,39 +53,39 @@ hooks-test: ## Test that the pre-commit hook blocks plaintext secrets 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) - sops $(FILE) +# Raw values prevent both Make-function evaluation and shell interpolation. +unexport FILE INPUT OUTPUT COMMAND OPERATOR_AGE_PUBKEY +sops-edit sops-encrypt sops-decrypt sops-rotate: export NK_SOPS_FILE := $(value FILE) +sops-edit: export NK_SOPS_INPUT := $(value INPUT) +sops-decrypt: export NK_SOPS_OUTPUT := $(value OUTPUT) -sops-encrypt: ## Encrypt a file in place: make sops-encrypt FILE=secrets/foo.yaml - @[[ -n "$(FILE)" ]] || (echo "Usage: make sops-encrypt FILE=secrets/path/to/file.yaml"; exit 1) - sops --encrypt --in-place $(FILE) - @echo "✔ Encrypted $(FILE)" +sops-edit: ## Replace encrypted content from private INPUT outside Git; explicit custody required + @python3 sso-mfa/bootstrap/sops-write.py edit + +sops-encrypt: ## Encrypt private, untracked FILE in place with the registered public recipient + @python3 sso-mfa/bootstrap/sops-write.py encrypt -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) - sops --rotate --in-place $(FILE) - @echo "✔ Recipients rotated for $(FILE)" +sops-rotate: ## Rotate the SOPS data key; recipient changes require a separate reviewed procedure + @python3 sso-mfa/bootstrap/sops-write.py rotate -sops-custody-check: ## Validate a custody age key against keys/age.pub without keeping it on disk +sops-custody-check sops-custody-shell sops-custody-run: export NK_CUSTODY_RECIPIENT := $(value OPERATOR_AGE_PUBKEY) +sops-custody-run: export NK_CUSTODY_COMMAND := $(value COMMAND) + +sops-custody-check: ## Validate a custody age key without keeping it on disk @bash sso-mfa/bootstrap/sops-custody-unlock.sh \ - --expected-recipient "$(OPERATOR_AGE_PUBKEY)" \ - --check-only + --expected-recipient "$$NK_CUSTODY_RECIPIENT" --check-only sops-custody-shell: ## Open a shell with a temporary SOPS_AGE_KEY_FILE from custody material @bash sso-mfa/bootstrap/sops-custody-unlock.sh \ - --expected-recipient "$(OPERATOR_AGE_PUBKEY)" + --expected-recipient "$$NK_CUSTODY_RECIPIENT" -sops-custody-run: ## Run COMMAND with a temporary custody age key: make sops-custody-run COMMAND='make -C ../inter-hub recovery-drill' - @[[ -n "$(COMMAND)" ]] || (echo "Usage: make sops-custody-run COMMAND='make -C ../inter-hub recovery-drill'"; exit 1) - @COMMAND='$(COMMAND)' bash sso-mfa/bootstrap/sops-custody-unlock.sh \ - --expected-recipient "$(OPERATOR_AGE_PUBKEY)" \ - -- bash -lc "$$COMMAND" +sops-custody-run: ## Run COMMAND inside the temporary custody handoff + @[[ -n "$$NK_CUSTODY_COMMAND" ]] || (echo "Usage: make sops-custody-run COMMAND=''"; exit 1) + @bash sso-mfa/bootstrap/sops-custody-unlock.sh \ + --expected-recipient "$$NK_CUSTODY_RECIPIENT" -- bash -lc "$$NK_CUSTODY_COMMAND" check-secrets: ## Fail if any file under secrets/ is not SOPS-encrypted @echo "Checking for unencrypted files under secrets/..." @@ -100,7 +100,7 @@ check-secrets: ## Fail if any file under secrets/ is not SOPS-encrypted done < <(git ls-files --others --cached 'secrets' 2>/dev/null | grep -v '/$$'); \ if [[ "$$bad" -ne 0 ]]; then \ echo ""; \ - echo "ERROR: Unencrypted secret(s) detected. Encrypt with: sops --encrypt --in-place "; \ + echo "ERROR: Unencrypted file detected. For private untracked input: make sops-encrypt FILE=; tracked plaintext requires incident review."; \ exit 1; \ fi; \ echo "✔ All secrets/ files appear SOPS-encrypted" diff --git a/docs/security-bootstrap-age-custody.md b/docs/security-bootstrap-age-custody.md index a520118..19f3cd8 100644 --- a/docs/security-bootstrap-age-custody.md +++ b/docs/security-bootstrap-age-custody.md @@ -174,10 +174,10 @@ 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: 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. +Remaining concrete caller defects stay in NK-WP-0049-T02, including legacy +diagnostics, key export and coordinated rotation. Its current checklist records +repaired callers and outstanding work. Do not treat narrower helper repairs as +closure of the whole custody surface. ## Make recovery entry points — 2026-10-12 @@ -226,6 +226,45 @@ loads explicit and ambient identities together, so setting `SOPS_AGE_KEY_FILE` alone is insufficient to exclude ambient credentials. +## SOPS writes — 2026-10-13 + +The Make wrappers support the registered single-age JSON/YAML profile: + +- `make sops-encrypt FILE=...` encrypts a private, owned, untracked plaintext + mapping in place using only `keys/age.pub`. It needs no private key. Tracked + plaintext is refused and needs incident review; encryption cannot erase history. +- `make sops-edit FILE=... INPUT=...` replaces content from an owned private + plaintext file in a private directory outside Git. Use private decryption above + to prepare that file, edit it in the approved custody environment, then remove + it after successful replacement. No ambient editor is launched. INPUT must use + the same JSON/YAML format. The original MAC is verified before replacement and + its declared field-encryption policy is preserved. +- `make sops-rotate FILE=...` rotates the data key and re-encrypts values. It does + **not** migrate recipients or apply changes to `.sops.yaml`. A recipient change + needs its own reviewed custody migration, independent recipient admission and + recovery verification. + +Edit and rotate require the explicit matching temporary custody key. Both refuse +extra document recipients, even when the registered key can decrypt the file: +metadata is not authority to rewrap a data key for another recipient. The new +ciphertext is decrypted for verification with plaintext output discarded. +Inputs reject symlinks, hardlinks, nonregular files and unsafe modes; key inputs +also reject symlink ancestors and FIFOs. Parser and tool diagnostics are captured. +Make passes FILE, INPUT, OUTPUT and custody arguments literally, without evaluating +Make functions or interpolating them into shell code before the custody handoff. +The approved COMMAND is still intentionally executed inside that handoff. + +Ciphertext publication uses private staging, a cooperative file lock and a final +source identity/version check followed by atomic rename. The check and rename are +separate operations: this is not protection against hostile concurrent same-UID +writers or ancestor-directory substitution. Directory fsync failure after rename +can report failure after publication; inspect state before retrying. SIGKILL may +leave staging files. Private modes and a temporary key are not credential isolation +or an authorization grant. Current native recovery acceptance remains NK-WP-0049-T03. + +Version-specific behavior was exercised with disposable SOPS 3.13.3 and age keys; +see the [upstream SOPS documentation](https://github.com/getsops/sops/blob/v3.13.3/README.rst). + ## Rotation output boundary (2026-10-12 source repair) `creds-rotate.sh` keeps generated values out of stdout/stderr and disables shell diff --git a/sso-mfa/bootstrap/README.md b/sso-mfa/bootstrap/README.md index cf0d924..c7d3ccb 100644 --- a/sso-mfa/bootstrap/README.md +++ b/sso-mfa/bootstrap/README.md @@ -208,3 +208,14 @@ make sops-custody-shell ``` Exit the shell when finished so the temporary key file is removed. + + +For repository SOPS files, use `make sops-encrypt FILE=...` with private untracked +JSON/YAML plaintext. Inside the approved custody handoff, `make sops-decrypt +FILE=... OUTPUT=...` creates a new private file outside Git, and `make sops-edit +FILE=... INPUT=...` replaces encrypted content from a private file outside Git. +Remove recovered/replacement plaintext after use. `make sops-rotate FILE=...` +rotates only the data key; recipient migration requires a separately reviewed +procedure. Edit/rotate require the explicit matching key and exactly the registered +recipient. See [the custody contract](../../docs/security-bootstrap-age-custody.md) +for input restrictions, failure handling and concurrency limits. diff --git a/sso-mfa/bootstrap/sops-private.py b/sso-mfa/bootstrap/sops-private.py index 6df141a..21c20b2 100644 --- a/sso-mfa/bootstrap/sops-private.py +++ b/sso-mfa/bootstrap/sops-private.py @@ -43,18 +43,11 @@ def unique_object(pairs): 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': +def parse_document(raw, suffix): + if suffix == '.json': data = json.loads(raw, object_pairs_hook=unique_object) kind = 'json' - elif source.suffix in {'.yaml', '.yml'}: + elif suffix in {'.yaml', '.yml'}: import yaml class UniqueLoader(yaml.SafeLoader): def construct_mapping(self, node, deep=False): @@ -69,6 +62,19 @@ def age_document(source, recipient): kind = 'yaml' else: raise Refused('age recovery supports SOPS JSON/YAML input') + return data, kind + + +def age_document(source, recipient, *, raw=None): + # 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. + if raw is None: + 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') + data, kind = parse_document(raw, source.suffix) metadata = data.get('sops') if isinstance(data, dict) else None if not isinstance(metadata, dict): raise Refused('SOPS metadata is missing') @@ -96,9 +102,12 @@ 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) + path = Path(os.path.abspath(name)) + if any(p.is_symlink() for p in (path, *path.parents)): + raise Refused('custody key path must not contain symlinks') + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) 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: + if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077 or info.st_nlink != 1 or info.st_size > 16384: os.close(fd) raise Refused('custody key must be an owned private regular file') return fd diff --git a/sso-mfa/bootstrap/sops-write.py b/sso-mfa/bootstrap/sops-write.py new file mode 100644 index 0000000..1bbe4ed --- /dev/null +++ b/sso-mfa/bootstrap/sops-write.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Private SOPS writes for the independently registered single-age profile. + +Edit consumes an explicitly prepared private plaintext file. No editor or shell +is launched, and subprocess plaintext output is discarded, never forwarded. +""" +import contextlib +import fcntl +import importlib.util +import os +from pathlib import Path +import shutil +import signal +import stat +import subprocess +import sys +import tempfile +import uuid + +spec = importlib.util.spec_from_file_location('sops_private', Path(__file__).with_name('sops-private.py')) +base = importlib.util.module_from_spec(spec) +spec.loader.exec_module(base) +Refused = base.Refused +LIMIT = 8 * 1024 * 1024 +POLICY = ('unencrypted_suffix', 'encrypted_suffix', 'unencrypted_regex', 'encrypted_regex', + 'unencrypted_comment_regex', 'encrypted_comment_regex') + + +def in_git(parent): + return 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)) + + +@contextlib.contextmanager +def snapshot(name, *, plaintext=False, replacement=False, lock=False): + if not name: raise Refused('explicit FILE or INPUT is required') + path = Path(os.path.abspath(name)) + if any(p.is_symlink() for p in (path, *path.parents)): + raise Refused('file paths must not contain symlinks') + directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + fd = None + try: + parent = os.fstat(directory) + if parent.st_uid != os.getuid() or parent.st_mode & (0o077 if replacement else 0o022): + raise Refused('file directory ownership/mode refused') + if replacement and in_git(path.parent): + raise Refused('private replacement input must be outside Git') + fd = os.open(path.name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory) + info = os.fstat(fd) + if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_nlink != 1 + or info.st_mode & (0o077 if plaintext else 0o022)): + raise Refused('file ownership/type/mode refused') + if lock: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + with os.fdopen(os.dup(fd), 'rb') as stream: + raw = stream.read(LIMIT + 1) + if len(raw) > LIMIT: raise Refused('input exceeds the supported size') + yield path, directory, info, raw + finally: + if fd is not None: os.close(fd) + os.close(directory) + + +def plain_document(raw, suffix): + data, kind = base.parse_document(raw, suffix) + if not isinstance(data, dict) or not data or 'sops' in data: + raise Refused('plaintext must be a nonempty JSON/YAML mapping without SOPS metadata') + return kind + + +def encrypted_document(raw, path, recipient): + _, kind = base.age_document(path, recipient, raw=raw) + data, _ = base.parse_document(raw, path.suffix) + metadata = data['sops'] + # Document recipient metadata is not an independent admission of another + # recipient. Never rewrap a data key to an injected or unreviewed identity. + if len(metadata['age']) != 1 or metadata['age'][0]['recipient'] != recipient: + raise Refused('write requires exactly the independently registered recipient') + return kind, metadata + + +def sops(binary, arguments, raw, env, isolated, keyfd=None, *, discard=False): + result = subprocess.run([binary, *arguments, '/dev/stdin'], input=raw, + stdout=subprocess.DEVNULL if discard else subprocess.PIPE, + stderr=subprocess.DEVNULL, cwd=isolated, env=env, + pass_fds=() if keyfd is None else (keyfd,), timeout=30) + if result.returncode: raise Refused('SOPS operation failed; no replacement published') + if not discard and (not result.stdout or len(result.stdout) > LIMIT): + raise Refused('SOPS output was empty or oversized') + return None if discard else result.stdout + + +def publish(path, directory, original, encrypted): + temporary = '.netkingdom-sops-write-' + uuid.uuid4().hex + try: + 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 file: + file.write(encrypted); file.flush(); os.fsync(file.fileno()) + current = os.stat(path.name, dir_fd=directory, follow_symlinks=False) + fields = ('st_dev', 'st_ino', 'st_size', 'st_mtime_ns', 'st_ctime_ns') + if any(getattr(current, key) != getattr(original, key) for key in fields): + raise Refused('source changed during operation; replacement refused') + os.replace(temporary, path.name, src_dir_fd=directory, dst_dir_fd=directory) + os.fsync(directory) + finally: + try: os.unlink(temporary, dir_fd=directory) + except FileNotFoundError: pass + + +def write(action, recipient): + binary = shutil.which('sops') + if binary is None: raise Refused('sops executable is unavailable') + with snapshot(os.environ.get('NK_SOPS_FILE', ''), plaintext=action == 'encrypt', lock=True) as target: + path, directory, original, raw = target + if action == 'encrypt': + kind = plain_document(raw, path.suffix) + if in_git(path.parent): + tracked = subprocess.run(['git', '-C', str(path.parent), 'ls-files', '--error-unmatch', '--', path.name], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10) + if tracked.returncode != 1: + raise Refused('tracked plaintext or unverifiable Git index requires incident review') + metadata = {} + else: + kind, metadata = encrypted_document(raw, path, recipient) + keyfd = None + try: + with tempfile.TemporaryDirectory(prefix='netkingdom-sops-write-home-') as isolated: + env = {'PATH': os.environ.get('PATH', os.defpath), 'HOME': isolated, 'XDG_CONFIG_HOME': isolated, + 'GNUPGHOME': isolated, 'AWS_EC2_METADATA_DISABLED': 'true'} + if action != 'encrypt': + keyfd = base.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 registered recipient') + env['SOPS_AGE_KEY_FILE'] = f'/proc/self/fd/{keyfd}' + formats = ['--input-type', kind, '--output-type', kind] + if action == 'rotate': + encrypted = sops(binary, ['--rotate', *formats], raw, env, isolated, keyfd) + else: + options = [] + if action == 'edit': + # MAC verification must precede replacement of even a + # syntactically valid encrypted document. + sops(binary, ['--decrypt', *formats], raw, env, isolated, keyfd, discard=True) + with snapshot(os.environ.get('NK_SOPS_INPUT', ''), plaintext=True, replacement=True) as replacement: + plain = replacement[3] + if plain_document(plain, replacement[0].suffix) != kind: + raise Refused('replacement format must match the encrypted document') + for name in POLICY: + value = metadata.get(name) + if value: + if not isinstance(value, str): raise Refused('invalid encryption policy') + options.extend(['--' + name.replace('_', '-'), value]) + if metadata.get('mac_only_encrypted'): + options.append('--mac-only-encrypted') + else: + plain = raw + encrypted = sops(binary, ['--encrypt', '--age', recipient, *formats, *options], + plain, env, isolated, keyfd) + encrypted_document(encrypted, path, recipient) + if keyfd is not None: + sops(binary, ['--decrypt', *formats], encrypted, env, isolated, keyfd, discard=True) + if base.public_recipient() != recipient: + raise Refused('registered recipient changed during operation') + publish(path, directory, original, encrypted) + finally: + if keyfd is not None: os.close(keyfd) + print('Encrypted file updated; no plaintext was printed. Retain approved independent recovery custody.') + + +def main(): + for sig in (signal.SIGTERM, signal.SIGHUP, signal.SIGINT): signal.signal(sig, base.interrupted) + try: + if len(sys.argv) != 2 or sys.argv[1] not in {'encrypt', 'rotate', 'edit'}: + raise Refused('unsupported SOPS write operation') + write(sys.argv[1], base.public_recipient()) + return 0 + except Refused as exc: + print('ERROR: ' + str(exc), file=sys.stderr) + except (OSError, ValueError, TypeError, KeyError, ImportError, subprocess.SubprocessError): + print('ERROR: SOPS write refused or failed; input and tool diagnostics suppressed.', file=sys.stderr) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/test_sops_write.py b/tests/test_sops_write.py new file mode 100644 index 0000000..b548fbd --- /dev/null +++ b/tests/test_sops_write.py @@ -0,0 +1,244 @@ +"""Real SOPS write-path regressions with disposable keys and plaintext only.""" +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='real SOPS required') +PLAIN='synthetic-sops-write-value' + + +@pytest.fixture +def case(tmp_path): + repo=tmp_path/'repo';boot=repo/'sso-mfa/bootstrap';boot.mkdir(parents=True) + shutil.copyfile(ROOT/'Makefile',repo/'Makefile') + for name in ['sops-private.py','sops-write.py']: + if (ROOT/'sso-mfa/bootstrap'/name).exists():shutil.copyfile(ROOT/'sso-mfa/bootstrap'/name,boot/name) + (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') + (repo/'.sops.yaml').write_text('creation_rules:\n - age: '+public+'\n') + home=tmp_path/'home';home.mkdir(mode=0o700) + private=tmp_path/'private';private.mkdir(mode=0o700) + replacement=private/'replacement.json';replacement.write_text(json.dumps({'credential':PLAIN+'-edited'}));replacement.chmod(0o600) + cipher=repo/'cipher.json' + env={'HOME':str(home),'PATH':os.environ['PATH'],'NETKINGDOM_CUSTODY_AGE_KEY_FILE':str(key),'SOPS_AGE_KEY_FILE':str(key)} + encrypted=subprocess.run([SOPS,'--encrypt','--age',public,'--input-type','json','--output-type','json','/dev/stdin'],input=json.dumps({'credential':PLAIN}).encode(),cwd=tmp_path,env=env,capture_output=True,check=True) + cipher.write_bytes(encrypted.stdout) + return {'repo':repo,'key':key,'public':public,'env':env,'cipher':cipher,'input':replacement,'tmp':tmp_path} + + +def invoke(case,action,*,file=None,input=None,env=None): + return subprocess.run(['make','-s','sops-'+action,'FILE='+str(file or case['cipher']),'INPUT='+str(input or case['input'])],cwd=case['repo'],env=env or case['env'],capture_output=True,text=True,timeout=15) + + +def decode(case,file=None): + result=subprocess.run([SOPS,'--decrypt',str(file or case['cipher'])],env=case['env'],capture_output=True,check=True) + return json.loads(result.stdout) + + +def quiet(result): + assert PLAIN not in result.stdout+result.stderr + assert 'Traceback' not in result.stdout+result.stderr + + +def plaintext(case,name='plain.json'): + path=case['repo']/name;path.write_text(json.dumps({'credential':PLAIN}));path.chmod(0o600);return path + + +def test_public_only_encrypt_roundtrip(case): + path=plaintext(case);env=dict(case['env']);env.pop('NETKINGDOM_CUSTODY_AGE_KEY_FILE');env.pop('SOPS_AGE_KEY_FILE') + result=invoke(case,'encrypt',file=path,env=env);assert result.returncode==0,result.stderr;quiet(result) + assert decode(case,path)=={'credential':PLAIN} + assert json.loads(path.read_text())['sops']['age'][0]['recipient']==case['public'] + assert path.stat().st_mode & 0o077==0 + + +def test_rotate_changes_ciphertext_not_plaintext_or_recipient(case): + before=case['cipher'].read_bytes();result=invoke(case,'rotate');assert result.returncode==0,result.stderr;quiet(result) + assert case['cipher'].read_bytes()!=before and decode(case)=={'credential':PLAIN} + assert [r['recipient'] for r in json.loads(case['cipher'].read_text())['sops']['age']]==[case['public']] + + +def test_edit_uses_private_replacement_and_never_launches_ambient_editor(case): + marker=case['tmp']/'editor-called';editor=case['tmp']/'editor' + editor.write_text('#!/bin/sh\ntouch '+str(marker)+'\nprintf '+PLAIN+'\\n >&2\n');editor.chmod(0o700) + result=invoke(case,'edit',env={**case['env'],'SOPS_EDITOR':str(editor),'EDITOR':str(editor)}) + assert result.returncode==0,result.stderr;quiet(result) + assert not marker.exists() and decode(case)=={'credential':PLAIN+'-edited'} + assert json.loads(case['input'].read_text())=={'credential':PLAIN+'-edited'} + + +@pytest.mark.parametrize('name',['cipher; touch SHELL_EXECUTED; #.json','$(shell touch MAKE_EXECUTED).json','with spaces.json']) +@pytest.mark.parametrize('action',['encrypt','rotate','edit']) +def test_file_names_are_data_not_shell_or_make_programs(case,name,action): + file=case['repo']/name + if action=='encrypt':file=plaintext(case,name) + else:file.write_bytes(case['cipher'].read_bytes()) + result=invoke(case,action,file=file);assert result.returncode==0,result.stderr;quiet(result) + assert not (case['repo']/'SHELL_EXECUTED').exists() and not (case['repo']/'MAKE_EXECUTED').exists() + assert decode(case,file)=={'credential':PLAIN+('-edited' if action=='edit' else '')} + + +@pytest.mark.parametrize('action',['rotate','edit']) +def test_explicit_key_required_despite_ambient_identity(case,action): + before=case['cipher'].read_bytes();env=dict(case['env']);env.pop('NETKINGDOM_CUSTODY_AGE_KEY_FILE') + result=invoke(case,action,env=env);assert result.returncode!=0;quiet(result) + assert case['cipher'].read_bytes()==before + + +@pytest.mark.parametrize('action',['rotate','edit']) +def test_unauthorized_recipient_metadata_is_not_a_rotation_grant(case,action): + other=case['tmp']/'other.key';subprocess.run(['age-keygen','-o',str(other)],capture_output=True,check=True) + public=subprocess.check_output(['age-keygen','-y',str(other)],text=True).strip() + data=json.loads(case['cipher'].read_text());data['sops']['age'].append({**data['sops']['age'][0],'recipient':public}) + case['cipher'].write_text(json.dumps(data));before=case['cipher'].read_bytes() + result=invoke(case,action);assert result.returncode!=0;quiet(result);assert case['cipher'].read_bytes()==before + + +@pytest.mark.parametrize('defect',['public','symlink','hardlink','git','missing']) +def test_edit_refuses_unsafe_input_without_changing_cipher(case,defect): + file=case['input'];before=case['cipher'].read_bytes() + if defect=='public':file.chmod(0o644) + elif defect=='missing':file.unlink() + elif defect=='symlink': + target=file.with_name('other');file.rename(target);file.symlink_to(target) + elif defect=='hardlink':os.link(file,file.with_name('alias')) + else:subprocess.run(['git','init',str(file.parent)],capture_output=True,check=True) + result=invoke(case,'edit');assert result.returncode!=0;quiet(result);assert case['cipher'].read_bytes()==before + + +@pytest.mark.parametrize('defect',['symlink','hardlink','public-write']) +def test_cipher_path_cannot_redirect_or_alias_replacement(case,defect): + file=case['cipher'];before=file.read_bytes() + if defect=='symlink': + target=file.with_name('other.json');file.rename(target);file.symlink_to(target) + elif defect=='hardlink':os.link(file,file.with_name('alias.json')) + else:file.chmod(0o666) + result=invoke(case,'rotate');assert result.returncode!=0;quiet(result);assert file.read_bytes()==before + + +def test_tracked_plaintext_refused_before_encryption(case): + file=plaintext(case) + subprocess.run(['git','init',str(case['repo'])],capture_output=True,check=True) + subprocess.run(['git','add',file.name],cwd=case['repo'],capture_output=True,check=True) + before=file.read_bytes();result=invoke(case,'encrypt',file=file) + assert result.returncode!=0;quiet(result);assert file.read_bytes()==before + + +@pytest.mark.parametrize('action', ['edit', 'rotate']) +def test_tampered_cipher_cannot_be_replaced(case, action): + data=json.loads(case['cipher'].read_text());data['credential']='tampered';case['cipher'].write_text(json.dumps(data));before=case['cipher'].read_bytes() + result=invoke(case,action);assert result.returncode!=0;quiet(result);assert case['cipher'].read_bytes()==before + + +def test_encryption_does_not_run_ambient_key_command(case): + path=plaintext(case);marker=case['tmp']/'key-command';command=case['tmp']/'command' + command.write_text('#!/bin/sh\ntouch '+str(marker)+'\n');command.chmod(0o700) + env={**case['env'],'SOPS_AGE_KEY_CMD':str(command),'SOPS_KMS_ARN':'unadmitted','SOPS_CONFIG':str(case['repo']/'.sops.yaml')} + result=invoke(case,'encrypt',file=path,env=env);assert result.returncode==0,result.stderr;quiet(result);assert not marker.exists() + + +def test_malformed_plaintext_is_not_echoed_or_replaced(case): + path=plaintext(case);path.write_text('invalid '+PLAIN);before=path.read_bytes() + result=invoke(case,'encrypt',file=path);assert result.returncode!=0;quiet(result);assert path.read_bytes()==before + + +def test_make_input_is_literal(case): + replacement=case['input'].with_name('$(shell touch MAKE_INPUT_EXECUTED).json');case['input'].rename(replacement) + result=invoke(case,'edit',input=replacement);assert result.returncode==0,result.stderr + assert not (case['repo']/'MAKE_INPUT_EXECUTED').exists() + assert decode(case)=={'credential':PLAIN+'-edited'} + + +@pytest.mark.parametrize('defect',['hardlink','fifo','parent-symlink']) +def test_custody_key_alias_or_nonregular_input_is_refused(case,defect): + key=case['key'];env=dict(case['env']);before=case['cipher'].read_bytes() + if defect=='hardlink':os.link(key,key.with_name('alias.key')) + elif defect=='fifo': + key.unlink();os.mkfifo(key,0o600) + else: + parent=case['tmp']/'key-directory';parent.mkdir(mode=0o700);key.rename(parent/'key');alias=case['tmp']/'alias';alias.symlink_to(parent);env['NETKINGDOM_CUSTODY_AGE_KEY_FILE']=str(alias/'key') + result=invoke(case,'rotate',env=env);assert result.returncode!=0;quiet(result) + assert case['cipher'].read_bytes()==before + + +def test_edit_preserves_declared_encryption_policy(case): + raw=json.dumps({'credential':PLAIN,'visible_unencrypted':'public-label'}).encode() + encrypted=subprocess.run([SOPS,'--encrypt','--age',case['public'],'--input-type','json','--output-type','json','/dev/stdin'],input=raw,cwd=case['tmp'],env=case['env'],capture_output=True,check=True) + case['cipher'].write_bytes(encrypted.stdout) + case['input'].write_text(json.dumps({'credential':PLAIN+'-edited','visible_unencrypted':'public-label'})) + result=invoke(case,'edit');assert result.returncode==0,result.stderr + obj=json.loads(case['cipher'].read_text()) + assert obj['visible_unencrypted']=='public-label' and obj['credential'].startswith('ENC[') + assert decode(case)=={'credential':PLAIN+'-edited','visible_unencrypted':'public-label'} + + +def test_child_diagnostics_and_plaintext_output_are_suppressed(case): + directory=case['tmp']/'bin';directory.mkdir();binary=directory/'sops' + binary.write_text('#!/bin/sh\nprintf "'+PLAIN+'\\n"\nprintf "'+PLAIN+'\\n" >&2\nexit 1\n');binary.chmod(0o700) + file=plaintext(case);before=file.read_bytes() + result=invoke(case,'encrypt',file=file,env={**case['env'],'PATH':str(directory)+':'+case['env']['PATH']}) + assert result.returncode!=0;quiet(result);assert file.read_bytes()==before + assert not list(file.parent.glob('.netkingdom-sops-write-*')) + + +def test_source_change_during_encryption_is_not_overwritten(case): + file=plaintext(case);directory=case['tmp']/'bin';directory.mkdir();binary=directory/'sops' + binary.write_text('#!/usr/bin/python3\nimport subprocess,sys\nfrom pathlib import Path\nr=subprocess.run('+repr([SOPS])+ '+sys.argv[1:],input=sys.stdin.buffer.read(),capture_output=True)\nPath('+repr(str(file))+').write_text("concurrent-update")\nsys.stdout.buffer.write(r.stdout)\nsys.exit(r.returncode)\n');binary.chmod(0o700) + result=invoke(case,'encrypt',file=file,env={**case['env'],'PATH':str(directory)+':'+case['env']['PATH']}) + assert result.returncode!=0;quiet(result);assert file.read_text()=='concurrent-update' + assert not list(file.parent.glob('.netkingdom-sops-write-*')) + + +def test_make_custody_command_cannot_execute_before_handoff(case): + helper=case['repo']/'sso-mfa/bootstrap/sops-custody-unlock.sh' + helper.write_text('#!/bin/bash\npython3 - "$@" <<\'PYTHON\'\nimport json,sys\nfrom pathlib import Path\nPath("handoff.json").write_text(json.dumps(sys.argv[1:]))\nPYTHON\n') + command="printf 'safe'; touch OUTSIDE_CUSTODY; #" + result=subprocess.run(['make','-s','sops-custody-run','COMMAND='+command],cwd=case['repo'],env=case['env'],capture_output=True,text=True,timeout=10) + assert result.returncode==0,result.stderr + assert not (case['repo']/'OUTSIDE_CUSTODY').exists() + assert json.loads((case['repo']/'handoff.json').read_text())[-3:]==['bash','-lc',command] + + +def test_yaml_encrypt_rotate_edit_roundtrip(case): + import yaml + file = case['repo'] / 'plain.yaml' + file.write_text('credential: ' + PLAIN + '\n'); file.chmod(0o600) + replacement = case['input'].with_suffix('.yaml') + replacement.write_text('credential: ' + PLAIN + '-edited\n'); replacement.chmod(0o600) + for action in ('encrypt', 'rotate', 'edit'): + result = invoke(case, action, file=file, input=replacement) + assert result.returncode == 0, result.stderr + quiet(result) + decrypted = subprocess.run([SOPS, '--decrypt', str(file)], env=case['env'], capture_output=True, check=True) + assert yaml.safe_load(decrypted.stdout) == {'credential': PLAIN + ('-edited' if action == 'edit' else '')} + + +def test_make_decrypt_output_is_literal(case): + output = case['input'].with_name('$(shell touch MAKE_OUTPUT_EXECUTED).json') + result = subprocess.run(['make', '-s', 'sops-decrypt', 'FILE=' + str(case['cipher']), 'OUTPUT=' + str(output)], + cwd=case['repo'], env=case['env'], capture_output=True, text=True, timeout=15) + assert result.returncode == 0, result.stderr + quiet(result) + assert not (case['repo'] / 'MAKE_OUTPUT_EXECUTED').exists() + assert json.loads(output.read_text()) == {'credential': PLAIN} + + +def test_make_custody_values_are_not_evaluated(case): + helper = case['repo'] / 'sso-mfa/bootstrap/sops-custody-unlock.sh' + helper.write_text('#!/bin/bash\nexec python3 -c \'import json,sys; from pathlib import Path; Path("handoff.json").write_text(json.dumps(sys.argv[1:]))\' "$@"\n') + command = '$(shell touch MAKE_COMMAND_EXECUTED)' + recipient = '$(shell touch MAKE_RECIPIENT_EXECUTED)' + result = subprocess.run(['make', '-s', 'sops-custody-run', 'COMMAND=' + command, 'OPERATOR_AGE_PUBKEY=' + recipient], + cwd=case['repo'], env=case['env'], capture_output=True, text=True, timeout=15) + assert result.returncode == 0, result.stderr + assert not (case['repo'] / 'MAKE_COMMAND_EXECUTED').exists() + assert not (case['repo'] / 'MAKE_RECIPIENT_EXECUTED').exists() + assert json.loads((case['repo'] / 'handoff.json').read_text()) == ['--expected-recipient', recipient, '--', 'bash', '-lc', command] 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 387cb37..c2c4fc4 100644 --- a/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md +++ b/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md @@ -85,7 +85,7 @@ Concrete uncovered callers remain (T02 stays progress): legacy replacement paths now refuse before mutation; this is not rotation closure. - PostgreSQL/privacyIDEA/LLDAP/Authelia generators and the batch apply caller are repaired below. Other diagnostic/bootstrap/key-export callers and direct - SOPS edit/encrypt/rotate remain; native effect/recovery evidence is mandatory. + SOPS edit/encrypt/rotate are repaired below; native effect/recovery evidence is 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 @@ -182,6 +182,44 @@ No live Secret, signing key, credential or deployment was changed. T02 remains progress for the explicit callers/lifecycle work above; T03 remains waiting for its owner-scoped native recovery ceremony. +2026-10-13 SOPS write caller repair: Make passes paths and custody arguments +literally, removing shell/Make execution before handoff. Public-only encryption +refuses tracked plaintext. Edit consumes private replacement JSON/YAML outside +Git and verifies the original MAC; rotate changes the data key only. Both require +explicit matching custody and exactly the independently registered recipient, +refusing metadata-injected recipients. Child diagnostics/plaintext output are +suppressed, policy is preserved, and ciphertext publication checks source version +before atomic replacement. Shared private-key validation rejects links/FIFOs. +A stat/rename race against noncooperating same-UID writers remains; no OS isolation +or live recipient migration is claimed. Validation and exact source binding: +Custodian `docs/evidence/2026-10-13-netkingdom-security/sops-write-callers.json`. + +Remaining closure checklist (source review, not proof of current live use): +- `privacyidea/enckey-bootstrap.sh` is still called by `creds-bootstrap-agent.sh` + and documented by `creds-apply.sh` and the privacyIDEA README. It exports keys to + ordinary overwriteable paths and uses client-side Secret apply. Repair private + no-overwrite export, existing-key preservation and conditional DR Secret writes; + verify the actual backup/restore contract before recovery acceptance. +- `privacyidea/check-user-mfa-state.sh`, linked by the smooth-bootstrap guide, + still reads retired `keycape-pi-token`, disables TLS verification, puts its token + in curl argv and prints provider error text. Reconcile with the current scoped + factor-reader owner contract and a bounded diagnostic interface. +- `lldap/bootstrap-users.sh` still sources credentials, sends password/token in + argv and can print auth responses. Inspect its group/user provisioning callers + against the current User Engine owner path before replacing or retiring them. + Targeted source findings also include `manage-group-members.sh`, `create-user.sh`, + `break-glass.sh`, `dry-run-nonroot-user.sh` and `verify-t07.sh`; their caller and + lifecycle review remains incomplete. +- `privacyidea/repair-realm-live.sh` writes shell-escaped credentials for + `bootstrap-realm.sh`, which still sources them. Reconcile both ends together; + a private temporary directory alone does not establish the transport contract. +- Reconcile the complete `docs/attended-procedure-inventory.md` against current + scripts and Make/bootstrap/root-output callers. The selected findings above + are not an exhaustive closure audit. Retain historical revocation and + configuration-preserving rotation requirements already recorded here. +- After source coverage, execute T03's exact owner-approved off-host recovery and + current-consumer checks. Source regressions do not satisfy that gate. + ## Verify owner-approved recovery and current native callers ```task