exchange

Base system with REST service to issue digital coins, run by the payment service provider
Log | Files | Refs | Submodules | README | LICENSE

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