test_security_key_permissions.py
python
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
26 days ago
| 1 | """Security tests — key file permission and ownership check before loading. |
| 2 | |
| 3 | Verifies that _load_private_key_from_path() refuses keys with overly-permissive |
| 4 | file modes or foreign ownership, and logs the appropriate warning with the fix |
| 5 | command. |
| 6 | """ |
| 7 | |
| 8 | from __future__ import annotations |
| 9 | |
| 10 | import os |
| 11 | import pathlib |
| 12 | import stat |
| 13 | from unittest.mock import patch |
| 14 | |
| 15 | import pytest |
| 16 | from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey |
| 17 | from cryptography.hazmat.primitives.serialization import ( |
| 18 | Encoding, |
| 19 | NoEncryption, |
| 20 | PrivateFormat, |
| 21 | ) |
| 22 | |
| 23 | from muse.core.identity import _load_private_key_from_path |
| 24 | |
| 25 | |
| 26 | # --------------------------------------------------------------------------- |
| 27 | # Helpers |
| 28 | # --------------------------------------------------------------------------- |
| 29 | |
| 30 | def _generate_pem_key() -> bytes: |
| 31 | """Generate a fresh Ed25519 private key and return PEM bytes.""" |
| 32 | private_key = Ed25519PrivateKey.generate() |
| 33 | return private_key.private_bytes( |
| 34 | encoding=Encoding.PEM, |
| 35 | format=PrivateFormat.PKCS8, |
| 36 | encryption_algorithm=NoEncryption(), |
| 37 | ) |
| 38 | |
| 39 | |
| 40 | def _write_key(path: pathlib.Path, mode: int) -> None: |
| 41 | """Write a valid PEM key to *path* with the given file mode.""" |
| 42 | path.write_bytes(_generate_pem_key()) |
| 43 | os.chmod(path, mode) |
| 44 | |
| 45 | |
| 46 | # --------------------------------------------------------------------------- |
| 47 | # Tests |
| 48 | # --------------------------------------------------------------------------- |
| 49 | |
| 50 | |
| 51 | def test_key_0600_loads(tmp_path: pathlib.Path) -> None: |
| 52 | """0o600 key file → loads successfully and returns a key object.""" |
| 53 | key_file = tmp_path / "key.pem" |
| 54 | _write_key(key_file, 0o600) |
| 55 | result = _load_private_key_from_path(str(key_file)) |
| 56 | assert result is not None |
| 57 | assert isinstance(result, Ed25519PrivateKey) |
| 58 | |
| 59 | |
| 60 | def test_key_0644_refused(tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture) -> None: |
| 61 | """0o644 key file → returns None and logs a warning.""" |
| 62 | import logging |
| 63 | key_file = tmp_path / "key.pem" |
| 64 | _write_key(key_file, 0o644) |
| 65 | with caplog.at_level(logging.WARNING, logger="muse.core.identity"): |
| 66 | result = _load_private_key_from_path(str(key_file)) |
| 67 | assert result is None |
| 68 | assert any("0644" in r.message or "644" in r.message for r in caplog.records) |
| 69 | |
| 70 | |
| 71 | def test_key_0640_refused(tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture) -> None: |
| 72 | """0o640 key file → returns None and logs a warning.""" |
| 73 | import logging |
| 74 | key_file = tmp_path / "key.pem" |
| 75 | _write_key(key_file, 0o640) |
| 76 | with caplog.at_level(logging.WARNING, logger="muse.core.identity"): |
| 77 | result = _load_private_key_from_path(str(key_file)) |
| 78 | assert result is None |
| 79 | assert any("0640" in r.message or "640" in r.message for r in caplog.records) |
| 80 | |
| 81 | |
| 82 | def test_key_wrong_owner_refused( |
| 83 | tmp_path: pathlib.Path, |
| 84 | caplog: pytest.LogCaptureFixture, |
| 85 | ) -> None: |
| 86 | """Key owned by different UID → returns None.""" |
| 87 | import logging |
| 88 | key_file = tmp_path / "key.pem" |
| 89 | _write_key(key_file, 0o600) |
| 90 | |
| 91 | current_uid = os.getuid() |
| 92 | other_uid = current_uid + 1 |
| 93 | |
| 94 | real_path_stat = pathlib.Path.stat |
| 95 | |
| 96 | def fake_path_stat(self: pathlib.Path, **kwargs: object) -> os.stat_result: |
| 97 | result = real_path_stat(self, **kwargs) |
| 98 | if self.resolve() == key_file.resolve(): |
| 99 | return os.stat_result(( |
| 100 | result.st_mode, result.st_ino, result.st_dev, |
| 101 | result.st_nlink, other_uid, result.st_gid, |
| 102 | result.st_size, result.st_atime, result.st_mtime, result.st_ctime, |
| 103 | )) |
| 104 | return result |
| 105 | |
| 106 | with ( |
| 107 | patch.object(pathlib.Path, "stat", fake_path_stat), |
| 108 | caplog.at_level(logging.WARNING, logger="muse.core.identity"), |
| 109 | ): |
| 110 | result = _load_private_key_from_path(str(key_file)) |
| 111 | |
| 112 | assert result is None |
| 113 | assert any( |
| 114 | "owned by" in r.message.lower() or "uid" in r.message.lower() |
| 115 | for r in caplog.records |
| 116 | ) |
| 117 | |
| 118 | |
| 119 | def test_root_skips_ownership_check( |
| 120 | tmp_path: pathlib.Path, |
| 121 | caplog: pytest.LogCaptureFixture, |
| 122 | ) -> None: |
| 123 | """uid==0 → ownership check skipped (but permissions still checked).""" |
| 124 | import logging |
| 125 | key_file = tmp_path / "key.pem" |
| 126 | _write_key(key_file, 0o600) |
| 127 | |
| 128 | current_uid = os.getuid() |
| 129 | other_uid = current_uid + 999 # clearly different owner |
| 130 | |
| 131 | real_path_stat = pathlib.Path.stat |
| 132 | |
| 133 | def fake_path_stat(self: pathlib.Path, **kwargs: object) -> os.stat_result: |
| 134 | result = real_path_stat(self, **kwargs) |
| 135 | if self.resolve() == key_file.resolve(): |
| 136 | return os.stat_result(( |
| 137 | result.st_mode, result.st_ino, result.st_dev, |
| 138 | result.st_nlink, other_uid, result.st_gid, |
| 139 | result.st_size, result.st_atime, result.st_mtime, result.st_ctime, |
| 140 | )) |
| 141 | return result |
| 142 | |
| 143 | with ( |
| 144 | patch("os.getuid", return_value=0), |
| 145 | patch.object(pathlib.Path, "stat", fake_path_stat), |
| 146 | caplog.at_level(logging.WARNING, logger="muse.core.identity"), |
| 147 | ): |
| 148 | # Root + other owner + 0600 mode → should load successfully. |
| 149 | result = _load_private_key_from_path(str(key_file)) |
| 150 | |
| 151 | assert result is not None, "Root should bypass ownership check" |
| 152 | |
| 153 | |
| 154 | def test_warning_includes_chmod_command( |
| 155 | tmp_path: pathlib.Path, |
| 156 | caplog: pytest.LogCaptureFixture, |
| 157 | ) -> None: |
| 158 | """Warning message includes 'chmod 600' fix command.""" |
| 159 | import logging |
| 160 | key_file = tmp_path / "key.pem" |
| 161 | _write_key(key_file, 0o644) |
| 162 | with caplog.at_level(logging.WARNING, logger="muse.core.identity"): |
| 163 | _load_private_key_from_path(str(key_file)) |
| 164 | assert any("chmod 600" in r.message for r in caplog.records) |
File History
2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62
baseline: rc14 re-baseline after rc3 store corruption recovery
Human
patch
26 days ago