test_secmod_key_directory.py (10210B)
1 #!/usr/bin/env python3 2 # 3 # This file is part of TALER 4 # Copyright (C) 2026 Taler Systems SA 5 # 6 # TALER is free software; you can redistribute it and/or modify it under the 7 # terms of the GNU General Public License as published by the Free Software 8 # Foundation; either version 3, or (at your option) any later version. 9 # 10 # TALER is distributed in the hope that it will be useful, but WITHOUT ANY 11 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR 12 # A PARTICULAR PURPOSE. See the GNU General Public License for more details. 13 # 14 # You should have received a copy of the GNU General Public License along with 15 # TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>. 16 17 """Check secmod key preservation across section capitalization changes.""" 18 19 import hashlib 20 import os 21 from pathlib import Path 22 import socket 23 import stat 24 import struct 25 import subprocess 26 import sys 27 import tempfile 28 import time 29 30 31 # 2026-01-01 00:30 UTC, safely between hourly key rotations. 32 HELPER_TIME_US = 1767227400000000 33 34 35 def receive(sock, size): 36 """Read a complete IPC header or payload.""" 37 result = b"" 38 while len(result) < size: 39 part = sock.recv(size - len(result)) 40 assert part, "Helper disconnected before sending its key set" 41 result += part 42 return result 43 44 45 def run_helper(binary, cipher, root, section, error=None): 46 """Start the helper, read its initial key set, and shut it down cleanly.""" 47 config = root / "secmod.conf" 48 # Recompute the clock offset for every start so the helper always runs near 49 # HELPER_TIME_US, even when restarts straddle a real UTC hour boundary. 50 config.write_text( 51 f"""[{section}] 52 CIPHER = {cipher.upper()} 53 RSA_KEYSIZE = 1024 54 DURATION_WITHDRAW = 1 hour 55 ANCHOR_ROUND = 1 hour 56 57 [taler-exchange-secmod-{cipher}] 58 KEY_DIR = {root}/Keys 59 SM_PRIV_KEY = {root}/secmod-private-key 60 UNIXPATH = {root}/server.sock 61 OVERLAP_DURATION = 0 s 62 LOOKAHEAD_SIGN = 2 hours 63 64 [testing] 65 SKEW_OFFSET = {HELPER_TIME_US} 66 SKEW_VARIANCE = {time.time_ns() // 1000} 67 """ 68 ) 69 # Supply all settings explicitly, independently of installed configuration. 70 env = dict(os.environ, TALER_BASE_CONFIG=str(root / "empty-config")) 71 with tempfile.TemporaryFile(mode="w+t") as log: 72 proc = subprocess.Popen( 73 [binary, "-c", str(config), "-w", "1", "-L", "INFO"], 74 stdout=log, 75 stderr=log, 76 env=env, 77 ) 78 try: 79 if error is not None: 80 assert proc.wait(timeout=10) == 1, "Expected migration failure" 81 log.seek(0) 82 assert error in log.read(), "Missing migration failure diagnostic" 83 return None 84 with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: 85 sock.settimeout(10) 86 deadline = time.monotonic() + 20 87 while True: 88 assert proc.poll() is None, "Helper exited during startup" 89 try: 90 sock.connect(str(root / "server.sock")) 91 break 92 except (FileNotFoundError, ConnectionRefusedError): 93 assert time.monotonic() < deadline, "Helper startup timed out" 94 time.sleep(0.02) 95 messages = set() 96 # Message types are defined in secmod_rsa.h and secmod_cs.h. 97 synced = 10 if cipher == "rsa" else 15 98 while True: 99 size, msg_type = struct.unpack("!HH", receive(sock, 4)) 100 assert size >= 4 101 payload = receive(sock, size - 4) 102 if msg_type == synced: 103 break 104 assert msg_type == 2, "Expected a key announcement" 105 assert payload.endswith(b"coin_chf_n1\0"), "Section is not lowercase" 106 messages.add(payload) 107 assert messages, "Helper did not announce any keys" 108 proc.terminate() 109 assert proc.wait(timeout=10) == 0, "Helper did not shut down cleanly" 110 return messages 111 except BaseException: 112 log.seek(0) 113 sys.stderr.write(log.read()) 114 raise 115 finally: 116 if proc.poll() is None: 117 proc.kill() 118 proc.wait() 119 120 121 def snapshot(directory): 122 """Record metadata, link targets and key hashes without exposing keys.""" 123 result = {} 124 for entry in directory.rglob("*"): 125 info = entry.lstat() 126 digest = None 127 if stat.S_ISREG(info.st_mode): 128 digest = hashlib.sha256(entry.read_bytes()).digest() 129 target = os.readlink(entry) if stat.S_ISLNK(info.st_mode) else None 130 result[str(entry.relative_to(directory))] = ( 131 info.st_ino, info.st_mode, digest, target 132 ) 133 return result 134 135 136 def main(): 137 binary, cipher = sys.argv[1:] 138 with tempfile.TemporaryDirectory(prefix=f"secmod-{cipher}-") as tmp: 139 root = Path(tmp) 140 (root / "empty-config").mkdir() 141 keydir = root / "Keys" 142 canonical = keydir / "coin_chf_n1" 143 legacy = keydir / "cOiN_CHF_n1" 144 145 # Mixed-case configuration must create a lowercase directory immediately. 146 announcements = run_helper(binary, cipher, root, "cOiN_CHF_n1") 147 assert {p.name for p in keydir.iterdir()} == {canonical.name} 148 keys = snapshot(canonical) 149 assert keys 150 assert min(keys).startswith("1767225600-"), "Helper clock was not controlled" 151 152 # A case-only configuration change must reload exactly the same keys. 153 assert run_helper(binary, cipher, root, "COIN_CHF_N1") == announcements 154 assert snapshot(canonical) == keys, "Restart changed the keys" 155 156 # Reject symlinks before renaming their targets, including links whose 157 # own names are already lowercase or do not have the coin_ prefix. 158 stored = keydir / "CoIn_Stored" 159 canonical.rename(stored) 160 for link, target in ( 161 (canonical, stored.name), 162 (legacy, stored.name), 163 (canonical, str(stored)), 164 (canonical, "missing-directory"), 165 (canonical, canonical.name), 166 (keydir / "key-alias", stored.name), 167 ): 168 link.symlink_to(target, target_is_directory=True) 169 before = snapshot(keydir) 170 run_helper(binary, cipher, root, "coin_chf_n1", 171 error="symbolic links are not allowed") 172 assert snapshot(keydir) == before, "Symlink rejection changed key storage" 173 link.unlink() 174 stored.rename(canonical) 175 176 # Slash-containing section names must be rejected before migrating any 177 # directories or loading keys, including an old nested key layout. 178 nested = keydir / "coin_chf" / "N1" 179 nested.parent.mkdir() 180 canonical.rename(nested) 181 pending = keydir / "cOiN_Pending" 182 pending.mkdir() 183 before = snapshot(keydir) 184 for section in ("coin_chf/N1", "CoIn_CHF/N1", "coin_chf/", "coin_../escape"): 185 run_helper(binary, cipher, root, section, error="must not contain '/'") 186 assert snapshot(keydir) == before, "Invalid section changed key storage" 187 nested.rename(canonical) 188 nested.parent.rmdir() 189 pending.rmdir() 190 191 # Simulate an old installation, with a different spelling in the config. 192 canonical.rename(legacy) 193 archive = keydir / "CoIn_Archived" 194 archive.mkdir() 195 (archive / "saved-key").write_bytes(b"archived key material") 196 archived = snapshot(archive) 197 unrelated = keydir / "Keep_Mixed" 198 unrelated.mkdir() 199 assert run_helper(binary, cipher, root, "Coin_Chf_N1") == announcements 200 assert legacy.name not in {p.name for p in keydir.iterdir()} 201 assert snapshot(canonical) == keys, "Migration changed the keys" 202 assert snapshot(keydir / "coin_archived") == archived 203 assert unrelated.is_dir() 204 205 # Older timestamp-only key filenames must still migrate after the directory. 206 canonical.rename(legacy) 207 for key in legacy.iterdir(): 208 key.rename(key.with_name(key.name.split("-")[0])) 209 assert run_helper(binary, cipher, root, "coin_chf_n1") == announcements 210 assert snapshot(canonical) == keys, "Legacy filename migration changed keys" 211 212 if legacy.exists(): 213 print("Skipping name collision cases on a case-insensitive filesystem") 214 return 215 216 # Even an empty destination directory must not be silently overwritten. 217 canonical.rename(legacy) 218 canonical.mkdir() 219 before = snapshot(keydir) 220 run_helper(binary, cipher, root, "coin_chf_n1", error="before restarting") 221 assert snapshot(keydir) == before, "Conflict modified key storage" 222 canonical.rmdir() 223 legacy.rename(canonical) 224 225 # Detect two mixed-case directories even when the lowercase name is absent. 226 canonical.rename(legacy) 227 other = keydir / "COIN_CHF_N1" 228 other.mkdir() 229 (keydir / "coin_archived").rename(archive) 230 before = snapshot(keydir) 231 run_helper(binary, cipher, root, "Coin_Chf_N1", error="before restarting") 232 assert snapshot(keydir) == before, "Conflict partially migrated directories" 233 other.rmdir() 234 235 # A file occupying the destination must also survive a failed migration. 236 canonical.write_bytes(b"do not overwrite") 237 before = snapshot(keydir) 238 run_helper(binary, cipher, root, "coin_chf_n1", error="before restarting") 239 assert snapshot(keydir) == before, "Conflict overwrote a file" 240 canonical.unlink() 241 242 # A failed rename must stop startup instead of generating replacement keys. 243 if os.geteuid() != 0: 244 keydir.chmod(0o500) 245 try: 246 before = snapshot(keydir) 247 run_helper(binary, cipher, root, "coin_chf_n1", error="rename") 248 assert snapshot(keydir) == before, "Failed migration changed keys" 249 finally: 250 keydir.chmod(0o700) 251 252 253 if __name__ == "__main__": 254 main()