fix: decrypt SOPS through explicit custody into private output
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a12d13-27f2-7163-b8c0-f682af02772c
This commit is contained in:
parent
4cf26a9bc8
commit
681c5b0ea1
6 changed files with 499 additions and 27 deletions
|
|
@ -7,7 +7,8 @@
|
|||
#
|
||||
# To edit an encrypted file: sops secrets/<file>
|
||||
# To encrypt a new file: sops --encrypt --in-place secrets/<file>
|
||||
# To decrypt to stdout (inspect): sops -d secrets/<file>
|
||||
# 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>
|
||||
|
||||
creation_rules:
|
||||
|
|
|
|||
28
Makefile
28
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
201
sso-mfa/bootstrap/sops-private.py
Normal file
201
sso-mfa/bootstrap/sops-private.py
Normal file
|
|
@ -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())
|
||||
227
tests/test_sops_private.py
Normal file
227
tests/test_sops_private.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue