fix(security): constrain SOPS writes and custody argument handoff
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a1346b-72a4-7d40-b8bd-8b8ca346cfa2
This commit is contained in:
tegwick 2026-10-13 21:03:24 +02:00
parent b01a30bd49
commit 8f808577d6
8 changed files with 573 additions and 44 deletions

View file

@ -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/<file>
# To encrypt a new file: sops --encrypt --in-place secrets/<file>
# To replace encrypted content: make sops-edit FILE=secrets/<file>.yaml INPUT=<private-directory>/<replacement>.yaml
# To encrypt a private untracked file: make sops-encrypt FILE=secrets/<file>.yaml
# Attended private recovery: make sops-decrypt FILE=secrets/<file>.yaml OUTPUT=<private-directory>/<new-file>
# Run through the custody unlock helper; never decrypt into a transcript.
# To add a recipient: update .sops.yaml + sops --rotate --in-place secrets/<file>
# To rotate the data key only: make sops-rotate FILE=secrets/<file>.yaml
# Recipient changes require a separately reviewed custody migration; rotate does not apply this config.
creation_rules:
- path_regex: secrets/.*$

View file

@ -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='<approved 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 <file>"; \
echo "ERROR: Unencrypted file detected. For private untracked input: make sops-encrypt FILE=<file>; tracked plaintext requires incident review."; \
exit 1; \
fi; \
echo "✔ All secrets/ files appear SOPS-encrypted"

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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())

244
tests/test_sops_write.py Normal file
View file

@ -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]

View file

@ -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