aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-20 12:44:01 -0500
committerCraig Jennings <c@cjennings.net>2026-07-20 12:44:01 -0500
commitdffecf528cd20af2bd4437df567896ce03cabe2f (patch)
tree8c9ef1c82bc3bad726e33b1b58996bdfdc44c4ea /tests
parent1115543736997cc612d31109cf83391a744051c4 (diff)
downloadarchsetup-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')
-rw-r--r--tests/cmail/test_cmail_setup.py79
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()