diff --git a/services/hermes/scripts/node_account_hardening.py b/services/hermes/scripts/node_account_hardening.py index a545af47..29af19a8 100644 --- a/services/hermes/scripts/node_account_hardening.py +++ b/services/hermes/scripts/node_account_hardening.py @@ -22,6 +22,7 @@ from node_account_io import ( assert_unchanged, atomic_write, backup_once, + read_mounted_secret, read_regular, ) @@ -214,7 +215,8 @@ def _key_identity(line: bytes) -> tuple[str, bytes] | None: def _validated_public_key(path: Path) -> tuple[bytes, tuple[str, bytes]]: - value, _ = _read_regular(path, 16 * 1024) + # Vault CSI projects the key as a symlink chain inside its own mount. + value = read_mounted_secret(path, path.parent, 16 * 1024).value line = value.strip() if b"\n" in line or b"\r" in line: raise HardeningError("Hermes public key must contain one line") diff --git a/services/hermes/scripts/node_account_io.py b/services/hermes/scripts/node_account_io.py index 4fb2ae9e..6421e648 100644 --- a/services/hermes/scripts/node_account_io.py +++ b/services/hermes/scripts/node_account_io.py @@ -113,6 +113,27 @@ def read_regular(path: Path, maximum: int) -> FileSnapshot: ) +def read_mounted_secret(path: Path, mount_root: Path, maximum: int) -> FileSnapshot: + """Read one bounded secret projected through mount-internal symlinks. + + Kubernetes CSI and projected volumes present each file as a symlink into + a hidden ``..data`` directory, which the O_NOFOLLOW read refuses. Resolve + the full chain first, refuse any resolution that loops or escapes the + read-only mount, then apply the unchanged hardened read to the resolved + regular file. + """ + try: + resolved = Path(os.path.realpath(path, strict=True)) + root = Path(os.path.realpath(mount_root, strict=True)) + except FileNotFoundError: + raise + except OSError as exc: + raise HardeningError(f"unresolvable mounted secret: {path.name}") from exc + if root not in resolved.parents: + raise HardeningError(f"mounted secret escapes its mount: {path.name}") + return read_regular(resolved, maximum) + + def assert_unchanged(path: Path, expected: FileSnapshot, maximum: int) -> None: """Fail if an account file changed since planning began.""" current = read_regular(path, maximum) diff --git a/testing/tests/test_hermes_node_account_io.py b/testing/tests/test_hermes_node_account_io.py index 6b05cc0a..674897e4 100644 --- a/testing/tests/test_hermes_node_account_io.py +++ b/testing/tests/test_hermes_node_account_io.py @@ -99,6 +99,21 @@ def test_preexisting_target_key_is_removed_before_legacy_cleanup_and_reinstalled assert target.read_text() == key + "\n" +def test_reconcile_reads_csi_projected_public_key(tmp_path: Path, monkeypatch): + module, _originals, key, _other, public_key = _fixture(tmp_path, monkeypatch) + data = public_key.parent / "..2026_08_18.1" + data.mkdir() + (data / public_key.name).write_text(key + "\n", encoding="utf-8") + (public_key.parent / "..data").symlink_to(data.name) + public_key.unlink() + public_key.symlink_to(Path("..data") / public_key.name) + + module.reconcile(public_key) + + target = module.HOST_HOME / module.ACCOUNT / ".ssh/authorized_keys" + assert target.read_text() == key + "\n" + + def test_account_database_write_preserves_mode_owner_and_xattrs( tmp_path: Path, monkeypatch ): diff --git a/testing/tests/test_hermes_node_io_coverage.py b/testing/tests/test_hermes_node_io_coverage.py index e0c7a4b9..23131927 100644 --- a/testing/tests/test_hermes_node_io_coverage.py +++ b/testing/tests/test_hermes_node_io_coverage.py @@ -106,6 +106,66 @@ def test_read_regular_rejects_directory_oversize_and_changed_read( module.read_regular(source, 10) +def test_read_mounted_secret_follows_csi_projection_inside_mount(tmp_path): + module = _io_module() + mount = tmp_path / "vault-secrets" + data = mount / "..2026_08_18.1" + data.mkdir(parents=True) + (data / "node-ssh-public-key").write_bytes(b"ssh-ed25519 synthetic\n") + (mount / "..data").symlink_to(data.name) + (mount / "node-ssh-public-key").symlink_to(Path("..data") / "node-ssh-public-key") + snapshot = module.read_mounted_secret(mount / "node-ssh-public-key", mount, 64) + assert snapshot.value == b"ssh-ed25519 synthetic\n" + + +def test_read_mounted_secret_reads_plain_regular_file_unchanged(tmp_path): + module = _io_module() + mount = tmp_path / "vault-secrets" + mount.mkdir() + secret = mount / "node-ssh-public-key" + secret.write_bytes(b"ssh-ed25519 synthetic\n") + snapshot = module.read_mounted_secret(secret, mount, 64) + assert snapshot == module.read_regular(secret, 64) + + +def test_read_mounted_secret_refuses_escape_from_mount(tmp_path): + module = _io_module() + mount = tmp_path / "vault-secrets" + mount.mkdir() + outside = tmp_path / "outside" + outside.write_bytes(b"planted\n") + (mount / "node-ssh-public-key").symlink_to(outside) + with pytest.raises(module.HardeningError, match="escapes its mount"): + module.read_mounted_secret(mount / "node-ssh-public-key", mount, 64) + + +def test_read_mounted_secret_refuses_symlink_loop(tmp_path): + module = _io_module() + mount = tmp_path / "vault-secrets" + mount.mkdir() + (mount / "node-ssh-public-key").symlink_to("node-ssh-public-key") + with pytest.raises(module.HardeningError, match="unresolvable mounted secret"): + module.read_mounted_secret(mount / "node-ssh-public-key", mount, 64) + + +def test_read_mounted_secret_reports_missing_secret_as_not_found(tmp_path): + module = _io_module() + mount = tmp_path / "vault-secrets" + mount.mkdir() + with pytest.raises(FileNotFoundError): + module.read_mounted_secret(mount / "node-ssh-public-key", mount, 64) + + +def test_read_mounted_secret_refuses_non_regular_resolved_target(tmp_path): + module = _io_module() + mount = tmp_path / "vault-secrets" + data = mount / "..data" + data.mkdir(parents=True) + (mount / "node-ssh-public-key").symlink_to("..data") + with pytest.raises(module.HardeningError, match="unsafe regular"): + module.read_mounted_secret(mount / "node-ssh-public-key", mount, 64) + + def test_assert_unchanged_rejects_changed_snapshot(tmp_path): module = _io_module() source = tmp_path / "passwd"