diff options
| -rwxr-xr-x | scripts/cmail-setup-finish.sh | 12 | ||||
| -rw-r--r-- | tests/cmail/test_cmail_setup.py | 79 |
2 files changed, 89 insertions, 2 deletions
diff --git a/scripts/cmail-setup-finish.sh b/scripts/cmail-setup-finish.sh index 7f9d3fc..949023f 100755 --- a/scripts/cmail-setup-finish.sh +++ b/scripts/cmail-setup-finish.sh @@ -30,6 +30,15 @@ err() { printf 'error: %s\n' "$*" >&2; exit 1; } info() { printf '==> %s\n' "$*"; } ok() { printf ' %s\n' "$*"; } +# Decrypt $1 to $2 with 0600 from the moment of creation. gpg writes its output +# at the process umask (often 0644), so a bare decrypt leaves the plaintext +# world-readable until the chmod on the next line. The 0077 umask subshell closes +# that window; the chmod stays to tighten a looser file left by an earlier run. +decrypt_to_secure() { + ( umask 077; gpg --quiet --yes --decrypt --output "$2" "$1" ) + chmod 600 "$2" +} + # 1. Pre-reqs command -v protonmail-bridge >/dev/null 2>&1 \ || err "protonmail-bridge not found in PATH — install via archsetup first" @@ -49,8 +58,7 @@ cmailpass_enc="$HOME/.config/.cmailpass.gpg" # 2. Decrypt cmailpass info "decrypting $cmailpass_enc" cmailpass_plain="$HOME/.config/.cmailpass" -gpg --quiet --yes --decrypt --output "$cmailpass_plain" "$cmailpass_enc" -chmod 600 "$cmailpass_plain" +decrypt_to_secure "$cmailpass_enc" "$cmailpass_plain" ok "wrote $cmailpass_plain (mode 0600)" # 3. Bridge cert 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() |
