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()
|