diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-20 12:44:01 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-20 12:44:01 -0500 |
| commit | dffecf528cd20af2bd4437df567896ce03cabe2f (patch) | |
| tree | 8c9ef1c82bc3bad726e33b1b58996bdfdc44c4ea /tests/cmail/test_cmail_setup.py | |
| parent | 1115543736997cc612d31109cf83391a744051c4 (diff) | |
| download | archsetup-dffecf528cd20af2bd4437df567896ce03cabe2f.tar.gz archsetup-dffecf528cd20af2bd4437df567896ce03cabe2f.zip | |
fix(security): decrypt the cmail password under a tight umask
gpg writes its output at the process umask (often 0644), so the plaintext mail password was world-readable between the decrypt and the chmod. I extracted decrypt_to_secure, which runs the decrypt in a 0077 umask subshell so the file is 0600 from creation. A fake-gpg test pins that the write runs under the tight umask.
Diffstat (limited to 'tests/cmail/test_cmail_setup.py')
| -rw-r--r-- | tests/cmail/test_cmail_setup.py | 79 |
1 files changed, 79 insertions, 0 deletions
diff --git a/tests/cmail/test_cmail_setup.py b/tests/cmail/test_cmail_setup.py new file mode 100644 index 0000000..3330ee6 --- /dev/null +++ b/tests/cmail/test_cmail_setup.py @@ -0,0 +1,79 @@ +"""Test that cmail-setup-finish decrypts the mail password without a +world-readable window. + +gpg creates its --output file at the process umask (typically 0644), so a plain +`gpg --decrypt --output f` followed by `chmod 600 f` leaves the plaintext mail +password world-readable in the gap between the two. decrypt_to_secure wraps the +decrypt in a 0077 umask subshell so the file is 0600 from creation. + +Method: sed-extract decrypt_to_secure from scripts/cmail-setup-finish.sh, run it +with a fake gpg that (a) records the effective umask at call time and (b) writes +the --output file the way real gpg would (respecting the umask). Assert the umask +was tight during the write, and that the resulting file is 0600. + +Run from repo root: + python3 -m unittest tests.cmail.test_cmail_setup +""" + +import os +import stat +import subprocess +import tempfile +import textwrap +import unittest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +SCRIPT = os.path.join(REPO_ROOT, "scripts", "cmail-setup-finish.sh") + + +def run_decrypt(): + """Run decrypt_to_secure with a fake gpg. Returns (plain_mode, umask_str).""" + tmp = tempfile.mkdtemp() + enc = os.path.join(tmp, "in.gpg") + plain = os.path.join(tmp, "cmailpass") + umask_rec = os.path.join(tmp, "umask") + with open(enc, "w") as fh: + fh.write("ciphertext") + script = textwrap.dedent(f"""\ + UMASK_REC={umask_rec!r} + gpg() {{ + umask > "$UMASK_REC" + local out="" + while [ $# -gt 0 ]; do + case "$1" in --output) out="$2"; shift;; esac + shift + done + printf 'SECRET' > "$out" + }} + source <(sed -n '/^decrypt_to_secure() {{/,/^}}/p' {SCRIPT!r}) + decrypt_to_secure {enc!r} {plain!r} + """) + subprocess.run(["bash", "-c", script], check=True, + capture_output=True, text=True, timeout=10) + mode = stat.S_IMODE(os.stat(plain).st_mode) + with open(umask_rec) as fh: + umask_str = fh.read().strip() + return mode, umask_str + + +class CmailDecryptPermissions(unittest.TestCase): + def test_decrypt_runs_under_tight_umask(self): + # The security property: gpg writes the plaintext under a 0077 umask, so + # it is 0600 from creation, not world-readable before the chmod. + _mode, umask_str = run_decrypt() + self.assertEqual( + int(umask_str, 8), 0o077, + "the mail-password decrypt must run under a 0077 umask so the " + "plaintext is never world-readable", + ) + + def test_plaintext_is_owner_only(self): + mode, _umask = run_decrypt() + self.assertEqual(mode, 0o600, + f"cmailpass ended at {oct(mode)}, expected 0600") + self.assertFalse(mode & 0o077, + "cmailpass must not be group- or world-accessible") + + +if __name__ == "__main__": + unittest.main() |
