aboutsummaryrefslogtreecommitdiff
path: root/tests/cmail/test_cmail_setup.py
blob: 3330ee6813a2802b655b49bd9a2238c34a3a6098 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
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()