Files
MeshTerm/tests/test_secrets.py
T
Jean-Pierre MartineauandClaude Opus 5.5 1d983013d7 No log keeps a password or a secret: the log file, the run log, or the remote history
Passwords, channel secrets, pairing PINs, and private keys reached the DEBUG log and
the runs table in plain text: every chat run stored its channel secret inside the
Conversation repr, and --password, --secret, channel links, and config set device_pin
were stored as typed. meshterm.core.secrets now hides each one as <secret> by its
shape (a secret-named key, record, set operation, or dataclass field; bytes; a Secret;
a remote `password`/`set guest.password` command; name=value in text), and by value
for each secret MeshTerm has read or sent. Both log handlers and the runs table go
through it, always. The remote CLI history keeps `password ` without its value. Schema
v20 hides the secrets that older versions stored.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 00:20:22 -04:00

307 lines
13 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""No log ever holds a password or a secret: the log file, the run log, or the remote history.
JP, 2026-10-18: "NEVER log passwords or secrets!" The rule is a property of the places that
keep records, and not a habit of each tool. Thus the most important test here does not
look at the rules one by one. It runs each command that takes a secret, with a planted
value, through the real CLI at the DEBUG log level, and then searches the log file and the
``runs`` table for each planted value. A new command that takes a secret is covered when
it is added to that list. The other tests hold each rule and each shape on its own, so
that a failure says which rule broke.
"""
from __future__ import annotations
import io
import json
import logging
import sqlite3
from pathlib import Path
import pytest
from rich.console import Console
from typer.testing import CliRunner
from meshterm.core.device_config import DeviceConfigError, get_spec, parse_value
from meshterm.core.models import Contact, Conversation
from meshterm.core.remote_store import RemoteStore
from meshterm.core.secrets import (
SECRET_TEXT,
Secret,
hide,
loggable,
names_secret,
remember,
without_secret,
)
from meshterm.persistence import db
from meshterm.persistence.logging import configure_logging, get_logger, log_path
from meshterm.persistence.repository import Repository
#: Planted values. Each one is distinct, so a failure names the path that leaked it.
_PASSWORD = "plantedpw-7c1e"
_HEX = "5ec2e7a1" * 4
# --- names ----------------------------------------------------------------------------
@pytest.mark.parametrize(
"name", ["password", "device_pin", "guest.password", "bridge.secret", "prv.key", "ble_pin"]
)
def test_a_name_with_a_secret_word_names_a_secret(name: str) -> None:
"""A secret word anywhere in the name, between any separators, makes the name secret."""
assert names_secret(name)
@pytest.mark.parametrize("name", ["pinned", "spin", "public_key", "key_prefix", "name", "to"])
def test_a_name_that_only_contains_the_letters_is_not_secret(name: str) -> None:
"""A word must be whole: ``pinned`` is not ``pin``, and a public key is not a secret."""
assert not names_secret(name)
# --- structures -----------------------------------------------------------------------
def test_each_shape_of_a_secret_in_the_parameters_is_hidden() -> None:
"""A key, a record, an operation, a dataclass field, a wrapper, and bytes: each is hidden."""
params = {
"room": "Lakeside",
"password": _PASSWORD,
"ops": [("set", "device_pin", 246813), ("import_key", Secret(_HEX))],
"changes": [{"key": "device_pin", "previous": 135792, "value": 246813}],
"set_channel": (5, "#lakeside", bytes.fromhex(_HEX)),
"conversation": Conversation(
label="#lakeside", is_channel=True, secret=bytes.fromhex(_HEX)
),
}
shown = json.dumps(loggable(params), default=str)
for value in (_PASSWORD, _HEX, "246813", "135792", "xc2"):
assert value not in shown
assert "Lakeside" in shown # what is not a secret stays
assert "Conversation(label='#lakeside'" in shown
assert "secret=<secret>" in shown
def test_a_list_of_setting_names_is_not_an_operation() -> None:
"""Only ``(verb, name, value)`` hides its value. A list of names has no value in it."""
names = ["name", "device_pin", "radio_freq"]
assert loggable({"settings": names}) == {"settings": names}
assert loggable(("get", "device_pin")) == ("get", "device_pin")
def test_an_absent_secret_stays_absent() -> None:
"""A password that was not given is a fact that hides nothing. It stays ``None``."""
assert loggable({"password": None, "secret": ""}) == {"password": None, "secret": ""}
assert "secret=None" in str(loggable(Conversation(label="Bob", is_channel=False)))
# --- text -----------------------------------------------------------------------------
@pytest.mark.parametrize(
"text,expected",
[
("password hunter2-new", f"password {SECRET_TEXT}"),
("set guest.password hello there", f"set guest.password {SECRET_TEXT}"),
("set radio 869.5,250,11,5", "set radio 869.5,250,11,5"),
(
f"meshcore://channel/add?name=Lakeside&secret={_HEX}",
f"meshcore://channel/add?name=Lakeside&secret={SECRET_TEXT}",
),
(
"Conversation(secret=b'\\x8b3\\x87', contact=None)",
"Conversation(secret=<secret>, contact=None)",
),
("{'password': 'abcd1234'}", "{'password': <secret>}"),
('{"pin": "998877"}', '{"pin": <secret>}'),
("Conversation(secret=None)", "Conversation(secret=None)"),
("device_pin: must be 0 (no PIN) or six digits", None),
("BLE ProvidePin pairing for DD:F3:76:8B:0B:0B: status=19 ok=False", None),
],
)
def test_a_text_hides_each_secret_that_its_form_shows(text: str, expected: str | None) -> None:
"""A remote command, a URL, a ``repr``, and JSON all show which value is the secret."""
assert hide(text) == (text if expected is None else expected)
def test_a_known_secret_is_hidden_in_any_text() -> None:
"""A registered secret is hidden where nothing names it: in an error, or inside a hex frame."""
remember(_PASSWORD)
remember(bytes.fromhex(_HEX))
assert hide(f"login refused: {_PASSWORD} is wrong") == f"login refused: {SECRET_TEXT} is wrong"
frame = "Unknown stats type: 66, data: 1842" + _HEX.upper() + "00ff"
assert _HEX.upper() not in hide(frame)
def test_a_short_or_common_value_is_not_registered() -> None:
"""A value of three characters, or a secret word, would hide ordinary text everywhere."""
remember("abc")
remember("password")
assert hide("abc password") == "abc password"
def test_a_registered_pin_does_not_hide_part_of_a_longer_number() -> None:
"""A PIN is found as a whole word only, so it cannot cut a timestamp or a count."""
remember(246813)
assert hide("pin 246813 and 12468139") == f"pin {SECRET_TEXT} and 12468139"
# --- the remote command line ----------------------------------------------------------
def test_the_remote_history_keeps_the_command_without_its_secret(tmp_path: Path) -> None:
"""↑ recalls ``password ``, ready to type, and the file never holds the password."""
store = RemoteStore(tmp_path / "remote.json")
node = Contact(name="Hilltop", public_key="3d" * 32)
store.append_history(node, f"password {_PASSWORD}")
store.append_history(node, f"set guest.password {_PASSWORD}")
store.append_history(node, "set name Hilltop")
assert store.history(node) == ["password ", "set guest.password ", "set name Hilltop"]
assert _PASSWORD not in (tmp_path / "remote.json").read_text(encoding="utf-8")
assert without_secret("ver") == "ver"
# --- the device settings --------------------------------------------------------------
def test_a_refused_pin_is_not_repeated_in_the_error() -> None:
"""The error of a secret setting states the rule. Each other setting still shows the value."""
with pytest.raises(DeviceConfigError) as refused:
parse_value(get_spec("device_pin"), "24681x")
assert "24681x" not in str(refused.value)
with pytest.raises(DeviceConfigError) as short:
parse_value(get_spec("device_pin"), "24681")
assert "24681" not in str(short.value)
with pytest.raises(DeviceConfigError) as other:
parse_value(get_spec("radio_sf"), "13")
assert "13" in str(other.value)
# --- the places that keep records -----------------------------------------------------
def test_neither_log_handler_writes_a_secret(tmp_path: Path) -> None:
"""The file, its traceback, and the console all get the hidden form of a record."""
console = Console(file=io.StringIO(), width=200, record=True, color_system=None)
configure_logging(console, tmp_path, file_level=logging.DEBUG, level=logging.DEBUG)
log = get_logger("core.connection")
log.debug("run 1 start: params=%s", {"password": _PASSWORD})
try:
raise ValueError(f"login with {_PASSWORD} refused")
except ValueError:
log.exception("login failed")
for handler in get_logger().handlers:
handler.flush()
written = log_path(tmp_path).read_text(encoding="utf-8")
assert _PASSWORD not in written
assert SECRET_TEXT in written
assert _PASSWORD not in console.export_text()
def test_the_run_log_stores_no_secret(tmp_path: Path) -> None:
"""The arguments and the summary of a run go into the ``runs`` table hidden."""
repo = Repository(tmp_path / "test.db")
run = repo.start_run("rooms", {"cli_action": "join", "password": _PASSWORD})
repo.finish_run(run, "error", {"error": f"refused {_PASSWORD}"})
repo.close()
conn = sqlite3.connect(tmp_path / "test.db")
args, summary = conn.execute("SELECT args_json, summary_json FROM runs").fetchone()
conn.close()
assert _PASSWORD not in args + summary
assert SECRET_TEXT in args
def test_the_migration_hides_the_secrets_that_older_versions_stored(tmp_path: Path) -> None:
"""A database at version 19 loses each stored secret, one time, when it opens."""
path = tmp_path / "old.db"
db.connect(path).close()
conn = sqlite3.connect(path)
conversation = "Conversation(label='#x', is_channel=True, secret=b'\\x5e\\xc2', contact=None)"
leaked = json.dumps({"conversation": conversation})
conn.execute(
"INSERT INTO runs (tool, args_json, summary_json, status, started_at) "
"VALUES ('chat', ?, ?, 'ok', '2026-10-01T00:00:00')",
(leaked, json.dumps({"password": _PASSWORD})),
)
conn.execute("UPDATE schema_meta SET value = '19' WHERE key = 'version'")
conn.commit()
conn.close()
db.connect(path).close()
conn = sqlite3.connect(path)
args, summary = conn.execute("SELECT args_json, summary_json FROM runs").fetchone()
conn.close()
assert "\\\\x5e" not in args and "secret=<secret>" in args
assert _PASSWORD not in summary
# --- the whole program ----------------------------------------------------------------
#: Each command that takes a secret, with a planted value in it, and the planted values.
#: The commands run against the simulator. Some of them fail (a room that the simulator
#: does not have), and that is fine: a failure is logged too, and with the parameters.
_COMMANDS: list[tuple[list[str], tuple[str, ...]]] = [
(["repeater-admin", "Yagi-Repeater", "ver", "--password", "plantedpw-a1"], ("plantedpw-a1",)),
(["repeater-admin", "Yagi-Repeater", "password", "plantedpw-a2"], ("plantedpw-a2",)),
(
["repeater-admin", "Yagi-Repeater", "set", "guest.password", "plantedpw-a3"],
("plantedpw-a3",),
),
(["rooms", "join", "Lakeside BBS", "--password", "plantedpw-r1"], ("plantedpw-r1",)),
(
["tx-optimize", "--path", "a1,b2", "--min", "20", "--max", "20", "--samples", "1"]
+ ["--no-apply", "--password", "plantedpw-t1"],
("plantedpw-t1",),
),
(["channels", "add", "5", "Planted", "--secret", "a1" * 16], ("a1" * 16,)),
(["channels", "join", "6", "Planted2", "b2" * 16], ("b2" * 16,)),
(
["channels", "import", "7", "meshcore://channel/add?name=P3&secret=" + "c3" * 16],
("c3" * 16,),
),
(["config", "channel", "5", "Planted4", "--secret", "d4" * 16], ("d4" * 16,)),
(["config", "set", "device_pin", "246813"], ("246813",)),
(["config", "set", "device_pin", "97531"], ("97531",)),
]
def test_no_command_writes_a_planted_secret_to_any_log(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Each command with a secret, at the DEBUG level: no planted value in the log or the runs.
This is the test that holds the promise. The rules above can each be correct and a
path between them can still leak, as the parameters of a chat run did. Thus this test
asks the two places that keep records, and not the rules.
"""
home = tmp_path / "home"
monkeypatch.setenv("MESHTERM_HOME", str(home))
runner = CliRunner()
assert runner.invoke(app_(), ["preferences", "set", "log_level", "DEBUG"]).exit_code == 0
planted: list[str] = []
for args, values in _COMMANDS:
runner.invoke(app_(), ["--mock", *args])
planted.extend(values)
for handler in get_logger().handlers:
handler.flush()
log = log_path(home).read_text(encoding="utf-8")
assert log.count("start: tool=") >= len(_COMMANDS), "the commands did not run"
conn = sqlite3.connect(home / "meshterm-mock.db")
runs = "\n".join(
f"{args} {summary}"
for args, summary in conn.execute("SELECT args_json, summary_json FROM runs")
)
conn.close()
for value in planted:
assert value not in log, f"{value} is in the log"
assert value not in runs, f"{value} is in the runs table"
def app_():
"""The CLI application, imported late so that the environment of the test applies."""
from meshterm.cli import app
return app