"""Full OPNexus backup: round trip, integrity, refusal of hostile or newer archives."""
import io
import json
import os
import tarfile
from pathlib import Path
import pytest
from app import full_backup as fb
from app.config import settings
from app.services import backup_crypto
PASS = "full backup passphrase 1"
old_mtime = 1_700_000_000 # Nov 2023
@pytest.fixture
def data(tmp_path, monkeypatch):
"""Point every data directory at a scratch tree with recognisable content."""
for name, attr in fb._DATA_DIRS.items():
path = tmp_path / name
path.mkdir()
monkeypatch.setattr(settings, attr, str(path))
(tmp_path / "opnsense_secrets" / "host-1.secret").write_text("API-SECRET-1")
(tmp_path / "totp_secrets").joinpath("u.secret").write_text("TOTP-SECRET")
sub = tmp_path / "opnsense_backups" / "host-1"
sub.mkdir()
(sub / "20260101T000000.xml").write_text("")
os.utime(sub / "20260101T000000.xml", (old_mtime, old_mtime))
return tmp_path
def _dump(path: Path) -> None:
path.write_bytes(b"PGDMP-fake-database")
def _create(**kw):
return fb.create(PASS, dump=_dump, revision="0009", **kw)
def test_roundtrip_restores_every_secret_and_the_database(data):
blob = _create()
assert b"API-SECRET-1" not in blob and b"TOTP-SECRET" not in blob and b"PGDMP" not in blob
# Wipe the data, then restore.
for f in data.rglob("*"):
if f.is_file():
f.unlink()
seen = {}
result = fb.restore(blob, PASS, restore_db=lambda p: seen.update(dump=p.read_bytes()), head="0012")
assert seen["dump"] == b"PGDMP-fake-database"
assert (data / "opnsense_secrets" / "host-1.secret").read_text() == "API-SECRET-1"
assert (data / "totp_secrets" / "u.secret").read_text() == "TOTP-SECRET"
assert (data / "opnsense_backups" / "host-1" / "20260101T000000.xml").read_text() == ""
assert result["files_restored"] == 3 and result["db_revision"] == "0009"
mode = (data / "opnsense_secrets" / "host-1.secret").stat().st_mode & 0o777
assert mode == 0o600
# file times survive: stored config backups are aged by their mtime
assert abs((data / "opnsense_backups" / "host-1" / "20260101T000000.xml").stat().st_mtime - old_mtime) < 2
def test_info_shows_only_the_manifest_and_needs_the_passphrase(data):
blob = _create()
manifest = fb.info(blob, PASS)
assert manifest["format"] == 1 and manifest["db_revision"] == "0009" and "opnsense_secrets" in manifest["data"]
with pytest.raises(fb.FullBackupError, match="Passphrase"):
fb.info(blob, "wrong passphrase 123")
def test_short_passphrase_is_refused(data):
with pytest.raises(fb.FullBackupError, match="mindestens"):
fb.create("short", dump=_dump, revision="0009")
def test_tampered_or_foreign_files_are_refused(data):
blob = bytearray(_create())
blob[-5] ^= 0xFF
with pytest.raises(fb.FullBackupError):
fb.restore(bytes(blob), PASS, restore_db=lambda p: None, head="0012")
for junk in (b"", b"not a backup"):
with pytest.raises(fb.FullBackupError):
fb.restore(junk, PASS, restore_db=lambda p: None, head="0012")
# A valid encrypted file that is not a full backup (a single config backup, say).
with pytest.raises(fb.FullBackupError, match="Archiv unlesbar"):
fb.restore(backup_crypto.encrypt(b"", PASS), PASS, restore_db=lambda p: None, head="0012")
def test_a_backup_from_a_newer_version_is_refused_before_anything_is_touched(data):
blob = fb.create(PASS, dump=_dump, revision="0020")
touched = []
with pytest.raises(fb.FullBackupError, match="neueren"):
fb.restore(blob, PASS, restore_db=lambda p: touched.append(p), head="0012")
assert touched == []
(data / "totp_secrets" / "u.secret").write_text("UNCHANGED")
assert (data / "totp_secrets" / "u.secret").read_text() == "UNCHANGED"
def _hostile(member_name: str, kind=tarfile.REGTYPE, link=""):
plain = io.BytesIO()
with tarfile.open(fileobj=plain, mode="w:gz") as tar:
manifest = json.dumps({"format": 1, "db_revision": "0001"}).encode()
info = tarfile.TarInfo("manifest.json"); info.size = len(manifest)
tar.addfile(info, io.BytesIO(manifest))
dump = tarfile.TarInfo("database.dump"); dump.size = 1
tar.addfile(dump, io.BytesIO(b"x"))
evil = tarfile.TarInfo(member_name); evil.type = kind; evil.linkname = link
evil.size = 4 if kind == tarfile.REGTYPE else 0
tar.addfile(evil, io.BytesIO(b"evil") if kind == tarfile.REGTYPE else None)
return backup_crypto.encrypt(plain.getvalue(), PASS)
@pytest.mark.parametrize("name,kind,link", [
("../../etc/cron.d/x", tarfile.REGTYPE, ""),
("/etc/passwd", tarfile.REGTYPE, ""),
("data/opnsense_secrets/../../escape", tarfile.REGTYPE, ""),
("data/opnsense_secrets/link", tarfile.SYMTYPE, "/etc/shadow"),
("data/opnsense_secrets/dev", tarfile.CHRTYPE, ""),
])
def test_hostile_archives_are_rejected_before_the_database_is_touched(data, name, kind, link):
touched = []
with pytest.raises(fb.FullBackupError, match="Unzulässig"):
fb.restore(_hostile(name, kind, link), PASS, restore_db=lambda p: touched.append(p), head="0012")
assert touched == [] and not (data.parent / "escape").exists()
def test_unknown_directories_in_the_archive_are_ignored(data, tmp_path):
blob = _hostile("data/not_a_known_volume/file")
fb.restore(blob, PASS, restore_db=lambda p: None, head="0012")
assert not (tmp_path / "not_a_known_volume").exists()
def test_command_line_reads_the_passphrase_from_the_environment(data, monkeypatch, capsys):
blob = _create()
monkeypatch.setenv("FULL_BACKUP_PASSPHRASE", PASS)
class _In: # sys.stdin stand-in with a binary buffer
buffer = io.BytesIO(blob)
monkeypatch.setattr("sys.stdin", _In)
assert fb._main(["full_backup", "info"]) == 0
assert '"format": 1' in capsys.readouterr().out
monkeypatch.setenv("FULL_BACKUP_PASSPHRASE", "")
assert fb._main(["full_backup", "create"]) == 1
assert fb._main(["full_backup", "bogus"]) == 2
def test_a_secret_changing_during_the_backup_triggers_a_new_attempt(data):
calls = []
def dump(path: Path) -> None:
calls.append(1)
if len(calls) == 1: # ein Objekt wird mitten in der Sicherung angelegt
(data / "opnsense_secrets" / "host-2.secret").write_text("API-SECRET-2")
path.write_bytes(b"PGDMP-fake-database")
blob = fb.create(PASS, dump=dump, revision="0009")
assert len(calls) == 2
for f in data.rglob("*"):
if f.is_file():
f.unlink()
fb.restore(blob, PASS, restore_db=lambda p: None, head="0012")
assert (data / "opnsense_secrets" / "host-2.secret").read_text() == "API-SECRET-2"
def test_a_backup_that_never_holds_still_is_refused_instead_of_written_inconsistent(data):
n = []
def dump(path: Path) -> None:
n.append(1)
(data / "totp_secrets" / f"u{len(n)}.secret").write_text("x")
path.write_bytes(b"PGDMP-fake-database")
with pytest.raises(fb.FullBackupError, match="Geheimnisse geändert"):
fb.create(PASS, dump=dump, revision="0009")
assert len(n) == fb.ATTEMPTS
def test_new_firewall_config_backups_do_not_count_as_a_change(data):
calls = []
def dump(path: Path) -> None:
calls.append(1)
(data / "opnsense_backups" / "host-1" / f"new-{len(calls)}.xml").write_text("")
path.write_bytes(b"PGDMP-fake-database")
fb.create(PASS, dump=dump, revision="0009")
assert len(calls) == 1