Bound credential input reads and verify registry lock recovery

This commit is contained in:
FrameBridge compatibility build committed 2026-10-27 11:10:28 -04:00
1 parent 12a03a1f97
commit bf79a79342
4 files changed
+80 -3

No files matched your search

+3 -1
View File
@@ -25,7 +25,9 @@ owner/profile-specific commands tagged `NOLOG_INPUT`, `NOLOG_OUTPUT` and
`NOSETENV`. The frontend then queries the effective policy for the exact command `NOSETENV`. The frontend then queries the effective policy for the exact command
using command-specific `sudo -n -ll` in the C locale. It requires one effective using command-specific `sudo -n -ll` in the C locale. It requires one effective
matching root rule, the exact command/arguments and explicit disabled I/O logging matching root rule, the exact command/arguments and explicit disabled I/O logging
and environment overrides before sending any key. Generic Defaults and absence and environment overrides before sending any key. Helper input is limited to
16384 bytes plus EOF with a five-second absolute deadline, so an open input pipe
cannot hold the shared registry lock indefinitely. Generic Defaults and absence
of logging flags are not accepted as evidence. Unknown formats fail closed. of logging flags are not accepted as evidence. Unknown formats fail closed.
On systemd 255, setup writes host-encrypted ciphertext and principal metadata under On systemd 255, setup writes host-encrypted ciphertext and principal metadata under
@@ -17,9 +17,11 @@ import pwd
import re import re
import resource import resource
import secrets import secrets
import select
import stat import stat
import subprocess import subprocess
import sys import sys
import time
ROOT = Path("/var/lib/framebridge-expanded/credentials") ROOT = Path("/var/lib/framebridge-expanded/credentials")
SUDOERS = Path("/etc/sudoers.d") SUDOERS = Path("/etc/sudoers.d")
@@ -32,6 +34,7 @@ USER = re.compile(r"[A-Za-z_][A-Za-z0-9_-]{0,63}\$?\Z")
ENV = {"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C", "LC_ALL": "C"} ENV = {"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C", "LC_ALL": "C"}
MAX_KEY = 16384 MAX_KEY = 16384
MAX_CIPHER = 131072 MAX_CIPHER = 131072
INPUT_TIMEOUT = 5
def _fail(): def _fail():
@@ -100,6 +103,24 @@ def _sync(directory):
os.close(fd) os.close(fd)
def _read_key():
# The frontend has already collected the key before launching this helper.
# A caller holding stdin open must not retain the shared registry lock.
deadline = time.monotonic() + INPUT_TIMEOUT
fd = sys.stdin.fileno()
value = bytearray()
while True:
remaining = deadline - time.monotonic()
if remaining <= 0 or not select.select([fd], [], [], remaining)[0]:
_fail()
chunk = os.read(fd, MAX_KEY + 1 - len(value))
if not chunk:
return bytes(value)
value.extend(chunk)
if len(value) > MAX_KEY:
_fail()
def _prepare_root(): def _prepare_root():
# Only this fixed root-owned tree is created. No profile or HOME is opened. # Only this fixed root-owned tree is created. No profile or HOME is opened.
for directory in (ROOT.parent, ROOT): for directory in (ROOT.parent, ROOT):
@@ -453,7 +474,7 @@ def main(argv=None):
# sudo already selected this exact NOLOG policy. Consume its # sudo already selected this exact NOLOG policy. Consume its
# encryption permission before accepting any key bytes. # encryption permission before accepting any key bytes.
unauthorize(owner, args[1], "host", required=True) unauthorize(owner, args[1], "host", required=True)
key = sys.stdin.buffer.read(MAX_KEY + 1) key = _read_key()
result = protect(owner, args[1], key) result = protect(owner, args[1], key)
key = b"" key = b""
elif operation == "decrypt": elif operation == "decrypt":
+9 -1
View File
@@ -216,12 +216,20 @@ class CredentialHelperTests(unittest.TestCase):
def test_broken_receipt_pipe_rolls_back_published_protect_grant(self): def test_broken_receipt_pipe_rolls_back_published_protect_grant(self):
helper.authorize(self.owner, PROFILE, "host") helper.authorize(self.owner, PROFILE, "host")
stdout = mock.Mock(); stdout.buffer.write.side_effect = BrokenPipeError("synthetic closed pipe") stdout = mock.Mock(); stdout.buffer.write.side_effect = BrokenPipeError("synthetic closed pipe")
with mock.patch.object(helper, "_installation"), mock.patch.object(helper, "_caller", return_value=self.owner), mock.patch.object(helper, "_prepare_root"), mock.patch.object(helper.resource, "setrlimit"), mock.patch.object(helper.os, "umask"), mock.patch("sys.stdin", SimpleNamespace(buffer=io.BytesIO(KEY))), mock.patch("sys.stdout", stdout), mock.patch("sys.stderr", new_callable=io.StringIO) as stderr: with mock.patch.object(helper, "_installation"), mock.patch.object(helper, "_caller", return_value=self.owner), mock.patch.object(helper, "_prepare_root"), mock.patch.object(helper, "_read_key", return_value=KEY), mock.patch.object(helper.resource, "setrlimit"), mock.patch.object(helper.os, "umask"), mock.patch("sys.stdout", stdout), mock.patch("sys.stderr", new_callable=io.StringIO) as stderr:
self.assertEqual(1, helper.main(["protect", PROFILE])) self.assertEqual(1, helper.main(["protect", PROFILE]))
self.assertNotIn(KEY.decode(), stderr.getvalue()) self.assertNotIn(KEY.decode(), stderr.getvalue())
self.assertEqual([".lock"], [p.name for p in self.registry.iterdir()]) self.assertEqual([".lock"], [p.name for p in self.registry.iterdir()])
self.assertEqual([], list(self.policies.iterdir())) self.assertEqual([], list(self.policies.iterdir()))
def test_open_stdin_has_a_deadline_and_excess_input_is_bounded(self):
with mock.patch("sys.stdin", SimpleNamespace(fileno=lambda: 17)), mock.patch.object(helper.select, "select", return_value=([], [], [])), mock.patch.object(helper.os, "read") as read:
with self.assertRaises(RuntimeError): helper._read_key()
read.assert_not_called()
with mock.patch("sys.stdin", SimpleNamespace(fileno=lambda: 17)), mock.patch.object(helper.select, "select", return_value=([17], [], [])), mock.patch.object(helper.os, "read", return_value=b"x" * (helper.MAX_KEY + 1)) as read:
with self.assertRaises(RuntimeError): helper._read_key()
read.assert_called_once_with(17, helper.MAX_KEY + 1)
def test_ids_reject_paths_flags_and_extra_arguments(self): def test_ids_reject_paths_flags_and_extra_arguments(self):
for value in ("../secret", "--uid=0", "A" * 64, "a" * 63, PROFILE + "\n"): for value in ("../secret", "--uid=0", "A" * 64, "a" * 63, PROFILE + "\n"):
with self.subTest(value=value), self.assertRaises(RuntimeError): with self.subTest(value=value), self.assertRaises(RuntimeError):
@@ -27,6 +27,7 @@ spec.loader.exec_module(helper)
helper.ROOT = Path(sys.argv[2]) helper.ROOT = Path(sys.argv[2])
helper._installation = lambda: None helper._installation = lambda: None
helper._prepare_root = lambda: None helper._prepare_root = lambda: None
helper._cleanup_temporaries = lambda: None
helper._caller = lambda: SimpleNamespace(pw_uid=12345, pw_name="synthetic", pw_dir="/synthetic-only") helper._caller = lambda: SimpleNamespace(pw_uid=12345, pw_name="synthetic", pw_dir="/synthetic-only")
helper.decrypt = lambda *args: b"X" * 16384 helper.decrypt = lambda *args: b"X" * 16384
# The only file is our own temporary lock; simulate its expected root metadata. # The only file is our own temporary lock; simulate its expected root metadata.
@@ -37,7 +38,52 @@ raise SystemExit(helper.main(["decrypt", "a" * 64, "b" * 64]))
""" """
PROTECT_CHILD = CHILD.replace(
'raise SystemExit(helper.main(["decrypt", "a" * 64, "b" * 64]))',
'\n'.join([
'helper.INPUT_TIMEOUT = 0.1',
'helper.unauthorize = lambda *args, **kwargs: None',
'helper.protect = lambda *args: b"SYNTHETIC-CIPHER-ONLY" * 8',
'raise SystemExit(helper.main(["protect", "a" * 64]))',
]),
)
class CredentialHelperPipeTests(unittest.TestCase): class CredentialHelperPipeTests(unittest.TestCase):
def test_open_stdin_without_eof_times_out_and_releases_registry_lock(self):
with tempfile.TemporaryDirectory(prefix="framebridge-input-canary-") as directory:
child = None
lock_fd = None
try:
started = time.monotonic()
child = subprocess.Popen([sys.executable, "-I", "-S", "-c", PROTECT_CHILD,
str(Path(credential_helper.__file__).resolve()), directory],
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, close_fds=True)
self.assertEqual(1, child.wait(timeout=1), "Protect must reject an input stream without EOF")
self.assertLess(time.monotonic() - started, 1, "Synthetic input timeout exceeded its bound")
# Do not close stdin or communicate(): both would supply EOF and
# could hide an unbounded read or a leaked registry lock.
self.assertFalse(child.stdin.closed)
lock_fd = os.open(Path(directory) / ".lock", os.O_RDWR)
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
self.fail("Protect retained the registry lock after its input deadline")
finally:
fcntl.flock(lock_fd, fcntl.LOCK_UN)
self.assertEqual(b"", child.stdout.read())
self.assertIn(b"protected credential operation failed", child.stderr.read())
finally:
if child is not None:
if child.poll() is None:
child.kill()
child.wait(timeout=3)
for stream in (child.stdin, child.stdout, child.stderr):
stream.close()
if lock_fd is not None:
os.close(lock_fd)
@unittest.skipUnless(hasattr(fcntl, "F_SETPIPE_SZ"), "Linux pipe-size controls required") @unittest.skipUnless(hasattr(fcntl, "F_SETPIPE_SZ"), "Linux pipe-size controls required")
def test_stalled_stdout_reader_does_not_hold_global_registry_lock(self): def test_stalled_stdout_reader_does_not_hold_global_registry_lock(self):
with tempfile.TemporaryDirectory(prefix="framebridge-pipe-canary-") as directory: with tempfile.TemporaryDirectory(prefix="framebridge-pipe-canary-") as directory: