From cc110f78f429e86a3c2a8f817aaa6d180e57c824 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Wed, 17 Jun 2026 15:34:21 +0200 Subject: [PATCH 1/7] monitor: publish signed+sealed state reports for monitoring After create/prune, the borg client now publishes a small state report into the repository's new `monitoring/` namespace. The report is signed (Ed25519) and sealed (HPKE, RFC 9180) using key material derived from the existing borg key, so the untrusted repository server can neither forge nor read it - it can only relay it. The new `borg monitor` command reads, verifies and decrypts the reports from from the repository using the monitoring key, applies a freshness window (--max-age) and exits non-zero if the report is missing, stale, unsigned or unsuccessful. `borg monitor --key` (which needs the unlocked borg key) derives and prints BORG_MONITORING_KEY for the monitoring host; that value only allows verifying and decrypting the state reports, not creating them. It also does not give access to other data or metadata in the repository. Unencrypted (`--encryption none`) repos have no key to derive the monitoring key from, so their reports are published as unsigned, unencrypted plaintext and these reports are flagged untrusted on read. Requires OpenSSL >= 3.2 (for the built-in HPKE API). borg monitor --json outputs report(s) in json format. borg monitor --keep=N keeps the latest N status reports (default: 500), older reports are deleted. --keep=0 disables this. borg monitor reads all reports and prints, per archive series (and per maintenance command), the latest status and freshness; --name / --command restrict the output. The exit code is the worst across all units. Co-Authored-By: Claude Opus 4.8 --- docs/usage.rst | 1 + docs/usage/general/environment.rst.inc | 7 + docs/usage/monitor.rst | 1 + docs/usage/monitor.rst.inc | 83 +++++++ src/borg/archiver/__init__.py | 3 + src/borg/archiver/create_cmd.py | 12 +- src/borg/archiver/monitor_cmd.py | 201 +++++++++++++++ src/borg/archiver/prune_cmd.py | 14 ++ src/borg/crypto/low_level.pyi | 14 ++ src/borg/crypto/low_level.pyx | 230 +++++++++++++++++- src/borg/crypto/monitoring.py | 111 +++++++++ src/borg/monitoring.py | 196 +++++++++++++++ src/borg/repository.py | 7 +- .../testsuite/archiver/monitor_cmd_test.py | 147 +++++++++++ src/borg/testsuite/monitoring_test.py | 212 ++++++++++++++++ 15 files changed, 1236 insertions(+), 3 deletions(-) create mode 100644 docs/usage/monitor.rst create mode 100644 docs/usage/monitor.rst.inc create mode 100644 src/borg/archiver/monitor_cmd.py create mode 100644 src/borg/crypto/monitoring.py create mode 100644 src/borg/monitoring.py create mode 100644 src/borg/testsuite/archiver/monitor_cmd_test.py create mode 100644 src/borg/testsuite/monitoring_test.py diff --git a/docs/usage.rst b/docs/usage.rst index c62faad033..33e7cc672d 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -41,6 +41,7 @@ Usage usage/repo-list usage/repo-info usage/repo-delete + usage/monitor usage/serve usage/version usage/compact diff --git a/docs/usage/general/environment.rst.inc b/docs/usage/general/environment.rst.inc index f11d3ea46f..80d987053a 100644 --- a/docs/usage/general/environment.rst.inc +++ b/docs/usage/general/environment.rst.inc @@ -299,6 +299,13 @@ Directories and files: - the key file must either exist (for most commands) or will be created (``borg repo-create``). - you need to give a different path for different repositories. - you need to point to the correct key file matching the repository the command will operate on. + BORG_MONITORING_KEY + Used by ``borg monitor`` on the monitoring host to verify and decrypt + the reports backup clients published into the repository. + You can get the correct key value by running ``borg monitor --key`` on + a host that has access to the borg key. + This key can not be used to create reports, nor does it grant access + to backup data. TMPDIR This is where temporary files are stored (might need a lot of temporary space for some operations), see tempfile_ for details. diff --git a/docs/usage/monitor.rst b/docs/usage/monitor.rst new file mode 100644 index 0000000000..3a9b6e1e66 --- /dev/null +++ b/docs/usage/monitor.rst @@ -0,0 +1 @@ +.. include:: monitor.rst.inc diff --git a/docs/usage/monitor.rst.inc b/docs/usage/monitor.rst.inc new file mode 100644 index 0000000000..0508ee25e1 --- /dev/null +++ b/docs/usage/monitor.rst.inc @@ -0,0 +1,83 @@ +.. IMPORTANT: this file is auto-generated from borg's built-in help, do not edit! + +.. _borg_monitor: + +borg monitor +------------ +.. code-block:: none + + borg [common options] monitor [options] + +.. only:: html + + .. class:: borg-options-table + + +-------------------------------------------------------+-----------------------+------------------------------------------------------------------------------------------------------------------------+ + | **options** | + +-------------------------------------------------------+-----------------------+------------------------------------------------------------------------------------------------------------------------+ + | | ``--key`` | derive and print BORG_MONITORING_KEY for this repository (needs the borg key) | + +-------------------------------------------------------+-----------------------+------------------------------------------------------------------------------------------------------------------------+ + | | ``--name SERIES`` | only report on the given archive series (e.g. the name used with borg create) | + +-------------------------------------------------------+-----------------------+------------------------------------------------------------------------------------------------------------------------+ + | | ``--command COMMAND`` | only report on the given command, e.g. create or prune (default: all commands) | + +-------------------------------------------------------+-----------------------+------------------------------------------------------------------------------------------------------------------------+ + | | ``--max-age SECONDS`` | freshness window in seconds; older reports count as stale (default: 90000) | + +-------------------------------------------------------+-----------------------+------------------------------------------------------------------------------------------------------------------------+ + | | ``--keep N`` | after reading, delete all but the N newest report objects (needs delete permission; 0 = do not clean up; default: 500) | + +-------------------------------------------------------+-----------------------+------------------------------------------------------------------------------------------------------------------------+ + | | ``--json`` | format output as JSON | + +-------------------------------------------------------+-----------------------+------------------------------------------------------------------------------------------------------------------------+ + | .. class:: borg-common-opt-ref | + | | + | :ref:`common_options` | + +-------------------------------------------------------+-----------------------+------------------------------------------------------------------------------------------------------------------------+ + + .. raw:: html + + + +.. only:: latex + + + + options + --key derive and print BORG_MONITORING_KEY for this repository (needs the borg key) + --name SERIES only report on the given archive series (e.g. the name used with borg create) + --command COMMAND only report on the given command, e.g. create or prune (default: all commands) + --max-age SECONDS freshness window in seconds; older reports count as stale (default: 90000) + --keep N after reading, delete all but the N newest report objects (needs delete permission; 0 = do not clean up; default: 500) + --json format output as JSON + + + :ref:`common_options` + | + +Description +~~~~~~~~~~~ + +Read or export trusted monitoring state of a repository. + +Backup-side commands publish a small signed-and-encrypted state report into the +repository after each run. Because each report is signed with a key derived from +the borg key, the (untrusted) repository server can neither forge nor read it - it +can only relay it. A monitoring system can therefore pull and verify the reports +from the same server without the repository passphrase. + +Setup (once, on a host that has the borg key):: + + BORG_MONITORING_KEY=$(borg monitor --key) + +Then, on the monitoring host, with that value exported as BORG_MONITORING_KEY:: + + borg monitor + +This verifies and decrypts the reports and prints, per archive series (and per +maintenance command), the latest status and its age. It exits with a non-zero code +(warning or error) if any series is missing, stale (older than --max-age), unsigned, +or did not indicate success - so it can drive alerting like a dead man's switch. +Reports accumulate over time; --keep=N (default 500) deletes all but the N newest +after reading. \ No newline at end of file diff --git a/src/borg/archiver/__init__.py b/src/borg/archiver/__init__.py index 141a24a2f9..5f3d04879a 100644 --- a/src/borg/archiver/__init__.py +++ b/src/borg/archiver/__init__.py @@ -84,6 +84,7 @@ from .recreate_cmd import RecreateMixIn from .rename_cmd import RenameMixIn from .repo_create_cmd import RepoCreateMixIn +from .monitor_cmd import MonitorMixIn from .repo_info_cmd import RepoInfoMixIn from .repo_delete_cmd import RepoDeleteMixIn from .repo_list_cmd import RepoListMixIn @@ -113,6 +114,7 @@ class Archiver( KeysMixIn, ListMixIn, LocksMixIn, + MonitorMixIn, MountMixIn, PruneMixIn, RecreateMixIn, @@ -303,6 +305,7 @@ def build_parser(self): self.build_parser_keys(subparsers, common_parser, mid_common_parser) self.build_parser_list(subparsers, common_parser, mid_common_parser) self.build_parser_locks(subparsers, common_parser, mid_common_parser) + self.build_parser_monitor(subparsers, common_parser, mid_common_parser) self.build_parser_mount_umount(subparsers, common_parser, mid_common_parser) self.build_parser_prune(subparsers, common_parser, mid_common_parser) self.build_parser_repo_create(subparsers, common_parser, mid_common_parser) diff --git a/src/borg/archiver/create_cmd.py b/src/borg/archiver/create_cmd.py index 0c03eddb01..25d472f06e 100644 --- a/src/borg/archiver/create_cmd.py +++ b/src/borg/archiver/create_cmd.py @@ -10,6 +10,7 @@ from ._common import with_repository, Highlander from .. import helpers +from .. import monitoring from ..archive import Archive, is_special, SF_DATALESS from ..archive import BackupError, BackupOSError, BackupItemExcluded, backup_io, OsOpen, stat_update_check from ..archive import FilesystemObjectProcessors, MetadataCollector, ChunksProcessor @@ -278,6 +279,15 @@ def create_inner(archive, cache, fso): files_changed=args.files_changed, ) create_inner(archive, cache, fso) + + monitoring.publish_command_report( + repository, + manifest.key, + "create", + archive=archive.name, + archive_id=archive.id, + stats=archive.stats.as_dict(), + ) args.stats |= args.json if args.stats: # Cache.close() writes the chunks index to the repo; sample after it so that traffic is counted. @@ -286,7 +296,7 @@ def create_inner(archive, cache, fso): json_print(basic_json_data(manifest, cache=cache, extra={"archive": archive})) else: log_multi(str(archive), str(archive.stats), logger=logging.getLogger("borg.output.stats")) - else: + else: # dry-run create_inner(None, None, None) def _process_any(self, *, path, parent_fd, name, st, fso, cache, read_special, dry_run, strip_prefix): diff --git a/src/borg/archiver/monitor_cmd.py b/src/borg/archiver/monitor_cmd.py new file mode 100644 index 0000000000..e27270cf84 --- /dev/null +++ b/src/borg/archiver/monitor_cmd.py @@ -0,0 +1,201 @@ +import os +from datetime import datetime, timezone + +from ._common import with_repository +from ..constants import * # NOQA +from ..helpers import set_ec, Error, json_print +from ..helpers.time import parse_timestamp +from ..crypto.low_level import IntegrityError +from ..helpers.argparsing import ArgumentParser +from ..manifest import Manifest +from .. import monitoring +from ..crypto import monitoring as mon_crypto + +from ..logger import create_logger + +logger = create_logger() + +# Default freshness window: alert if the newest report is older than this (slightly over +# a day, to tolerate a late daily backup). Override with --max-age. +DEFAULT_MAX_AGE = 25 * 3600 + + +class MonitorMixIn: + @with_repository(manifest=False) + def do_monitor(self, args, repository): + """Read or export trusted monitoring state of a repository. + + Without arguments this reads the monitoring reports that backup-side commands + published into the repository, verifies and decrypts them using the key from the + BORG_MONITORING_KEY environment variable, and reports - per archive series (and per + maintenance command) - the latest status and freshness. Because each series is + reported independently, a later successful backup of one series does not mask an + earlier failed backup of another. Restrict the output with --name (one archive + series) or --command (e.g. create or prune). Neither the repository passphrase nor + the borg key is needed for reading. + + Reports accumulate over time; --keep=N deletes all but the N newest after reading. + + With --key (which does need the borg key), it derives and prints the + BORG_MONITORING_KEY value for this repository, to be configured on the monitoring + host. The printed value only allows verifying and decrypting reports, not creating + them. + """ + if args.key: + return self._monitor_export_key(repository) + return self._monitor_read(args, repository) + + def _monitor_export_key(self, repository): + manifest = Manifest.load(repository, Manifest.NO_OPERATION_CHECK) + if not mon_crypto.is_signed_repo(manifest.key): + raise Error( + "This repository is unencrypted; monitoring reports are not signed, so there is no monitoring key." + ) + print(mon_crypto.export_monitor_key(manifest.key)) + + def _monitor_read(self, args, repository): + key_str = os.environ.get("BORG_MONITORING_KEY") + try: + monitor_key = mon_crypto.parse_monitor_key(key_str) if key_str else None + except ValueError as e: + raise Error(f"Invalid BORG_MONITORING_KEY: {e}") + + try: + reports = list(monitoring.iter_reports(repository, monitor_key)) + except IntegrityError as e: + raise Error(f"Monitoring report verification/decryption failed (wrong key or tampered): {e}") + except ValueError as e: + raise Error(str(e)) + + # Cleanup is best-effort and must not change the read result, so do it after reading. + monitoring.prune_reports(repository, args.keep) + + now = datetime.now(timezone.utc) + + # Reports are oldest-first, so the last one written per "unit" (archive series for + # create, else the command) wins - giving each unit its latest status independently, + # so a later successful series cannot mask an earlier failed one. + latest = {} + for report, trusted in reports: + if args.command and report.get("command") != args.command: + continue + if args.name and report.get("archive") != args.name: + continue + unit = report.get("archive") or report.get("command") + latest[unit] = (report, trusted) + + entries = [] + for unit in sorted(latest): + report, trusted = latest[unit] + age = (now - parse_timestamp(report["time"])).total_seconds() + stale = age > args.max_age + entries.append((unit, report, trusted, age, stale)) + + self._monitor_output(args, entries) + + if not entries: + set_ec(EXIT_ERROR) # nothing matched -> dead man's switch fires + return + # Exit code drives external alerting: worst unit wins (stale/error -> error, + # warning/untrusted -> warning). + for _, report, trusted, _, stale in entries: + if stale or report.get("status") == "error": + set_ec(EXIT_ERROR) + elif report.get("status") == "warning" or not trusted: + set_ec(EXIT_WARNING) + + def _monitor_output(self, args, entries): + if args.json: + out = { + "max_age_seconds": args.max_age, + "entries": [ + {"unit": unit, "trusted": trusted, "stale": stale, "age_seconds": age, "report": report} + for unit, report, trusted, age, stale in entries + ], + } + json_print(out) + return + if not entries: + scope = "" + if args.name: + scope = f" for archive '{args.name}'" + elif args.command: + scope = f" for command '{args.command}'" + print(f"No monitoring report found{scope}.") + return + for unit, report, trusted, age, stale in entries: + print(f"{unit}:") + print(f" command: {report.get('command')}") + print(f" status: {report.get('status')} (rc {report.get('rc')})") + print(f" archive: {report.get('archive', '-')}") + print(f" time: {report.get('time')}") + print(f" age: {int(age)}s (max {args.max_age}s){' STALE' if stale else ''}") + print(f" trusted: {trusted}{'' if trusted else ' (unsigned - repo is unencrypted)'}") + + def build_parser_monitor(self, subparsers, common_parser, mid_common_parser): + from ._common import process_epilog + + monitor_epilog = process_epilog( + """ + Read trusted monitoring state of a repository. + + Borg client commands publish a signed-and-encrypted state report into the + repository after each run. Only borg monitor can read these reports using + the monitoring key. + + Setup (once, on a host that has the borg key):: + + borg monitor --key # this outputs the monitoring key + + Then, on the monitoring host:: + + BORG_MONITORING_KEY= borg monitor + + This verifies and decrypts the reports and prints, per archive series (and per + maintenance command), the latest status and its age. It exits with a non-zero code + (warning or error) if any series is missing, stale (older than --max-age), unsigned, + or did not indicate success - so it can drive alerting like a dead man's switch. + Reports accumulate over time; --keep=N (default 500) deletes all but the N newest + after reading. + """ + ) + subparser = ArgumentParser(parents=[common_parser], description=self.do_monitor.__doc__, epilog=monitor_epilog) + subparsers.add_subcommand("monitor", subparser, help="read/export repository monitoring state") + subparser.add_argument( + "--key", + dest="key", + action="store_true", + help="derive and print BORG_MONITORING_KEY for this repository (needs the borg key)", + ) + subparser.add_argument( + "--name", + dest="name", + default=None, + metavar="SERIES", + help="only report on the given archive series (e.g. the name used with borg create)", + ) + subparser.add_argument( + "--command", + dest="command", + default=None, + metavar="COMMAND", + help="only report on the given command, e.g. create or prune (default: all commands)", + ) + subparser.add_argument( + "--max-age", + dest="max_age", + type=int, + default=DEFAULT_MAX_AGE, + metavar="SECONDS", + help=f"freshness window in seconds; older reports count as stale (default: {DEFAULT_MAX_AGE})", + ) + subparser.add_argument( + "--keep", + dest="keep", + type=int, + default=monitoring.DEFAULT_KEEP, + metavar="N", + help="after reading, delete all but the N newest report objects " + f"(0 = do not clean up; default: {monitoring.DEFAULT_KEEP})", + ) + subparser.add_argument("--json", action="store_true", help="format output as JSON") diff --git a/src/borg/archiver/prune_cmd.py b/src/borg/archiver/prune_cmd.py index 5f172303b2..c271fae146 100644 --- a/src/borg/archiver/prune_cmd.py +++ b/src/borg/archiver/prune_cmd.py @@ -5,6 +5,8 @@ from functools import wraps import os from itertools import count, combinations + +from .. import monitoring from ._common import with_repository, Highlander from ..constants import * # NOQA from ..helpers import ArchiveFormatter, ProgressIndicatorPercent, CommandError, Error @@ -291,6 +293,18 @@ def do_prune(self, args, repository, manifest): if sig_int: raise Error("Got Ctrl-C / SIGINT.") + if not args.dry_run: + monitoring.publish_command_report( + repository, + manifest.key, + "prune", + stats={ + "archives_pruned": num_archives_deleted, + "archives_kept": len(keep), + "archives_considered": len(archives), + }, + ) + def _validate_prune_args(self, args): keep_args = {rule.key: getattr(args, rule.key) for rule in PRUNING_RULES if getattr(args, rule.key) is not None} diff --git a/src/borg/crypto/low_level.pyi b/src/borg/crypto/low_level.pyi index 4ac4448aa7..5a081adac6 100644 --- a/src/borg/crypto/low_level.pyi +++ b/src/borg/crypto/low_level.pyi @@ -27,6 +27,20 @@ class XXH64: def digest(self) -> bytes: ... def hexdigest(self) -> str: ... +# Asymmetric primitives for monitoring reports (OpenSSL >= 3.2) +ED25519_SEED_SIZE: int +ED25519_PUBLIC_SIZE: int +ED25519_SIGNATURE_SIZE: int +X25519_SEED_SIZE: int +X25519_PUBLIC_SIZE: int + +def ed25519_public_from_seed(seed: bytes) -> bytes: ... +def x25519_public_from_seed(seed: bytes) -> bytes: ... +def ed25519_sign(seed: bytes, data: bytes) -> bytes: ... +def ed25519_verify(public: bytes, data: bytes, signature: bytes) -> None: ... +def hpke_seal(recipient_public: bytes, info: bytes, aad: bytes, plaintext: bytes) -> bytes: ... +def hpke_open(recipient_secret: bytes, info: bytes, aad: bytes, blob: bytes) -> bytes: ... + # Exception classes class CryptoError(Exception): """Malfunction in the crypto module.""" diff --git a/src/borg/crypto/low_level.pyx b/src/borg/crypto/low_level.pyx index 7c0e659f0e..c50ee3ecfb 100644 --- a/src/borg/crypto/low_level.pyx +++ b/src/borg/crypto/low_level.pyx @@ -42,7 +42,7 @@ from cpython cimport PyMem_Malloc, PyMem_Free from cpython.buffer cimport PyBUF_SIMPLE, PyObject_GetBuffer, PyBuffer_Release from cpython.bytes cimport PyBytes_FromStringAndSize, PyBytes_AsString from libc.stdlib cimport malloc, free -from libc.stdint cimport uint8_t, uint32_t, uint64_t +from libc.stdint cimport uint8_t, uint16_t, uint32_t, uint64_t from libc.string cimport memset, memcpy @@ -89,6 +89,66 @@ cdef extern from "openssl/evp.h": int EVP_CTRL_AEAD_SET_IVLEN +cdef extern from "openssl/evp.h": + # asymmetric keys (Ed25519 signing, X25519 key agreement for HPKE) + ctypedef struct EVP_PKEY: + pass + ctypedef struct EVP_PKEY_CTX: + pass + ctypedef struct EVP_MD_CTX: + pass + + int EVP_PKEY_ED25519 + int EVP_PKEY_X25519 + + EVP_PKEY *EVP_PKEY_new_raw_private_key(int type, ENGINE *e, const unsigned char *key, size_t keylen) + EVP_PKEY *EVP_PKEY_new_raw_public_key(int type, ENGINE *e, const unsigned char *key, size_t keylen) + int EVP_PKEY_get_raw_public_key(const EVP_PKEY *pkey, unsigned char *pub, size_t *len) + void EVP_PKEY_free(EVP_PKEY *key) + + EVP_MD_CTX *EVP_MD_CTX_new() + void EVP_MD_CTX_free(EVP_MD_CTX *ctx) + int EVP_DigestSignInit(EVP_MD_CTX *ctx, EVP_PKEY_CTX **pctx, const EVP_MD *type, ENGINE *e, EVP_PKEY *pkey) + int EVP_DigestSign(EVP_MD_CTX *ctx, unsigned char *sig, size_t *siglen, const unsigned char *tbs, size_t tbslen) + int EVP_DigestVerifyInit(EVP_MD_CTX *ctx, EVP_PKEY_CTX **pctx, const EVP_MD *type, ENGINE *e, EVP_PKEY *pkey) + int EVP_DigestVerify(EVP_MD_CTX *ctx, const unsigned char *sig, size_t siglen, const unsigned char *tbs, size_t tbslen) + + +cdef extern from "openssl/hpke.h": + ctypedef struct OSSL_HPKE_SUITE: + uint16_t kem_id + uint16_t kdf_id + uint16_t aead_id + ctypedef struct OSSL_HPKE_CTX: + pass + ctypedef struct OSSL_LIB_CTX: + pass + + int OSSL_HPKE_MODE_BASE + int OSSL_HPKE_ROLE_SENDER + int OSSL_HPKE_ROLE_RECEIVER + uint16_t OSSL_HPKE_KEM_ID_X25519 + uint16_t OSSL_HPKE_KDF_ID_HKDF_SHA256 + uint16_t OSSL_HPKE_AEAD_ID_AES_GCM_256 + + OSSL_HPKE_CTX *OSSL_HPKE_CTX_new(int mode, OSSL_HPKE_SUITE suite, int role, + OSSL_LIB_CTX *libctx, const char *propq) + void OSSL_HPKE_CTX_free(OSSL_HPKE_CTX *ctx) + int OSSL_HPKE_encap(OSSL_HPKE_CTX *ctx, unsigned char *enc, size_t *enclen, + const unsigned char *pub, size_t publen, + const unsigned char *info, size_t infolen) + int OSSL_HPKE_seal(OSSL_HPKE_CTX *ctx, unsigned char *ct, size_t *ctlen, + const unsigned char *aad, size_t aadlen, + const unsigned char *pt, size_t ptlen) + int OSSL_HPKE_decap(OSSL_HPKE_CTX *ctx, const unsigned char *enc, size_t enclen, + EVP_PKEY *recippriv, const unsigned char *info, size_t infolen) + int OSSL_HPKE_open(OSSL_HPKE_CTX *ctx, unsigned char *pt, size_t *ptlen, + const unsigned char *aad, size_t aadlen, + const unsigned char *ct, size_t ctlen) + size_t OSSL_HPKE_get_public_encap_size(OSSL_HPKE_SUITE suite) + size_t OSSL_HPKE_get_ciphertext_size(OSSL_HPKE_SUITE suite, size_t clearlen) + + import struct _int = struct.Struct('>I') @@ -669,6 +729,174 @@ def blake2b_128(data): return hashlib.blake2b(data, digest_size=16).digest() +# Asymmetric primitives used for monitoring reports: Ed25519 signatures (authenticity) +# and HPKE (RFC 9180) sealing (confidentiality from the untrusted repo server). Both are +# provided by OpenSSL >= 3.2; key material is 32-byte seeds derived from the borg key. + +ED25519_SEED_SIZE = 32 +ED25519_PUBLIC_SIZE = 32 +ED25519_SIGNATURE_SIZE = 64 +X25519_SEED_SIZE = 32 +X25519_PUBLIC_SIZE = 32 + + +cdef OSSL_HPKE_SUITE _hpke_suite(): + # DHKEM(X25519, HKDF-SHA256), HKDF-SHA256, AES-256-GCM + cdef OSSL_HPKE_SUITE suite + suite.kem_id = OSSL_HPKE_KEM_ID_X25519 + suite.kdf_id = OSSL_HPKE_KDF_ID_HKDF_SHA256 + suite.aead_id = OSSL_HPKE_AEAD_ID_AES_GCM_256 + return suite + + +cdef bytes _raw_public_key(int pkey_type, bytes seed): + if len(seed) != 32: + raise ValueError("raw key seed must be 32 bytes") + cdef EVP_PKEY *pkey = EVP_PKEY_new_raw_private_key(pkey_type, NULL, PyBytes_AsString(seed), 32) + if pkey == NULL: + raise CryptoError("EVP_PKEY_new_raw_private_key failed") + cdef unsigned char pub[32] + cdef size_t publen = 32 + try: + if not EVP_PKEY_get_raw_public_key(pkey, pub, &publen): + raise CryptoError("EVP_PKEY_get_raw_public_key failed") + return PyBytes_FromStringAndSize( pub, publen) + finally: + EVP_PKEY_free(pkey) + + +def ed25519_public_from_seed(bytes seed): + """Return the 32-byte Ed25519 public key for a 32-byte secret seed.""" + return _raw_public_key(EVP_PKEY_ED25519, seed) + + +def x25519_public_from_seed(bytes seed): + """Return the 32-byte X25519 (HPKE) public key for a 32-byte secret seed.""" + return _raw_public_key(EVP_PKEY_X25519, seed) + + +def ed25519_sign(bytes seed, bytes data): + """Sign *data* with the Ed25519 secret *seed* (32 bytes), returning a 64-byte signature.""" + if len(seed) != ED25519_SEED_SIZE: + raise ValueError("ed25519 seed must be 32 bytes") + cdef EVP_PKEY *pkey = EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, NULL, PyBytes_AsString(seed), 32) + if pkey == NULL: + raise CryptoError("EVP_PKEY_new_raw_private_key(ED25519) failed") + cdef EVP_MD_CTX *mdctx = EVP_MD_CTX_new() + cdef unsigned char sig[64] + cdef size_t siglen = 64 + try: + if mdctx == NULL: + raise CryptoError("EVP_MD_CTX_new failed") + if not EVP_DigestSignInit(mdctx, NULL, NULL, NULL, pkey): + raise CryptoError("EVP_DigestSignInit failed") + if not EVP_DigestSign(mdctx, sig, &siglen, PyBytes_AsString(data), len(data)): + raise CryptoError("EVP_DigestSign failed") + return PyBytes_FromStringAndSize( sig, siglen) + finally: + if mdctx != NULL: + EVP_MD_CTX_free(mdctx) + EVP_PKEY_free(pkey) + + +def ed25519_verify(bytes public, bytes data, bytes signature): + """Verify an Ed25519 *signature* over *data* with the 32-byte *public* key. + + Returns None on success, raises IntegrityError on a bad signature. + """ + if len(public) != ED25519_PUBLIC_SIZE: + raise ValueError("ed25519 public key must be 32 bytes") + cdef EVP_PKEY *pkey = EVP_PKEY_new_raw_public_key(EVP_PKEY_ED25519, NULL, PyBytes_AsString(public), 32) + if pkey == NULL: + raise CryptoError("EVP_PKEY_new_raw_public_key(ED25519) failed") + cdef EVP_MD_CTX *mdctx = EVP_MD_CTX_new() + cdef int rc + try: + if mdctx == NULL: + raise CryptoError("EVP_MD_CTX_new failed") + if not EVP_DigestVerifyInit(mdctx, NULL, NULL, NULL, pkey): + raise CryptoError("EVP_DigestVerifyInit failed") + rc = EVP_DigestVerify(mdctx, PyBytes_AsString(signature), len(signature), + PyBytes_AsString(data), len(data)) + if rc != 1: + raise IntegrityError("Ed25519 signature verification failed") + finally: + if mdctx != NULL: + EVP_MD_CTX_free(mdctx) + EVP_PKEY_free(pkey) + + +def hpke_seal(bytes recipient_public, bytes info, bytes aad, bytes plaintext): + """HPKE-seal *plaintext* to the recipient's 32-byte X25519 *public* key. + + Returns enc || ciphertext (the encapsulated key prepended to the AEAD ciphertext). + """ + if len(recipient_public) != X25519_PUBLIC_SIZE: + raise ValueError("recipient public key must be 32 bytes") + cdef OSSL_HPKE_SUITE suite = _hpke_suite() + cdef OSSL_HPKE_CTX *ctx = OSSL_HPKE_CTX_new(OSSL_HPKE_MODE_BASE, suite, OSSL_HPKE_ROLE_SENDER, NULL, NULL) + cdef size_t enclen = OSSL_HPKE_get_public_encap_size(suite) + cdef size_t ctlen = OSSL_HPKE_get_ciphertext_size(suite, len(plaintext)) + cdef unsigned char *enc = malloc(enclen) + cdef unsigned char *ct = malloc(ctlen) + try: + if ctx == NULL or enc == NULL or ct == NULL: + raise CryptoError("HPKE sender setup failed") + if not OSSL_HPKE_encap(ctx, enc, &enclen, + PyBytes_AsString(recipient_public), 32, + PyBytes_AsString(info), len(info)): + raise CryptoError("OSSL_HPKE_encap failed") + if not OSSL_HPKE_seal(ctx, ct, &ctlen, + PyBytes_AsString(aad), len(aad), + PyBytes_AsString(plaintext), len(plaintext)): + raise CryptoError("OSSL_HPKE_seal failed") + return PyBytes_FromStringAndSize( enc, enclen) + PyBytes_FromStringAndSize( ct, ctlen) + finally: + if enc != NULL: + free(enc) + if ct != NULL: + free(ct) + if ctx != NULL: + OSSL_HPKE_CTX_free(ctx) + + +def hpke_open(bytes recipient_secret, bytes info, bytes aad, bytes blob): + """HPKE-open a *blob* (enc || ciphertext) with the recipient's 32-byte X25519 secret. + + Returns the plaintext, raises IntegrityError if opening/authentication fails. + """ + if len(recipient_secret) != X25519_SEED_SIZE: + raise ValueError("recipient secret key must be 32 bytes") + cdef OSSL_HPKE_SUITE suite = _hpke_suite() + cdef size_t enclen = OSSL_HPKE_get_public_encap_size(suite) + if len(blob) < enclen: + raise IntegrityError("HPKE blob too short") + cdef bytes enc = blob[:enclen] + cdef bytes ct = blob[enclen:] + cdef EVP_PKEY *recippriv = EVP_PKEY_new_raw_private_key(EVP_PKEY_X25519, NULL, PyBytes_AsString(recipient_secret), 32) + cdef OSSL_HPKE_CTX *ctx = OSSL_HPKE_CTX_new(OSSL_HPKE_MODE_BASE, suite, OSSL_HPKE_ROLE_RECEIVER, NULL, NULL) + cdef size_t ptlen = len(ct) + 1 + cdef unsigned char *pt = malloc(ptlen) + try: + if recippriv == NULL or ctx == NULL or pt == NULL: + raise CryptoError("HPKE receiver setup failed") + if not OSSL_HPKE_decap(ctx, PyBytes_AsString(enc), len(enc), + recippriv, PyBytes_AsString(info), len(info)): + raise IntegrityError("OSSL_HPKE_decap failed") + if not OSSL_HPKE_open(ctx, pt, &ptlen, + PyBytes_AsString(aad), len(aad), + PyBytes_AsString(ct), len(ct)): + raise IntegrityError("OSSL_HPKE_open failed") + return PyBytes_FromStringAndSize( pt, ptlen) + finally: + if pt != NULL: + free(pt) + if ctx != NULL: + OSSL_HPKE_CTX_free(ctx) + if recippriv != NULL: + EVP_PKEY_free(recippriv) + + cdef class CSPRNG: """ Cryptographically Secure Pseudo-Random Number Generator based on AES-CTR mode. diff --git a/src/borg/crypto/monitoring.py b/src/borg/crypto/monitoring.py new file mode 100644 index 0000000000..01441fc4f8 --- /dev/null +++ b/src/borg/crypto/monitoring.py @@ -0,0 +1,111 @@ +"""Crypto for monitoring reports published into the (untrusted) repository. + +A backup client publishes a small state report into the repo's ``monitoring/`` +namespace after each relevant operation. A monitoring system pulls and verifies it +from the same untrusted server, needing no repo passphrase and no trust in the server: + +- **Authenticity** comes from an Ed25519 signature. The client signs; the monitor only + ever holds the public verify key, so neither the server nor the monitor can forge a + report. +- **Confidentiality from the server** comes from HPKE-sealing the signed payload to the + monitor's X25519 public key, so the server sees ciphertext only. + +All key material is derived deterministically from the existing borg key using borg's +own ``derive_key()`` (the same one-step KDF used for session keys) with fixed monitoring +labels and no random salt. Nothing extra is generated, stored, or rotated. + +For ``--encryption none`` repositories there is no borg key to derive from, so reports +are published as plain JSON, unsigned and unencrypted - consistent with such a repo +being unsafe anyway. The monitor must treat those as untrusted. +""" + +from binascii import hexlify, unhexlify + +from . import low_level +from .key import PlaintextKey + +# derive_key domains (labels). Fixed and distinct so the two seeds are independent. +SIGN_DOMAIN = b"borg-monitoring-sign" +SEAL_DOMAIN = b"borg-monitoring-seal" + +# HPKE info string, bound into the sealed context (domain separation / versioning). +HPKE_INFO = b"borg-monitoring-report-v1" + +# BORG_MONITORING_KEY wire format: ":". Bump on incompatible changes. +MONITOR_KEY_PREFIX = "v1:" + + +def is_signed_repo(key): + """True if this repo has a real borg key, so reports can be signed and sealed.""" + return not isinstance(key, PlaintextKey) + + +def _derive_seed(key, domain, size): + # Same one-step KDF as session keys (sha256(crypt_key + salt + domain)), but with a + # fixed label and NO random salt, so the result is deterministic. Derived from + # crypt_key (not id_key, which related repos share). + return key.derive_key(salt=b"", domain=domain, size=size) + + +def client_material(key): + """Client half: (ed25519_sign_seed, hpke_recipient_public). + + Used by the publishing side to sign with the Ed25519 secret seed and seal + to the monitor's HPKE public key. + """ + sign_seed = _derive_seed(key, SIGN_DOMAIN, low_level.ED25519_SEED_SIZE) + seal_seed = _derive_seed(key, SEAL_DOMAIN, low_level.X25519_SEED_SIZE) + hpke_public = low_level.x25519_public_from_seed(seal_seed) + return sign_seed, hpke_public + + +def monitor_material(key): + """Monitor half: (ed25519_verify_public, hpke_recipient_secret). + + This is everything the monitoring system needs to verify and decrypt and + nothing more: it cannot derive the signing secret or the borg key from it. + """ + sign_seed = _derive_seed(key, SIGN_DOMAIN, low_level.ED25519_SEED_SIZE) + seal_seed = _derive_seed(key, SEAL_DOMAIN, low_level.X25519_SEED_SIZE) + ed_public = low_level.ed25519_public_from_seed(sign_seed) + return ed_public, seal_seed + + +def export_monitor_key(key): + """Return the env-safe BORG_MONITORING_KEY string (the monitor half).""" + ed_public, hpke_secret = monitor_material(key) + return MONITOR_KEY_PREFIX + hexlify(ed_public + hpke_secret).decode("ascii") + + +def parse_monitor_key(text): + """Parse a BORG_MONITORING_KEY string into (ed25519_verify_public, hpke_secret).""" + text = text.strip() + if not text.startswith(MONITOR_KEY_PREFIX): + raise ValueError("BORG_MONITORING_KEY: unsupported format/version") + raw = unhexlify(text[len(MONITOR_KEY_PREFIX) :]) + if len(raw) != low_level.ED25519_PUBLIC_SIZE + low_level.X25519_SEED_SIZE: + raise ValueError("BORG_MONITORING_KEY: wrong length") + return raw[: low_level.ED25519_PUBLIC_SIZE], raw[low_level.ED25519_PUBLIC_SIZE :] + + +def seal_report(key, payload, aad): + """Sign *payload* (bytes) with the client's Ed25519 secret, then HPKE-seal it. + + *aad* (bytes, e.g. the repo id) is bound into both the HPKE seal context, so a sealed + report cannot be transplanted to a different repo. Returns the sealed envelope bytes. + """ + sign_seed, hpke_public = client_material(key) + signature = low_level.ed25519_sign(sign_seed, payload) + return low_level.hpke_seal(hpke_public, HPKE_INFO, aad, signature + payload) + + +def open_report(ed_public, hpke_secret, blob, aad): + """HPKE-open *blob*, verify the Ed25519 signature, and return the *payload* bytes. + + Raises low_level.IntegrityError if decryption or signature verification fails. + """ + signed = low_level.hpke_open(hpke_secret, HPKE_INFO, aad, blob) + siglen = low_level.ED25519_SIGNATURE_SIZE + signature, payload = signed[:siglen], signed[siglen:] + low_level.ed25519_verify(ed_public, payload, signature) + return payload diff --git a/src/borg/monitoring.py b/src/borg/monitoring.py new file mode 100644 index 0000000000..5cc9b352a9 --- /dev/null +++ b/src/borg/monitoring.py @@ -0,0 +1,196 @@ +"""Build, publish and read monitoring reports (see crypto/monitoring.py for the crypto). + +Each backup-side command (create, prune, ...) appends one report object to the repo's +``monitoring/`` namespace; a monitoring system reads them back and verifies them. The +on-disk object is:: + + byte 0: format version + byte 1: body type (0 = plaintext JSON, 1 = sealed: HPKE(ed25519-sign || JSON)) + rest: body + +The HPKE seal is bound (via aad) to the repository id, which both sides obtain from the +repository they are talking to - it is never trusted from inside the ciphertext. + +Objects are append-only and named by publish time, so reports for different archive +series never overwrite each other (e.g. a failed home backup is not masked by a later +successful system backup). The namespace is bounded by ``borg monitor --keep=N``, which +deletes all but the N newest objects. +""" + +import json +import logging +import os +import time +from binascii import hexlify + +from borgstore.store import ItemInfo +from borgstore.store import ObjectNotFound as StoreObjectNotFound + +from . import __version__ +from .constants import EXIT_SUCCESS, EXIT_WARNING, EXIT_WARNING_BASE, EXIT_SIGNAL_BASE +from .crypto import monitoring as mon_crypto +from .helpers import bin_to_hex, get_ec +from .helpers.time import archive_ts_now + +logger = logging.getLogger(__name__) + +STORE_NAMESPACE = "monitoring" + +# Default number of newest report objects to keep when running "borg monitor". +DEFAULT_KEEP = 500 + + +def _new_object_name(): + """A unique, chronologically sortable object name. + + Microseconds since the epoch, zero-padded to a fixed width so lexical sort equals + chronological order, plus a random suffix so same-microsecond or concurrent publishes + never collide (and never overwrite an existing report). + """ + us = int(time.time() * 1_000_000) + return f"{us:020d}.{hexlify(os.urandom(4)).decode('ascii')}" + + +FORMAT_VERSION = 1 +BODY_PLAIN = 0 +BODY_SEALED = 1 + + +def status_from_rc(rc): + """Map a borg return code to a coarse status string. + + See constants.py: 0 = success, 1 = generic warning, 100..127 = specific warnings, + everything else (generic/specific errors, signals) = error. + """ + if rc == EXIT_SUCCESS: + return "success" + if rc == EXIT_WARNING or EXIT_WARNING_BASE <= rc < EXIT_SIGNAL_BASE: + return "warning" + return "error" + + +def build_report(*, command, repo_id, time, rc, archive=None, archive_id=None, stats=None): + """Assemble the report dict. *repo_id*/*archive_id* are hex strings, *time* is ISO.""" + report = { + "borg_version": __version__, + "repo_id": repo_id, + "command": command, + "time": time, + "status": status_from_rc(rc), + "rc": rc, + } + if archive is not None: + report["archive"] = archive + if archive_id is not None: + report["archive_id"] = archive_id + if stats is not None: + report["stats"] = stats + return report + + +def serialize(key, repo_id_bin, report): + """Serialize *report* into the on-disk object bytes, sealing it if the repo is encrypted.""" + payload = json.dumps(report, sort_keys=True).encode("utf-8") + if mon_crypto.is_signed_repo(key): + body = mon_crypto.seal_report(key, payload, repo_id_bin) + body_type = BODY_SEALED + else: + body = payload + body_type = BODY_PLAIN + return bytes([FORMAT_VERSION, body_type]) + body + + +def deserialize(monitor_key, repo_id_bin, data): + """Return (report_dict, trusted: bool). + + *monitor_key* is the parsed (ed25519_public, hpke_secret) tuple, or None. A sealed + report is verified+decrypted (trusted=True); a plaintext report is returned as-is + (trusted=False). Raises ValueError/IntegrityError on malformed or unverifiable data. + """ + if len(data) < 2 or data[0] != FORMAT_VERSION: + raise ValueError("monitoring report: unsupported format version") + body_type, body = data[1], data[2:] + if body_type == BODY_SEALED: + if monitor_key is None: + raise ValueError("monitoring report is sealed but no BORG_MONITORING_KEY was given") + ed_public, hpke_secret = monitor_key + payload = mon_crypto.open_report(ed_public, hpke_secret, body, repo_id_bin) + trusted = True + elif body_type == BODY_PLAIN: + payload = body + trusted = False + else: + raise ValueError("monitoring report: unknown body type") + return json.loads(payload.decode("utf-8")), trusted + + +def publish(repository, key, report): + """Append *report* to the repository as a new object. Best-effort: never raise out.""" + try: + data = serialize(key, repository.id, report) + repository.store_store(f"{STORE_NAMESPACE}/{_new_object_name()}", data) + except Exception as exc: + logger.warning("Could not publish monitoring report: %s", exc) + + +def publish_command_report(repository, key, command, *, archive=None, archive_id=None, stats=None): + """Build and publish a report for a finished command. + + Captures the best-known return code at call time (the true process rc is only final + after the store is closed; see borg/monitoring.py). Call this as the last action while + the store is still open. *archive_id* is binary; it is hex-encoded for the report. + """ + report = build_report( + command=command, + repo_id=bin_to_hex(repository.id), + time=archive_ts_now().isoformat(timespec="microseconds"), + rc=get_ec(), + archive=archive, + archive_id=bin_to_hex(archive_id) if archive_id is not None else None, + stats=stats, + ) + publish(repository, key, report) + + +def list_names(repository): + """Return all monitoring object names, oldest first (names sort chronologically).""" + names = [ItemInfo(*info).name for info in repository.store_list(STORE_NAMESPACE)] + names.sort() + return names + + +def iter_reports(repository, monitor_key): + """Yield (report, trusted) for every stored report, oldest first. + + Each report is verified and decrypted; an unverifiable one raises (it is not silently + skipped) so tampering surfaces. + """ + for name in list_names(repository): + try: + data = repository.store_load(f"{STORE_NAMESPACE}/{name}") + except StoreObjectNotFound: + continue # raced with a concurrent --keep cleanup + yield deserialize(monitor_key, repository.id, data) + + +def prune_reports(repository, keep): + """Delete all but the *keep* newest report objects. Best-effort; returns #deleted. + + Needs delete permission on the monitoring namespace; on a permission error (e.g. a + read-only monitoring host) it warns and stops rather than failing the command. + """ + if keep is None or keep <= 0: + return 0 # 0 (or negative) disables cleanup + names = list_names(repository) + to_delete = names[:-keep] + deleted = 0 + for name in to_delete: + try: + repository.store_delete(f"{STORE_NAMESPACE}/{name}") + deleted += 1 + except StoreObjectNotFound: + pass # already gone (concurrent cleanup) + except Exception as exc: + logger.warning("Could not delete old monitoring report %s: %s", name, exc) + break # likely missing delete permission; do not hammer the server + return deleted diff --git a/src/borg/repository.py b/src/borg/repository.py index 22af077221..a4f9de9a25 100644 --- a/src/borg/repository.py +++ b/src/borg/repository.py @@ -62,6 +62,7 @@ def borg_permissions(permissions): "index": "lrwWD", # WD for index/ (merge/compaction of incremental indexes) "keys": "lr", "locks": "lrwD", # borg needs to create/delete a shared lock here + "monitoring": "lrw", # append new report objects (cleanup is done by borg monitor) "packs": "lrw", } case "write-only": # mostly no reading @@ -73,10 +74,13 @@ def borg_permissions(permissions): "index": "lrwWD", # read allowed so that borg create can check chunk presence for deduplication "keys": "lr", "locks": "lrwD", # borg needs to create/delete a shared lock here + "monitoring": "lw", # append new report objects (cleanup is done by borg monitor) "packs": "lw", # no r! } case "read-only": # mostly r/o - return {"": "lr", "locks": "lrwD"} + # "monitoring": lrD lets a restricted monitoring host run "borg monitor --keep" + # (read all reports, delete old ones) without any other write access. + return {"": "lr", "locks": "lrwD", "monitoring": "lrD"} case _: raise Error( f"Invalid BORG_REPO_PERMISSIONS value: {permissions}, should be one of: " @@ -450,6 +454,7 @@ def __init__( "index/": {"levels": [0]}, "keys/": {"levels": [0]}, "locks/": {"levels": [0]}, + "monitoring/": {"levels": [0]}, "packs/": {"levels": [1]}, } # Get permissions from parameter or environment variable diff --git a/src/borg/testsuite/archiver/monitor_cmd_test.py b/src/borg/testsuite/archiver/monitor_cmd_test.py new file mode 100644 index 0000000000..264f1444ac --- /dev/null +++ b/src/borg/testsuite/archiver/monitor_cmd_test.py @@ -0,0 +1,147 @@ +import json +import os + +from ...constants import * # NOQA +from . import cmd, create_regular_file, generate_archiver_tests, RK_ENCRYPTION + +pytest_generate_tests = lambda metafunc: generate_archiver_tests(metafunc, kinds="local") # NOQA + + +def _monitoring_key(archiver): + """Derive BORG_MONITORING_KEY via `borg monitor --key` (needs the borg key).""" + output = cmd(archiver, "monitor", "--key") + keys = [line.strip() for line in output.splitlines() if line.strip().startswith("v1:")] + assert len(keys) == 1, output + return keys[0] + + +def _entries(archiver): + """Run `borg monitor --json` and return {unit: entry}.""" + data = json.loads(cmd(archiver, "monitor", "--json")) + return {e["unit"]: e for e in data["entries"]} + + +def _monitoring_object_count(archiver): + return len(os.listdir(os.path.join(archiver.repository_path, "monitoring"))) + + +def test_create_publishes_report_and_monitor_reads_it(archivers, request, monkeypatch): + archiver = request.getfixturevalue(archivers) + create_regular_file(archiver.input_path, "file1", contents=b"some data") + cmd(archiver, "repo-create", RK_ENCRYPTION) + cmd(archiver, "create", "archive1", "input") + monkeypatch.setenv("BORG_MONITORING_KEY", _monitoring_key(archiver)) + + output = cmd(archiver, "monitor") + assert "status: success" in output + assert "trusted: True" in output + assert "archive1" in output + + entries = _entries(archiver) + assert set(entries) == {"archive1"} + e = entries["archive1"] + assert e["trusted"] is True and e["stale"] is False + assert e["report"]["command"] == "create" + assert e["report"]["status"] == "success" + assert "archive_id" in e["report"] + + +def test_multiple_series_are_not_masked(archivers, request, monkeypatch): + archiver = request.getfixturevalue(archivers) + create_regular_file(archiver.input_path, "file1", contents=b"some data") + cmd(archiver, "repo-create", RK_ENCRYPTION) + # two independent archive series backed up by the same command + cmd(archiver, "create", "backup-home", "input") + cmd(archiver, "create", "backup-system", "input") + monkeypatch.setenv("BORG_MONITORING_KEY", _monitoring_key(archiver)) + + # both series are reported independently - the later one does not overwrite the earlier + entries = _entries(archiver) + assert set(entries) == {"backup-home", "backup-system"} + + # --name restricts to a single series + data = json.loads(cmd(archiver, "monitor", "--name", "backup-home", "--json")) + assert [e["unit"] for e in data["entries"]] == ["backup-home"] + + +def test_prune_publishes_its_own_report(archivers, request, monkeypatch): + archiver = request.getfixturevalue(archivers) + create_regular_file(archiver.input_path, "file1", contents=b"some data") + cmd(archiver, "repo-create", RK_ENCRYPTION) + # one series with two archives, then prune down to one + cmd(archiver, "create", "series", "input") + cmd(archiver, "create", "series", "input") + cmd(archiver, "prune", "--keep", "1", "series") + monkeypatch.setenv("BORG_MONITORING_KEY", _monitoring_key(archiver)) + + entries = _entries(archiver) + assert set(entries) == {"series", "prune"} + assert entries["series"]["report"]["command"] == "create" + prune = entries["prune"]["report"] + assert prune["command"] == "prune" + assert prune["stats"]["archives_pruned"] == 1 + assert prune["stats"]["archives_kept"] == 1 + + +def test_keep_evicts_old_objects(archivers, request, monkeypatch): + archiver = request.getfixturevalue(archivers) + create_regular_file(archiver.input_path, "file1", contents=b"some data") + cmd(archiver, "repo-create", RK_ENCRYPTION) + for _ in range(4): + cmd(archiver, "create", "series", "input") + assert _monitoring_object_count(archiver) == 4 + monkeypatch.setenv("BORG_MONITORING_KEY", _monitoring_key(archiver)) + # reading with --keep deletes all but the N newest objects + cmd(archiver, "monitor", "--keep", "2") + assert _monitoring_object_count(archiver) == 2 + # --keep 0 disables cleanup + cmd(archiver, "monitor", "--keep", "0") + assert _monitoring_object_count(archiver) == 2 + + +def test_monitor_stale_report_alerts(archivers, request, monkeypatch): + archiver = request.getfixturevalue(archivers) + create_regular_file(archiver.input_path, "file1", contents=b"some data") + cmd(archiver, "repo-create", RK_ENCRYPTION) + cmd(archiver, "create", "archive1", "input") + monkeypatch.setenv("BORG_MONITORING_KEY", _monitoring_key(archiver)) + # a zero freshness window makes any report stale -> error exit code (dead man's switch) + output = cmd(archiver, "monitor", "--max-age", "0", exit_code=EXIT_ERROR) + assert "STALE" in output + + +def test_monitor_no_report(archivers, request, monkeypatch): + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + monkeypatch.setenv("BORG_MONITORING_KEY", _monitoring_key(archiver)) + output = cmd(archiver, "monitor", exit_code=EXIT_ERROR) + assert "No monitoring report" in output + + +def test_monitor_without_key_errors(archivers, request): + archiver = request.getfixturevalue(archivers) + create_regular_file(archiver.input_path, "file1", contents=b"some data") + cmd(archiver, "repo-create", RK_ENCRYPTION) + cmd(archiver, "create", "archive1", "input") + # sealed report but no BORG_MONITORING_KEY -> clean Error (use fork so it maps to a rc) + output = cmd(archiver, "monitor", fork=True, exit_code=EXIT_ERROR) + assert "BORG_MONITORING_KEY" in output + + +def test_monitor_unencrypted_repo_is_untrusted(archivers, request): + archiver = request.getfixturevalue(archivers) + create_regular_file(archiver.input_path, "file1", contents=b"some data") + cmd(archiver, "repo-create", "--encryption=none") + cmd(archiver, "create", "archive1", "input") + # no key needed; report is plaintext and flagged untrusted -> warning exit code + output = cmd(archiver, "monitor", exit_code=EXIT_WARNING) + assert "trusted: False" in output + # there is no monitoring key to export for an unencrypted repo + out = cmd(archiver, "monitor", "--key", fork=True, exit_code=EXIT_ERROR) + assert "unencrypted" in out + + +def test_monitor_key_export_is_deterministic(archivers, request): + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + assert _monitoring_key(archiver) == _monitoring_key(archiver) diff --git a/src/borg/testsuite/monitoring_test.py b/src/borg/testsuite/monitoring_test.py new file mode 100644 index 0000000000..6094f2f096 --- /dev/null +++ b/src/borg/testsuite/monitoring_test.py @@ -0,0 +1,212 @@ +import os + +import pytest + +from ..constants import EXIT_SUCCESS, EXIT_WARNING, EXIT_ERROR, EXIT_SIGNAL_BASE +from ..crypto import low_level +from ..crypto.key import AESOCBKey, PlaintextKey +from ..crypto import monitoring as mc +from .. import monitoring as m + + +def make_key(): + """A minimal AEAD key with a real random crypt_key (enough for derive_key).""" + key = AESOCBKey.__new__(AESOCBKey) + key.crypt_key = os.urandom(32) + key.id_key = os.urandom(32) + return key + + +# --- low-level crypto primitives ------------------------------------------------------- + + +def test_ed25519_sign_verify_roundtrip(): + seed = os.urandom(32) + pub = low_level.ed25519_public_from_seed(seed) + assert len(pub) == 32 + sig = low_level.ed25519_sign(seed, b"hello") + assert len(sig) == 64 + low_level.ed25519_verify(pub, b"hello", sig) # must not raise + + +def test_ed25519_rejects_tampered_message(): + seed = os.urandom(32) + pub = low_level.ed25519_public_from_seed(seed) + sig = low_level.ed25519_sign(seed, b"hello") + with pytest.raises(low_level.IntegrityError): + low_level.ed25519_verify(pub, b"hello!", sig) + + +def test_ed25519_rejects_wrong_key(): + seed = os.urandom(32) + sig = low_level.ed25519_sign(seed, b"hello") + other_pub = low_level.ed25519_public_from_seed(os.urandom(32)) + with pytest.raises(low_level.IntegrityError): + low_level.ed25519_verify(other_pub, b"hello", sig) + + +def test_hpke_seal_open_roundtrip(): + rseed = os.urandom(32) + rpub = low_level.x25519_public_from_seed(rseed) + blob = low_level.hpke_seal(rpub, b"info", b"aad", b"secret payload") + assert low_level.hpke_open(rseed, b"info", b"aad", blob) == b"secret payload" + + +def test_hpke_rejects_wrong_recipient(): + rpub = low_level.x25519_public_from_seed(os.urandom(32)) + blob = low_level.hpke_seal(rpub, b"info", b"aad", b"secret") + with pytest.raises(low_level.IntegrityError): + low_level.hpke_open(os.urandom(32), b"info", b"aad", blob) + + +def test_hpke_rejects_wrong_aad(): + rseed = os.urandom(32) + rpub = low_level.x25519_public_from_seed(rseed) + blob = low_level.hpke_seal(rpub, b"info", b"aad", b"secret") + with pytest.raises(low_level.IntegrityError): + low_level.hpke_open(rseed, b"info", b"other-aad", blob) + + +# --- key derivation -------------------------------------------------------------------- + + +def test_derivation_is_deterministic(): + key = make_key() + assert mc.client_material(key) == mc.client_material(key) + assert mc.monitor_material(key) == mc.monitor_material(key) + + +def test_client_and_monitor_halves_are_consistent(): + key = make_key() + sign_seed, hpke_public = mc.client_material(key) + ed_public, hpke_secret = mc.monitor_material(key) + assert ed_public == low_level.ed25519_public_from_seed(sign_seed) + assert hpke_public == low_level.x25519_public_from_seed(hpke_secret) + + +def test_labels_yield_independent_keys(): + key = make_key() + sign_seed, _ = mc.client_material(key) + _, hpke_secret = mc.monitor_material(key) + assert sign_seed != hpke_secret + + +def test_monitor_half_does_not_contain_signing_secret(): + key = make_key() + sign_seed, _ = mc.client_material(key) + ed_public, hpke_secret = mc.monitor_material(key) + assert sign_seed not in (ed_public, hpke_secret) + + +def test_derivation_is_per_key_unique(): + assert mc.client_material(make_key()) != mc.client_material(make_key()) + + +def test_export_parse_monitor_key_roundtrip(): + key = make_key() + text = mc.export_monitor_key(key) + assert text.startswith(mc.MONITOR_KEY_PREFIX) + assert mc.parse_monitor_key(text) == mc.monitor_material(key) + + +def test_parse_monitor_key_rejects_bad_input(): + with pytest.raises(ValueError): + mc.parse_monitor_key("nope") + with pytest.raises(ValueError): + mc.parse_monitor_key("v1:00") + + +def test_plaintext_repo_is_not_signed(): + assert mc.is_signed_repo(make_key()) is True + assert mc.is_signed_repo(PlaintextKey.__new__(PlaintextKey)) is False + + +# --- report build / serialize / deserialize ------------------------------------------- + + +def sample_report(repo_id_hex): + return m.build_report( + command="create", + repo_id=repo_id_hex, + time="2026-06-17T11:59:58.123456+00:00", + rc=EXIT_SUCCESS, + archive="host-2026-06-17", + archive_id="aa" * 32, + stats={"original_size": 10485760, "nfiles": 1234}, + ) + + +def test_status_from_rc(): + assert m.status_from_rc(EXIT_SUCCESS) == "success" + assert m.status_from_rc(EXIT_WARNING) == "warning" + assert m.status_from_rc(100) == "warning" # specific warning range + assert m.status_from_rc(EXIT_ERROR) == "error" + assert m.status_from_rc(3) == "error" # specific error range + assert m.status_from_rc(EXIT_SIGNAL_BASE + 2) == "error" + + +def test_build_report_schema(): + report = sample_report("bb" * 32) + assert report["command"] == "create" + assert report["status"] == "success" + assert report["archive_id"] == "aa" * 32 + assert "borg_version" in report and "time" in report + + +def test_sealed_report_roundtrip_and_trusted(): + key = make_key() + repo_id = os.urandom(32) + report = sample_report(repo_id.hex()) + data = m.serialize(key, repo_id, report) + assert data[0] == m.FORMAT_VERSION and data[1] == m.BODY_SEALED + monitor_key = mc.parse_monitor_key(mc.export_monitor_key(key)) + got, trusted = m.deserialize(monitor_key, repo_id, data) + assert got == report and trusted is True + + +def test_sealed_report_rejects_wrong_repo_id(): + key = make_key() + repo_id = os.urandom(32) + data = m.serialize(key, repo_id, sample_report(repo_id.hex())) + monitor_key = mc.parse_monitor_key(mc.export_monitor_key(key)) + with pytest.raises(low_level.IntegrityError): + m.deserialize(monitor_key, os.urandom(32), data) + + +def test_sealed_report_rejects_tamper(): + key = make_key() + repo_id = os.urandom(32) + data = bytearray(m.serialize(key, repo_id, sample_report(repo_id.hex()))) + data[-1] ^= 1 + monitor_key = mc.parse_monitor_key(mc.export_monitor_key(key)) + with pytest.raises(low_level.IntegrityError): + m.deserialize(monitor_key, repo_id, bytes(data)) + + +def test_sealed_report_rejects_wrong_monitor_key(): + key = make_key() + repo_id = os.urandom(32) + data = m.serialize(key, repo_id, sample_report(repo_id.hex())) + wrong = mc.parse_monitor_key(mc.export_monitor_key(make_key())) + with pytest.raises(low_level.IntegrityError): + m.deserialize(wrong, repo_id, data) + + +def test_sealed_report_requires_key(): + key = make_key() + repo_id = os.urandom(32) + data = m.serialize(key, repo_id, sample_report(repo_id.hex())) + with pytest.raises(ValueError): + m.deserialize(None, repo_id, data) + + +def test_plaintext_report_roundtrip_is_untrusted(): + key = PlaintextKey.__new__(PlaintextKey) + key.crypt_key = b"" + key.id_key = b"" + repo_id = os.urandom(32) + report = sample_report(repo_id.hex()) + data = m.serialize(key, repo_id, report) + assert data[1] == m.BODY_PLAIN + got, trusted = m.deserialize(None, repo_id, data) + assert got == report and trusted is False From 35e95a44cad3d7e460f8133984e1f9e4845ebed5 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Thu, 18 Jun 2026 02:20:20 +0200 Subject: [PATCH 2/7] temp: use ubuntu 26.04 for OpenSSL >= 3.2, reduce CI to necessary jobs --- .github/workflows/ci.yml | 25 ++++++------------------- .github/workflows/codeql-analysis.yml | 4 ++-- 2 files changed, 8 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ceaa1da00..0d50043bbf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,7 +59,7 @@ jobs: asan_ubsan: - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 timeout-minutes: 25 needs: [lint] @@ -73,7 +73,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v7 with: - python-version: '3.12' + python-version: '3.14' - name: Install system packages run: | @@ -141,21 +141,13 @@ jobs: ${{ fromJSON( github.event_name == 'pull_request' && '{ "include": [ - {"os": "ubuntu-24.04", "python-version": "3.11", "toxenv": "mypy"}, - {"os": "ubuntu-24.04", "python-version": "3.11", "toxenv": "docs"}, - {"os": "ubuntu-24.04", "python-version": "3.11", "toxenv": "py311-llfuse"}, - {"os": "ubuntu-24.04", "python-version": "3.12", "toxenv": "py312-pyfuse3", "store_cache": "1"}, - {"os": "ubuntu-24.04", "python-version": "3.14", "toxenv": "py314-mfusepy"} + {"os": "ubuntu-26.04", "python-version": "3.14", "toxenv": "mypy"}, + {"os": "ubuntu-26.04", "python-version": "3.14", "toxenv": "docs"}, + {"os": "ubuntu-26.04", "python-version": "3.14", "toxenv": "py314-mfusepy", "store_cache": "1"}, + {"os": "macos-15", "python-version": "3.14", "toxenv": "py314-none", "binary": "borg-macos-15-arm64-gh"} ] }' || '{ "include": [ - {"os": "ubuntu-24.04", "python-version": "3.11", "toxenv": "py311-llfuse"}, - {"os": "ubuntu-24.04", "python-version": "3.12", "toxenv": "py312-pyfuse3"}, - {"os": "ubuntu-24.04", "python-version": "3.13", "toxenv": "py313-mfusepy", "store_cache": "1"}, - {"os": "ubuntu-24.04", "python-version": "3.14", "toxenv": "py314-pyfuse3", "binary": "borg-linux-glibc239-x86_64-gh"}, - {"os": "ubuntu-24.04-arm", "python-version": "3.14", "toxenv": "py314-pyfuse3", "binary": "borg-linux-glibc239-arm64-gh"}, - {"os": "macos-15", "python-version": "3.14", "toxenv": "py314-none", "binary": "borg-macos-15-arm64-gh"}, - {"os": "macos-15-intel", "python-version": "3.14", "toxenv": "py314-none", "binary": "borg-macos-15-x86_64-gh"} ] }' ) }} @@ -402,11 +394,6 @@ jobs: do_binaries: true artifact_prefix: borg-freebsd-15-x86_64-gh - - os: netbsd - version: '10.1' - display_name: NetBSD - do_binaries: false - - os: openbsd version: '7.8' display_name: OpenBSD diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 7dbf7605e0..81b96b7003 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -30,7 +30,7 @@ concurrency: jobs: analyze: name: Analyze - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 timeout-minutes: 20 permissions: actions: read @@ -53,7 +53,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v7 with: - python-version: 3.11 + python-version: 3.14 - name: Cache pip uses: actions/cache@v6 with: From db7c9ec78a22ae7cb706044dc8720cc201b2c531 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Thu, 18 Jun 2026 15:34:44 +0200 Subject: [PATCH 3/7] monitoring: record host/user, report per backup job, add --host/--user Reports now include the hostname and username of the backup (for create, the archive's own host/user; for repo-wide commands like prune, the local host/user). borg monitor groups reports by (host, user, command, archive series) and reports each distinct backup job independently, so several hosts backing up the same archive series name to one repository no longer mask each other. New --host and --user options restrict the output (alongside --name and --command); the JSON entries expose hostname/username/command/archive. Co-Authored-By: Claude Opus 4.8 --- docs/usage/monitor.rst.inc | 77 ++++++------ src/borg/archiver/monitor_cmd.py | 110 ++++++++++++------ src/borg/monitoring.py | 18 ++- .../testsuite/archiver/monitor_cmd_test.py | 38 +++++- 4 files changed, 166 insertions(+), 77 deletions(-) diff --git a/docs/usage/monitor.rst.inc b/docs/usage/monitor.rst.inc index 0508ee25e1..110714910c 100644 --- a/docs/usage/monitor.rst.inc +++ b/docs/usage/monitor.rst.inc @@ -12,25 +12,29 @@ borg monitor .. class:: borg-options-table - +-------------------------------------------------------+-----------------------+------------------------------------------------------------------------------------------------------------------------+ - | **options** | - +-------------------------------------------------------+-----------------------+------------------------------------------------------------------------------------------------------------------------+ - | | ``--key`` | derive and print BORG_MONITORING_KEY for this repository (needs the borg key) | - +-------------------------------------------------------+-----------------------+------------------------------------------------------------------------------------------------------------------------+ - | | ``--name SERIES`` | only report on the given archive series (e.g. the name used with borg create) | - +-------------------------------------------------------+-----------------------+------------------------------------------------------------------------------------------------------------------------+ - | | ``--command COMMAND`` | only report on the given command, e.g. create or prune (default: all commands) | - +-------------------------------------------------------+-----------------------+------------------------------------------------------------------------------------------------------------------------+ - | | ``--max-age SECONDS`` | freshness window in seconds; older reports count as stale (default: 90000) | - +-------------------------------------------------------+-----------------------+------------------------------------------------------------------------------------------------------------------------+ - | | ``--keep N`` | after reading, delete all but the N newest report objects (needs delete permission; 0 = do not clean up; default: 500) | - +-------------------------------------------------------+-----------------------+------------------------------------------------------------------------------------------------------------------------+ - | | ``--json`` | format output as JSON | - +-------------------------------------------------------+-----------------------+------------------------------------------------------------------------------------------------------------------------+ - | .. class:: borg-common-opt-ref | - | | - | :ref:`common_options` | - +-------------------------------------------------------+-----------------------+------------------------------------------------------------------------------------------------------------------------+ + +-------------------------------------------------------+-----------------------+-----------------------------------------------------------------------------------------------+ + | **options** | + +-------------------------------------------------------+-----------------------+-----------------------------------------------------------------------------------------------+ + | | ``--key`` | derive and print BORG_MONITORING_KEY for this repository (needs the borg key) | + +-------------------------------------------------------+-----------------------+-----------------------------------------------------------------------------------------------+ + | | ``--name SERIES`` | only report on the given archive series (e.g. the name used with borg create) | + +-------------------------------------------------------+-----------------------+-----------------------------------------------------------------------------------------------+ + | | ``--command COMMAND`` | only report on the given command, e.g. create or prune (default: all commands) | + +-------------------------------------------------------+-----------------------+-----------------------------------------------------------------------------------------------+ + | | ``--host HOSTNAME`` | only report on backups made from the given host | + +-------------------------------------------------------+-----------------------+-----------------------------------------------------------------------------------------------+ + | | ``--user USERNAME`` | only report on backups made by the given user | + +-------------------------------------------------------+-----------------------+-----------------------------------------------------------------------------------------------+ + | | ``--max-age SECONDS`` | freshness window in seconds; older reports count as stale (default: 90000) | + +-------------------------------------------------------+-----------------------+-----------------------------------------------------------------------------------------------+ + | | ``--keep N`` | after reading, delete all but the N newest report objects (0 = do not clean up; default: 500) | + +-------------------------------------------------------+-----------------------+-----------------------------------------------------------------------------------------------+ + | | ``--json`` | format output as JSON | + +-------------------------------------------------------+-----------------------+-----------------------------------------------------------------------------------------------+ + | .. class:: borg-common-opt-ref | + | | + | :ref:`common_options` | + +-------------------------------------------------------+-----------------------+-----------------------------------------------------------------------------------------------+ .. raw:: html @@ -45,12 +49,14 @@ borg monitor options - --key derive and print BORG_MONITORING_KEY for this repository (needs the borg key) - --name SERIES only report on the given archive series (e.g. the name used with borg create) + --key derive and print BORG_MONITORING_KEY for this repository (needs the borg key) + --name SERIES only report on the given archive series (e.g. the name used with borg create) --command COMMAND only report on the given command, e.g. create or prune (default: all commands) + --host HOSTNAME only report on backups made from the given host + --user USERNAME only report on backups made by the given user --max-age SECONDS freshness window in seconds; older reports count as stale (default: 90000) - --keep N after reading, delete all but the N newest report objects (needs delete permission; 0 = do not clean up; default: 500) - --json format output as JSON + --keep N after reading, delete all but the N newest report objects (0 = do not clean up; default: 500) + --json format output as JSON :ref:`common_options` @@ -59,25 +65,24 @@ borg monitor Description ~~~~~~~~~~~ -Read or export trusted monitoring state of a repository. +Read trusted monitoring state of a repository. -Backup-side commands publish a small signed-and-encrypted state report into the -repository after each run. Because each report is signed with a key derived from -the borg key, the (untrusted) repository server can neither forge nor read it - it -can only relay it. A monitoring system can therefore pull and verify the reports -from the same server without the repository passphrase. +Borg client commands publish a signed-and-encrypted state report into the +repository after each run. Only borg monitor can read these reports using +the monitoring key. Setup (once, on a host that has the borg key):: - BORG_MONITORING_KEY=$(borg monitor --key) + borg monitor --key # this outputs the monitoring key -Then, on the monitoring host, with that value exported as BORG_MONITORING_KEY:: +Then, on the monitoring host:: - borg monitor + BORG_MONITORING_KEY= borg monitor -This verifies and decrypts the reports and prints, per archive series (and per -maintenance command), the latest status and its age. It exits with a non-zero code -(warning or error) if any series is missing, stale (older than --max-age), unsigned, -or did not indicate success - so it can drive alerting like a dead man's switch. +This verifies and decrypts the reports and prints, for each distinct backup job +(host, user, command, archive series), the latest status and its age. It exits with +a non-zero code (warning or error) if any job is missing, stale (older than +--max-age), unsigned, or did not indicate success - so it can drive alerting like a +dead man's switch. Use --name, --command, --host and --user to restrict the output. Reports accumulate over time; --keep=N (default 500) deletes all but the N newest after reading. \ No newline at end of file diff --git a/src/borg/archiver/monitor_cmd.py b/src/borg/archiver/monitor_cmd.py index e27270cf84..eacf73e25f 100644 --- a/src/borg/archiver/monitor_cmd.py +++ b/src/borg/archiver/monitor_cmd.py @@ -27,12 +27,13 @@ def do_monitor(self, args, repository): Without arguments this reads the monitoring reports that backup-side commands published into the repository, verifies and decrypts them using the key from the - BORG_MONITORING_KEY environment variable, and reports - per archive series (and per - maintenance command) - the latest status and freshness. Because each series is - reported independently, a later successful backup of one series does not mask an - earlier failed backup of another. Restrict the output with --name (one archive - series) or --command (e.g. create or prune). Neither the repository passphrase nor - the borg key is needed for reading. + BORG_MONITORING_KEY environment variable, and reports the latest status and + freshness of each distinct backup job - identified by host, user, command and + archive series. Because each job is reported independently, a later successful + backup does not mask an earlier failed one - not even when several hosts back up + the same archive series name to one repository. Restrict the output with --name + (archive series), --command (e.g. create or prune), --host and --user. Neither the + repository passphrase nor the borg key is needed for reading. Reports accumulate over time; --keep=N deletes all but the N newest after reading. @@ -72,65 +73,91 @@ def _monitor_read(self, args, repository): now = datetime.now(timezone.utc) - # Reports are oldest-first, so the last one written per "unit" (archive series for - # create, else the command) wins - giving each unit its latest status independently, - # so a later successful series cannot mask an earlier failed one. + # Group by (host, user, command, archive). Reports are oldest-first, so the newest + # one per group wins - giving each distinct backup job its latest status + # independently. Including host/user means two hosts backing up the same archive + # series name (to the same repo) do not mask each other. latest = {} for report, trusted in reports: if args.command and report.get("command") != args.command: continue if args.name and report.get("archive") != args.name: continue - unit = report.get("archive") or report.get("command") - latest[unit] = (report, trusted) + if args.host and report.get("hostname") != args.host: + continue + if args.user and report.get("username") != args.user: + continue + key = (report.get("hostname"), report.get("username"), report.get("command"), report.get("archive")) + latest[key] = (report, trusted) entries = [] - for unit in sorted(latest): - report, trusted = latest[unit] + for key in sorted(latest, key=lambda k: tuple("" if v is None else str(v) for v in k)): + report, trusted = latest[key] age = (now - parse_timestamp(report["time"])).total_seconds() - stale = age > args.max_age - entries.append((unit, report, trusted, age, stale)) + entries.append({"report": report, "trusted": trusted, "age": age, "stale": age > args.max_age}) self._monitor_output(args, entries) if not entries: set_ec(EXIT_ERROR) # nothing matched -> dead man's switch fires return - # Exit code drives external alerting: worst unit wins (stale/error -> error, + # Exit code drives external alerting: worst job wins (stale/error -> error, # warning/untrusted -> warning). - for _, report, trusted, _, stale in entries: - if stale or report.get("status") == "error": + for entry in entries: + if entry["stale"] or entry["report"].get("status") == "error": set_ec(EXIT_ERROR) - elif report.get("status") == "warning" or not trusted: + elif entry["report"].get("status") == "warning" or not entry["trusted"]: set_ec(EXIT_WARNING) + @staticmethod + def _unit_label(report): + who = f"{report.get('username', '?')}@{report.get('hostname', '?')}" + return f"{who} {report.get('archive') or report.get('command')}" + def _monitor_output(self, args, entries): if args.json: out = { "max_age_seconds": args.max_age, "entries": [ - {"unit": unit, "trusted": trusted, "stale": stale, "age_seconds": age, "report": report} - for unit, report, trusted, age, stale in entries + { + "hostname": e["report"].get("hostname"), + "username": e["report"].get("username"), + "command": e["report"].get("command"), + "archive": e["report"].get("archive"), + "trusted": e["trusted"], + "stale": e["stale"], + "age_seconds": e["age"], + "report": e["report"], + } + for e in entries ], } json_print(out) return if not entries: - scope = "" - if args.name: - scope = f" for archive '{args.name}'" - elif args.command: - scope = f" for command '{args.command}'" + filters = [ + f"{k} '{v}'" + for k, v in ( + ("archive", args.name), + ("command", args.command), + ("host", args.host), + ("user", args.user), + ) + if v + ] + scope = (" for " + ", ".join(filters)) if filters else "" print(f"No monitoring report found{scope}.") return - for unit, report, trusted, age, stale in entries: - print(f"{unit}:") + for e in entries: + report = e["report"] + print(f"{self._unit_label(report)}:") print(f" command: {report.get('command')}") print(f" status: {report.get('status')} (rc {report.get('rc')})") + print(f" user@host: {report.get('username', '-')}@{report.get('hostname', '-')}") print(f" archive: {report.get('archive', '-')}") print(f" time: {report.get('time')}") - print(f" age: {int(age)}s (max {args.max_age}s){' STALE' if stale else ''}") - print(f" trusted: {trusted}{'' if trusted else ' (unsigned - repo is unencrypted)'}") + print(f" age: {int(e['age'])}s (max {args.max_age}s){' STALE' if e['stale'] else ''}") + print(f" trusted: {e['trusted']}{'' if e['trusted'] else ' (unsigned - repo is unencrypted)'}") def build_parser_monitor(self, subparsers, common_parser, mid_common_parser): from ._common import process_epilog @@ -151,10 +178,11 @@ def build_parser_monitor(self, subparsers, common_parser, mid_common_parser): BORG_MONITORING_KEY= borg monitor - This verifies and decrypts the reports and prints, per archive series (and per - maintenance command), the latest status and its age. It exits with a non-zero code - (warning or error) if any series is missing, stale (older than --max-age), unsigned, - or did not indicate success - so it can drive alerting like a dead man's switch. + This verifies and decrypts the reports and prints, for each distinct backup job + (host, user, command, archive series), the latest status and its age. It exits with + a non-zero code (warning or error) if any job is missing, stale (older than + --max-age), unsigned, or did not indicate success - so it can drive alerting like a + dead man's switch. Use --name, --command, --host and --user to restrict the output. Reports accumulate over time; --keep=N (default 500) deletes all but the N newest after reading. """ @@ -181,6 +209,20 @@ def build_parser_monitor(self, subparsers, common_parser, mid_common_parser): metavar="COMMAND", help="only report on the given command, e.g. create or prune (default: all commands)", ) + subparser.add_argument( + "--host", + dest="host", + default=None, + metavar="HOSTNAME", + help="only report on backups made from the given host", + ) + subparser.add_argument( + "--user", + dest="user", + default=None, + metavar="USERNAME", + help="only report on backups made by the given user", + ) subparser.add_argument( "--max-age", dest="max_age", diff --git a/src/borg/monitoring.py b/src/borg/monitoring.py index 5cc9b352a9..68fc916145 100644 --- a/src/borg/monitoring.py +++ b/src/borg/monitoring.py @@ -22,11 +22,13 @@ import os import time from binascii import hexlify +from getpass import getuser from borgstore.store import ItemInfo from borgstore.store import ObjectNotFound as StoreObjectNotFound from . import __version__ +from . import platform from .constants import EXIT_SUCCESS, EXIT_WARNING, EXIT_WARNING_BASE, EXIT_SIGNAL_BASE from .crypto import monitoring as mon_crypto from .helpers import bin_to_hex, get_ec @@ -69,7 +71,9 @@ def status_from_rc(rc): return "error" -def build_report(*, command, repo_id, time, rc, archive=None, archive_id=None, stats=None): +def build_report( + *, command, repo_id, time, rc, hostname=None, username=None, archive=None, archive_id=None, stats=None +): """Assemble the report dict. *repo_id*/*archive_id* are hex strings, *time* is ISO.""" report = { "borg_version": __version__, @@ -79,6 +83,10 @@ def build_report(*, command, repo_id, time, rc, archive=None, archive_id=None, s "status": status_from_rc(rc), "rc": rc, } + if hostname is not None: + report["hostname"] = hostname + if username is not None: + report["username"] = username if archive is not None: report["archive"] = archive if archive_id is not None: @@ -133,18 +141,24 @@ def publish(repository, key, report): logger.warning("Could not publish monitoring report: %s", exc) -def publish_command_report(repository, key, command, *, archive=None, archive_id=None, stats=None): +def publish_command_report( + repository, key, command, *, hostname=None, username=None, archive=None, archive_id=None, stats=None +): """Build and publish a report for a finished command. Captures the best-known return code at call time (the true process rc is only final after the store is closed; see borg/monitoring.py). Call this as the last action while the store is still open. *archive_id* is binary; it is hex-encoded for the report. + *hostname*/*username* default to the host and user borg also stores in new archives, + honoring BORG_HOSTNAME / BORG_USERNAME, so a create report and its archive agree. """ report = build_report( command=command, repo_id=bin_to_hex(repository.id), time=archive_ts_now().isoformat(timespec="microseconds"), rc=get_ec(), + hostname=hostname if hostname is not None else (os.environ.get("BORG_HOSTNAME") or platform.hostname), + username=username if username is not None else (os.environ.get("BORG_USERNAME") or getuser()), archive=archive, archive_id=bin_to_hex(archive_id) if archive_id is not None else None, stats=stats, diff --git a/src/borg/testsuite/archiver/monitor_cmd_test.py b/src/borg/testsuite/archiver/monitor_cmd_test.py index 264f1444ac..3697cf1cf1 100644 --- a/src/borg/testsuite/archiver/monitor_cmd_test.py +++ b/src/borg/testsuite/archiver/monitor_cmd_test.py @@ -15,10 +15,10 @@ def _monitoring_key(archiver): return keys[0] -def _entries(archiver): - """Run `borg monitor --json` and return {unit: entry}.""" - data = json.loads(cmd(archiver, "monitor", "--json")) - return {e["unit"]: e for e in data["entries"]} +def _entries(archiver, *extra): + """Run `borg monitor --json` and return {archive-or-command: entry}.""" + data = json.loads(cmd(archiver, "monitor", "--json", *extra)) + return {(e["archive"] or e["command"]): e for e in data["entries"]} def _monitoring_object_count(archiver): @@ -44,6 +44,9 @@ def test_create_publishes_report_and_monitor_reads_it(archivers, request, monkey assert e["report"]["command"] == "create" assert e["report"]["status"] == "success" assert "archive_id" in e["report"] + # host/user metadata is recorded and surfaced on the entry + assert e["hostname"] and e["hostname"] == e["report"]["hostname"] + assert e["username"] and e["username"] == e["report"]["username"] def test_multiple_series_are_not_masked(archivers, request, monkeypatch): @@ -61,7 +64,32 @@ def test_multiple_series_are_not_masked(archivers, request, monkeypatch): # --name restricts to a single series data = json.loads(cmd(archiver, "monitor", "--name", "backup-home", "--json")) - assert [e["unit"] for e in data["entries"]] == ["backup-home"] + assert [e["archive"] for e in data["entries"]] == ["backup-home"] + + +def test_same_series_from_different_hosts_kept_separate(archivers, request, monkeypatch): + archiver = request.getfixturevalue(archivers) + create_regular_file(archiver.input_path, "file1", contents=b"some data") + cmd(archiver, "repo-create", RK_ENCRYPTION) + # same archive series name, but backed up from two different hosts to the same repo + monkeypatch.setenv("BORG_HOSTNAME", "host-a") + cmd(archiver, "create", "shared", "input") + monkeypatch.setenv("BORG_HOSTNAME", "host-b") + cmd(archiver, "create", "shared", "input") + monkeypatch.delenv("BORG_HOSTNAME") + monkeypatch.setenv("BORG_MONITORING_KEY", _monitoring_key(archiver)) + + data = json.loads(cmd(archiver, "monitor", "--json")) + assert sorted(e["hostname"] for e in data["entries"]) == ["host-a", "host-b"] + assert all(e["archive"] == "shared" for e in data["entries"]) + + # --host narrows to a single host + data = json.loads(cmd(archiver, "monitor", "--host", "host-a", "--json")) + assert [e["hostname"] for e in data["entries"]] == ["host-a"] + + # an unknown host matches nothing -> dead man's switch fires + out = cmd(archiver, "monitor", "--host", "nope", exit_code=EXIT_ERROR) + assert "No monitoring report" in out def test_prune_publishes_its_own_report(archivers, request, monkeypatch): From 063451d68f98b7070578da2ca1c4da2430e8f199 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Thu, 18 Jun 2026 18:06:59 +0200 Subject: [PATCH 4/7] monitoring: publish reports for delete, undelete and transfer borg delete, borg undelete and borg transfer now publish a monitoring report as the last action while the store is open (skipped on --dry-run), with host/user defaulting to the local machine like prune. Stats record the number of archives deleted / undeleted / transferred (plus considered/skipped and transferred bytes). These appear as their own units in borg monitor (command=delete / undelete / transfer). Co-Authored-By: Claude Opus 4.8 --- src/borg/archiver/delete_cmd.py | 14 ++++++-- src/borg/archiver/transfer_cmd.py | 21 ++++++++++++ src/borg/archiver/undelete_cmd.py | 14 ++++++-- .../testsuite/archiver/monitor_cmd_test.py | 34 +++++++++++++++++++ 4 files changed, 77 insertions(+), 6 deletions(-) diff --git a/src/borg/archiver/delete_cmd.py b/src/borg/archiver/delete_cmd.py index 985abb475c..d489fe1acd 100644 --- a/src/borg/archiver/delete_cmd.py +++ b/src/borg/archiver/delete_cmd.py @@ -1,6 +1,7 @@ import logging from ._common import with_repository +from .. import monitoring from ..constants import * # NOQA from ..helpers import format_archive, CommandError, bin_to_hex, archivename_validator from ..helpers.argparsing import ArgumentParser @@ -32,7 +33,7 @@ def do_delete(self, args, repository): "or just delete the whole repository (might be much faster)." ) - deleted = False + deleted_count = 0 logger_list = logging.getLogger("borg.output.list") for i, archive_info in enumerate(archive_infos, 1): name, id, hex_id = archive_info.name, archive_info.id, bin_to_hex(archive_info.id) @@ -46,17 +47,24 @@ def do_delete(self, args, repository): except KeyError: self.print_warning(f"Archive {name} {hex_id} not found ({i}/{count}).") else: - deleted = True + deleted_count += 1 if self.output_list: msg = "Would delete: {} ({}/{})" if dry_run else "Deleted archive: {} ({}/{})" logger_list.info(msg.format(archive_formatted, i, count)) if dry_run: logger.info("Finished dry-run.") - elif deleted: + elif deleted_count: manifest.write() self.print_warning('Done. Run "borg compact" to free space.', wc=None) else: self.print_warning("Aborted.", wc=None) + if not dry_run: + monitoring.publish_command_report( + repository, + manifest.key, + "delete", + stats={"archives_deleted": deleted_count, "archives_considered": count}, + ) return def build_parser_delete(self, subparsers, common_parser, mid_common_parser): diff --git a/src/borg/archiver/transfer_cmd.py b/src/borg/archiver/transfer_cmd.py index d51e6261f0..295c1b5a52 100644 --- a/src/borg/archiver/transfer_cmd.py +++ b/src/borg/archiver/transfer_cmd.py @@ -1,4 +1,5 @@ from ._common import with_repository, with_other_repository, Highlander +from .. import monitoring from ..archive import Archive, cached_hash, DownloadPipeline from ..chunkers import get_chunker from ..constants import * # NOQA @@ -186,6 +187,9 @@ def do_transfer(self, args, *, repository, manifest, cache, other_repository=Non upgrader = UpgraderCls(cache=cache, args=args) + transferred_count = 0 + skipped_count = 0 + transferred_size = 0 for archive_info in archive_infos: name, id, ts = archive_info.name, archive_info.id, archive_info.ts id_hex, ts_str = bin_to_hex(id), ts.isoformat() @@ -201,9 +205,11 @@ def do_transfer(self, args, *, repository, manifest, cache, other_repository=Non # Useful for Borg 1.x -> 2 transfers; we have unique names in Borg 1.x. # Also useful for Borg 2 -> 2 transfers with metadata changes (ID changes). print(f"{name} {ts_str}: archive is already present in destination repo, skipping.") + skipped_count += 1 elif not dry_run and manifest.archives.exists_name_and_id(name, id): # Useful for Borg 2 -> 2 transfers without changes (ID stays the same) print(f"{name} {id_hex}: archive is already present in destination repo, skipping.") + skipped_count += 1 else: if not dry_run: print(f"{name} {ts_str} {id_hex}: copying archive to destination repo...") @@ -253,6 +259,8 @@ def do_transfer(self, args, *, repository, manifest, cache, other_repository=Non archive.stats.show_progress(final=True) additional_metadata = upgrader.upgrade_archive_metadata(metadata=other_archive.metadata) archive.save(additional_metadata=additional_metadata) + transferred_count += 1 + transferred_size += transfer_size print( f"{name} {ts_str} {id_hex}: finished. " f"transfer_size: {format_file_size(transfer_size)} " @@ -267,6 +275,19 @@ def do_transfer(self, args, *, repository, manifest, cache, other_repository=Non f"present_size: {format_file_size(present_size)}" ) + if not dry_run: + monitoring.publish_command_report( + repository, + manifest.key, + "transfer", + stats={ + "archives_transferred": transferred_count, + "archives_skipped": skipped_count, + "archives_considered": count, + "transferred_size": transferred_size, + }, + ) + def build_parser_transfer(self, subparsers, common_parser, mid_common_parser): from ._common import process_epilog from ._common import define_archive_filters_group diff --git a/src/borg/archiver/undelete_cmd.py b/src/borg/archiver/undelete_cmd.py index 8a37e6fb5f..7b96821aa6 100644 --- a/src/borg/archiver/undelete_cmd.py +++ b/src/borg/archiver/undelete_cmd.py @@ -1,6 +1,7 @@ import logging from ._common import with_repository +from .. import monitoring from ..constants import * # NOQA from ..helpers import format_archive, CommandError, bin_to_hex, archivename_validator from ..helpers.argparsing import ArgumentParser @@ -29,7 +30,7 @@ def do_undelete(self, args, repository): if not args.name and not args.match_archives and args.first == 0 and args.last == 0: raise CommandError("Aborting: if you really want to undelete all archives, please use -a 'sh:*'.") - undeleted = False + undeleted_count = 0 logger_list = logging.getLogger("borg.output.list") for i, archive_info in enumerate(archive_infos, 1): name, id, hex_id = archive_info.name, archive_info.id, bin_to_hex(archive_info.id) @@ -39,17 +40,24 @@ def do_undelete(self, args, repository): except KeyError: self.print_warning(f"Archive {name} {hex_id} not found ({i}/{count}).") else: - undeleted = True + undeleted_count += 1 if self.output_list: msg = "Would undelete: {} ({}/{})" if dry_run else "Undeleted archive: {} ({}/{})" logger_list.info(msg.format(format_archive(archive_info), i, count)) if dry_run: logger.info("Finished dry-run.") - elif undeleted: + elif undeleted_count: manifest.write() self.print_warning("Done.", wc=None) else: self.print_warning("Aborted.", wc=None) + if not dry_run: + monitoring.publish_command_report( + repository, + manifest.key, + "undelete", + stats={"archives_undeleted": undeleted_count, "archives_considered": count}, + ) return def build_parser_undelete(self, subparsers, common_parser, mid_common_parser): diff --git a/src/borg/testsuite/archiver/monitor_cmd_test.py b/src/borg/testsuite/archiver/monitor_cmd_test.py index 3697cf1cf1..42d33571db 100644 --- a/src/borg/testsuite/archiver/monitor_cmd_test.py +++ b/src/borg/testsuite/archiver/monitor_cmd_test.py @@ -111,6 +111,40 @@ def test_prune_publishes_its_own_report(archivers, request, monkeypatch): assert prune["stats"]["archives_kept"] == 1 +def test_delete_and_undelete_publish_reports(archivers, request, monkeypatch): + archiver = request.getfixturevalue(archivers) + create_regular_file(archiver.input_path, "file1", contents=b"some data") + cmd(archiver, "repo-create", RK_ENCRYPTION) + cmd(archiver, "create", "a1", "input") + cmd(archiver, "create", "a2", "input") + cmd(archiver, "delete", "a2") + cmd(archiver, "undelete", "a2") + monkeypatch.setenv("BORG_MONITORING_KEY", _monitoring_key(archiver)) + + entries = _entries(archiver) + assert {"delete", "undelete"} <= set(entries) + assert entries["delete"]["report"]["stats"]["archives_deleted"] == 1 + assert entries["undelete"]["report"]["stats"]["archives_undeleted"] == 1 + + +def test_transfer_publishes_report(archivers, request, monkeypatch): + from .transfer_cmd_test import setup_repos + + archiver = request.getfixturevalue(archivers) + with setup_repos(archiver, monkeypatch) as other_repo1: + create_regular_file(archiver.input_path, "file1", contents=b"some data") + cmd(archiver, "create", "arch1", "input") + cmd(archiver, "create", "arch2", "input") + cmd(archiver, "transfer", other_repo1) + monkeypatch.setenv("BORG_MONITORING_KEY", _monitoring_key(archiver)) + + entries = _entries(archiver) + assert "transfer" in entries + stats = entries["transfer"]["report"]["stats"] + assert stats["archives_transferred"] == 2 + assert stats["archives_considered"] == 2 + + def test_keep_evicts_old_objects(archivers, request, monkeypatch): archiver = request.getfixturevalue(archivers) create_regular_file(archiver.input_path, "file1", contents=b"some data") From 52b34fcf10f269d2bf60bf7cb909710fde35644a Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Thu, 30 Jul 2026 16:13:17 +0200 Subject: [PATCH 5/7] monitoring: do not let the server mask a failure by replaying a report Object names are plaintext and thus under the untrusted repository server's control, but the reader treated their order as truth: iter_reports() yielded objects in name order and the last one per job won. So the server could copy an old, validly signed "success" report to a new, later-sorting name and shadow a newer failure report for the whole --max-age window. Two defenses: - borg monitor now picks the latest report per job by the signed timestamp inside the report instead of by arrival order. This is the only defense available for unencrypted repos, whose reports are not sealed at all. - The object name is bound into the HPKE aad, so a sealed report only opens under the name it was published as. Re-serving it under another name now fails loudly instead of silently winning. Since every report's timestamp is parsed now (not just the winner's), a report with a missing or unparsable time is skipped with a warning rather than crashing the reader - the contents of an unsigned report are attacker controlled. Skipping can only drop an entry, never mask a newer one. Co-Authored-By: Claude Opus 5 --- src/borg/archiver/monitor_cmd.py | 37 ++++++++++--- src/borg/monitoring.py | 50 ++++++++++++----- .../testsuite/archiver/monitor_cmd_test.py | 53 ++++++++++++++++++- src/borg/testsuite/monitoring_test.py | 38 ++++++++----- 4 files changed, 145 insertions(+), 33 deletions(-) diff --git a/src/borg/archiver/monitor_cmd.py b/src/borg/archiver/monitor_cmd.py index eacf73e25f..2d3d7afa47 100644 --- a/src/borg/archiver/monitor_cmd.py +++ b/src/borg/archiver/monitor_cmd.py @@ -20,6 +20,18 @@ DEFAULT_MAX_AGE = 25 * 3600 +def _report_time(report): + """The report's own (signed) publish time, or None if missing/unparsable. + + Unsigned reports from an unencrypted repo carry whatever the publisher - or the + server - put there, so this must not blow up on garbage. + """ + try: + return parse_timestamp(report["time"]) + except (KeyError, TypeError, ValueError): + return None + + class MonitorMixIn: @with_repository(manifest=False) def do_monitor(self, args, repository): @@ -73,10 +85,14 @@ def _monitor_read(self, args, repository): now = datetime.now(timezone.utc) - # Group by (host, user, command, archive). Reports are oldest-first, so the newest - # one per group wins - giving each distinct backup job its latest status - # independently. Including host/user means two hosts backing up the same archive - # series name (to the same repo) do not mask each other. + # Group by (host, user, command, archive), so each distinct backup job gets its + # latest status independently. Including host/user means two hosts backing up the + # same archive series name (to the same repo) do not mask each other. + # + # Within a group, the report with the newest *signed* time wins. The order + # iter_reports() yields comes from the object names, which are plaintext and thus + # under the untrusted server's control: picking by arrival order would let the + # server shadow a newer failure by re-serving an older, validly signed success. latest = {} for report, trusted in reports: if args.command and report.get("command") != args.command: @@ -87,13 +103,20 @@ def _monitor_read(self, args, repository): continue if args.user and report.get("username") != args.user: continue + ts = _report_time(report) + if ts is None: + # Not placeable in time, so it may neither win nor mask anything. + logger.warning("Ignoring monitoring report with missing/invalid time: %r", report.get("time")) + continue key = (report.get("hostname"), report.get("username"), report.get("command"), report.get("archive")) - latest[key] = (report, trusted) + previous = latest.get(key) + if previous is None or ts > previous[2]: + latest[key] = (report, trusted, ts) entries = [] for key in sorted(latest, key=lambda k: tuple("" if v is None else str(v) for v in k)): - report, trusted = latest[key] - age = (now - parse_timestamp(report["time"])).total_seconds() + report, trusted, ts = latest[key] + age = (now - ts).total_seconds() entries.append({"report": report, "trusted": trusted, "age": age, "stale": age > args.max_age}) self._monitor_output(args, entries) diff --git a/src/borg/monitoring.py b/src/borg/monitoring.py index 68fc916145..6d729d1b93 100644 --- a/src/borg/monitoring.py +++ b/src/borg/monitoring.py @@ -8,13 +8,20 @@ byte 1: body type (0 = plaintext JSON, 1 = sealed: HPKE(ed25519-sign || JSON)) rest: body -The HPKE seal is bound (via aad) to the repository id, which both sides obtain from the -repository they are talking to - it is never trusted from inside the ciphertext. +The HPKE seal is bound (via aad) to the repository id and to the object name, both of +which the reader obtains from the repository it is talking to - never from inside the +ciphertext. Binding the name matters because the name is plaintext and thus under the +untrusted server's control: without it the server could copy an old, validly signed +"success" report to a new, later-sorting name and shadow a newer failure report. With it, +a report only opens under the name its author gave it. Objects are append-only and named by publish time, so reports for different archive series never overwrite each other (e.g. a failed home backup is not masked by a later successful system backup). The namespace is bounded by ``borg monitor --keep=N``, which deletes all but the N newest objects. + +Object names order the objects for storage and cleanup only; which report is the *latest* +one for a job is decided by the signed timestamp inside the report, never by the name. """ import json @@ -96,11 +103,24 @@ def build_report( return report -def serialize(key, repo_id_bin, report): - """Serialize *report* into the on-disk object bytes, sealing it if the repo is encrypted.""" +def report_aad(repo_id_bin, name): + """Additional authenticated data a sealed report is bound to. + + The repository id keeps a report from being transplanted into another repository, the + object *name* keeps the untrusted server from re-serving it under a different name. + The id has a fixed length, so plain concatenation is unambiguous. + """ + return repo_id_bin + name.encode("utf-8") + + +def serialize(key, repo_id_bin, name, report): + """Serialize *report* into the on-disk object bytes, sealing it if the repo is encrypted. + + *name* is the object name the result will be stored under; it is bound into the seal. + """ payload = json.dumps(report, sort_keys=True).encode("utf-8") if mon_crypto.is_signed_repo(key): - body = mon_crypto.seal_report(key, payload, repo_id_bin) + body = mon_crypto.seal_report(key, payload, report_aad(repo_id_bin, name)) body_type = BODY_SEALED else: body = payload @@ -108,12 +128,14 @@ def serialize(key, repo_id_bin, report): return bytes([FORMAT_VERSION, body_type]) + body -def deserialize(monitor_key, repo_id_bin, data): +def deserialize(monitor_key, repo_id_bin, name, data): """Return (report_dict, trusted: bool). *monitor_key* is the parsed (ed25519_public, hpke_secret) tuple, or None. A sealed report is verified+decrypted (trusted=True); a plaintext report is returned as-is - (trusted=False). Raises ValueError/IntegrityError on malformed or unverifiable data. + (trusted=False). *name* is the object name the data was read from - a sealed report + only opens under the name it was published as. Raises ValueError/IntegrityError on + malformed or unverifiable data. """ if len(data) < 2 or data[0] != FORMAT_VERSION: raise ValueError("monitoring report: unsupported format version") @@ -122,7 +144,7 @@ def deserialize(monitor_key, repo_id_bin, data): if monitor_key is None: raise ValueError("monitoring report is sealed but no BORG_MONITORING_KEY was given") ed_public, hpke_secret = monitor_key - payload = mon_crypto.open_report(ed_public, hpke_secret, body, repo_id_bin) + payload = mon_crypto.open_report(ed_public, hpke_secret, body, report_aad(repo_id_bin, name)) trusted = True elif body_type == BODY_PLAIN: payload = body @@ -135,8 +157,9 @@ def deserialize(monitor_key, repo_id_bin, data): def publish(repository, key, report): """Append *report* to the repository as a new object. Best-effort: never raise out.""" try: - data = serialize(key, repository.id, report) - repository.store_store(f"{STORE_NAMESPACE}/{_new_object_name()}", data) + name = _new_object_name() + data = serialize(key, repository.id, name, report) + repository.store_store(f"{STORE_NAMESPACE}/{name}", data) except Exception as exc: logger.warning("Could not publish monitoring report: %s", exc) @@ -174,17 +197,18 @@ def list_names(repository): def iter_reports(repository, monitor_key): - """Yield (report, trusted) for every stored report, oldest first. + """Yield (report, trusted) for every stored report, in storage order. Each report is verified and decrypted; an unverifiable one raises (it is not silently - skipped) so tampering surfaces. + skipped) so tampering surfaces. The order reports come in is *not* authenticated - the + caller must order them by the signed timestamp inside the report. """ for name in list_names(repository): try: data = repository.store_load(f"{STORE_NAMESPACE}/{name}") except StoreObjectNotFound: continue # raced with a concurrent --keep cleanup - yield deserialize(monitor_key, repository.id, data) + yield deserialize(monitor_key, repository.id, name, data) def prune_reports(repository, keep): diff --git a/src/borg/testsuite/archiver/monitor_cmd_test.py b/src/borg/testsuite/archiver/monitor_cmd_test.py index 42d33571db..56fa6bb2ee 100644 --- a/src/borg/testsuite/archiver/monitor_cmd_test.py +++ b/src/borg/testsuite/archiver/monitor_cmd_test.py @@ -1,5 +1,6 @@ import json import os +import shutil from ...constants import * # NOQA from . import cmd, create_regular_file, generate_archiver_tests, RK_ENCRYPTION @@ -21,8 +22,30 @@ def _entries(archiver, *extra): return {(e["archive"] or e["command"]): e for e in data["entries"]} +def _monitoring_dir(archiver): + return os.path.join(archiver.repository_path, "monitoring") + + def _monitoring_object_count(archiver): - return len(os.listdir(os.path.join(archiver.repository_path, "monitoring"))) + return len(os.listdir(_monitoring_dir(archiver))) + + +def _replay_oldest_under_newest_name(archiver): + """Copy the oldest report object to a name that sorts last, return the original names. + + This is what an untrusted repository server can do: object names are plaintext, so it + can re-serve an old (validly signed) report as if it were the most recent one. + """ + names = sorted(os.listdir(_monitoring_dir(archiver))) + replay = f"{'9' * 20}.baadf00d" + shutil.copyfile(os.path.join(_monitoring_dir(archiver), names[0]), os.path.join(_monitoring_dir(archiver), replay)) + return names + + +def _plain_report(archiver, name): + """Read an unsigned report object: 2 header bytes, then the JSON payload.""" + with open(os.path.join(_monitoring_dir(archiver), name), "rb") as f: + return json.loads(f.read()[2:].decode("utf-8")) def test_create_publishes_report_and_monitor_reads_it(archivers, request, monkeypatch): @@ -203,6 +226,34 @@ def test_monitor_unencrypted_repo_is_untrusted(archivers, request): assert "unencrypted" in out +def test_replayed_report_is_refused(archivers, request, monkeypatch): + archiver = request.getfixturevalue(archivers) + create_regular_file(archiver.input_path, "file1", contents=b"some data") + cmd(archiver, "repo-create", RK_ENCRYPTION) + cmd(archiver, "create", "series", "input") + cmd(archiver, "create", "series", "input") + _replay_oldest_under_newest_name(archiver) + monkeypatch.setenv("BORG_MONITORING_KEY", _monitoring_key(archiver)) + # the object name is bound into the seal, so a report re-served under another name + # does not even open - the attempt surfaces instead of silently shadowing the newer one + output = cmd(archiver, "monitor", fork=True, exit_code=EXIT_ERROR) + assert "verification/decryption failed" in output + + +def test_unsigned_replay_does_not_shadow_newer_report(archivers, request): + archiver = request.getfixturevalue(archivers) + create_regular_file(archiver.input_path, "file1", contents=b"some data") + cmd(archiver, "repo-create", "--encryption=none") + cmd(archiver, "create", "series", "input") + cmd(archiver, "create", "series", "input") + names = _replay_oldest_under_newest_name(archiver) + newest_time = _plain_report(archiver, names[-1])["time"] + # unsigned reports can not be bound to their name, so the time inside the report - not + # the (server-controlled) object name - has to decide which one is the latest + data = json.loads(cmd(archiver, "monitor", "--json", exit_code=EXIT_WARNING)) + assert [e["report"]["time"] for e in data["entries"]] == [newest_time] + + def test_monitor_key_export_is_deterministic(archivers, request): archiver = request.getfixturevalue(archivers) cmd(archiver, "repo-create", RK_ENCRYPTION) diff --git a/src/borg/testsuite/monitoring_test.py b/src/borg/testsuite/monitoring_test.py index 6094f2f096..a119c525a6 100644 --- a/src/borg/testsuite/monitoring_test.py +++ b/src/borg/testsuite/monitoring_test.py @@ -153,51 +153,65 @@ def test_build_report_schema(): assert "borg_version" in report and "time" in report +NAME = "00000000000000000001.deadbeef" + + def test_sealed_report_roundtrip_and_trusted(): key = make_key() repo_id = os.urandom(32) report = sample_report(repo_id.hex()) - data = m.serialize(key, repo_id, report) + data = m.serialize(key, repo_id, NAME, report) assert data[0] == m.FORMAT_VERSION and data[1] == m.BODY_SEALED monitor_key = mc.parse_monitor_key(mc.export_monitor_key(key)) - got, trusted = m.deserialize(monitor_key, repo_id, data) + got, trusted = m.deserialize(monitor_key, repo_id, NAME, data) assert got == report and trusted is True def test_sealed_report_rejects_wrong_repo_id(): key = make_key() repo_id = os.urandom(32) - data = m.serialize(key, repo_id, sample_report(repo_id.hex())) + data = m.serialize(key, repo_id, NAME, sample_report(repo_id.hex())) + monitor_key = mc.parse_monitor_key(mc.export_monitor_key(key)) + with pytest.raises(low_level.IntegrityError): + m.deserialize(monitor_key, os.urandom(32), NAME, data) + + +def test_sealed_report_rejects_wrong_object_name(): + # The server may not re-serve a report under a different (e.g. later-sorting) name to + # shadow a newer report: the name is bound into the seal. + key = make_key() + repo_id = os.urandom(32) + data = m.serialize(key, repo_id, NAME, sample_report(repo_id.hex())) monitor_key = mc.parse_monitor_key(mc.export_monitor_key(key)) with pytest.raises(low_level.IntegrityError): - m.deserialize(monitor_key, os.urandom(32), data) + m.deserialize(monitor_key, repo_id, "99999999999999999999.deadbeef", data) def test_sealed_report_rejects_tamper(): key = make_key() repo_id = os.urandom(32) - data = bytearray(m.serialize(key, repo_id, sample_report(repo_id.hex()))) + data = bytearray(m.serialize(key, repo_id, NAME, sample_report(repo_id.hex()))) data[-1] ^= 1 monitor_key = mc.parse_monitor_key(mc.export_monitor_key(key)) with pytest.raises(low_level.IntegrityError): - m.deserialize(monitor_key, repo_id, bytes(data)) + m.deserialize(monitor_key, repo_id, NAME, bytes(data)) def test_sealed_report_rejects_wrong_monitor_key(): key = make_key() repo_id = os.urandom(32) - data = m.serialize(key, repo_id, sample_report(repo_id.hex())) + data = m.serialize(key, repo_id, NAME, sample_report(repo_id.hex())) wrong = mc.parse_monitor_key(mc.export_monitor_key(make_key())) with pytest.raises(low_level.IntegrityError): - m.deserialize(wrong, repo_id, data) + m.deserialize(wrong, repo_id, NAME, data) def test_sealed_report_requires_key(): key = make_key() repo_id = os.urandom(32) - data = m.serialize(key, repo_id, sample_report(repo_id.hex())) + data = m.serialize(key, repo_id, NAME, sample_report(repo_id.hex())) with pytest.raises(ValueError): - m.deserialize(None, repo_id, data) + m.deserialize(None, repo_id, NAME, data) def test_plaintext_report_roundtrip_is_untrusted(): @@ -206,7 +220,7 @@ def test_plaintext_report_roundtrip_is_untrusted(): key.id_key = b"" repo_id = os.urandom(32) report = sample_report(repo_id.hex()) - data = m.serialize(key, repo_id, report) + data = m.serialize(key, repo_id, NAME, report) assert data[1] == m.BODY_PLAIN - got, trusted = m.deserialize(None, repo_id, data) + got, trusted = m.deserialize(None, repo_id, NAME, data) assert got == report and trusted is False From c7027c6d52c3028aeda092f79736d75db529a7e6 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Thu, 30 Jul 2026 16:21:24 +0200 Subject: [PATCH 6/7] monitoring: refuse unsigned reports when a monitoring key is configured Tampering with a sealed report already fails the run, but the server could avoid that entirely: delete the sealed report it can not forge and put an unsigned "success" of its own in its place. That came back as trusted=False, which is only a warning - so a compromised server could downgrade a failing backup into something an operator alerting on errors alone would never see. A monitoring key only exists for an encrypted repository, and such a repository's client always seals its reports, so an unsigned report there can not be genuine. Reject it like any other verification failure instead of reporting it as merely untrusted. Unencrypted repos (no key configured) keep the old behaviour: their plaintext reports stay readable, untrusted and warning. Also fix the text output hint, which claimed an untrusted report meant the repo is unencrypted - it can equally mean no BORG_MONITORING_KEY was set. Co-Authored-By: Claude Opus 5 --- src/borg/archiver/monitor_cmd.py | 3 +- src/borg/monitoring.py | 22 ++++++++++++--- .../testsuite/archiver/monitor_cmd_test.py | 28 +++++++++++++++++++ src/borg/testsuite/monitoring_test.py | 14 ++++++++++ 4 files changed, 62 insertions(+), 5 deletions(-) diff --git a/src/borg/archiver/monitor_cmd.py b/src/borg/archiver/monitor_cmd.py index 2d3d7afa47..c0e7e5b111 100644 --- a/src/borg/archiver/monitor_cmd.py +++ b/src/borg/archiver/monitor_cmd.py @@ -180,7 +180,8 @@ def _monitor_output(self, args, entries): print(f" archive: {report.get('archive', '-')}") print(f" time: {report.get('time')}") print(f" age: {int(e['age'])}s (max {args.max_age}s){' STALE' if e['stale'] else ''}") - print(f" trusted: {e['trusted']}{'' if e['trusted'] else ' (unsigned - repo is unencrypted)'}") + hint = "" if e["trusted"] else " (unsigned - no BORG_MONITORING_KEY set, or repo is unencrypted)" + print(f" trusted: {e['trusted']}{hint}") def build_parser_monitor(self, subparsers, common_parser, mid_common_parser): from ._common import process_epilog diff --git a/src/borg/monitoring.py b/src/borg/monitoring.py index 6d729d1b93..911554ae07 100644 --- a/src/borg/monitoring.py +++ b/src/borg/monitoring.py @@ -22,6 +22,11 @@ Object names order the objects for storage and cleanup only; which report is the *latest* one for a job is decided by the signed timestamp inside the report, never by the name. + +A plaintext body is only legitimate for an unencrypted repository, which has no key to +sign with. Once a monitoring key is configured, plaintext bodies are refused rather than +reported as untrusted - otherwise the server could downgrade a failure it wants hidden +into a mere warning by replacing the sealed report with an unsigned "success". """ import json @@ -38,6 +43,7 @@ from . import platform from .constants import EXIT_SUCCESS, EXIT_WARNING, EXIT_WARNING_BASE, EXIT_SIGNAL_BASE from .crypto import monitoring as mon_crypto +from .crypto.low_level import IntegrityError from .helpers import bin_to_hex, get_ec from .helpers.time import archive_ts_now @@ -132,10 +138,10 @@ def deserialize(monitor_key, repo_id_bin, name, data): """Return (report_dict, trusted: bool). *monitor_key* is the parsed (ed25519_public, hpke_secret) tuple, or None. A sealed - report is verified+decrypted (trusted=True); a plaintext report is returned as-is - (trusted=False). *name* is the object name the data was read from - a sealed report - only opens under the name it was published as. Raises ValueError/IntegrityError on - malformed or unverifiable data. + report is verified+decrypted (trusted=True); a plaintext report is only accepted if no + monitoring key is configured, and then it is untrusted (trusted=False). *name* is the + object name the data was read from - a sealed report only opens under the name it was + published as. Raises ValueError/IntegrityError on malformed or unverifiable data. """ if len(data) < 2 or data[0] != FORMAT_VERSION: raise ValueError("monitoring report: unsupported format version") @@ -147,6 +153,14 @@ def deserialize(monitor_key, repo_id_bin, name, data): payload = mon_crypto.open_report(ed_public, hpke_secret, body, report_aad(repo_id_bin, name)) trusted = True elif body_type == BODY_PLAIN: + if monitor_key is not None: + # A monitoring key exists, so this repo's own reports are always sealed: an + # unsigned one can only have been put there by someone else. Accepting it as a + # mere warning would let the server downgrade a failure it wants hidden. + raise IntegrityError( + "unsigned monitoring report, but this repository's reports are signed " + "(injected by the repository server?)" + ) payload = body trusted = False else: diff --git a/src/borg/testsuite/archiver/monitor_cmd_test.py b/src/borg/testsuite/archiver/monitor_cmd_test.py index 56fa6bb2ee..3349d82249 100644 --- a/src/borg/testsuite/archiver/monitor_cmd_test.py +++ b/src/borg/testsuite/archiver/monitor_cmd_test.py @@ -3,6 +3,7 @@ import shutil from ...constants import * # NOQA +from ...monitoring import BODY_PLAIN, FORMAT_VERSION from . import cmd, create_regular_file, generate_archiver_tests, RK_ENCRYPTION pytest_generate_tests = lambda metafunc: generate_archiver_tests(metafunc, kinds="local") # NOQA @@ -48,6 +49,13 @@ def _plain_report(archiver, name): return json.loads(f.read()[2:].decode("utf-8")) +def _write_plain_report(archiver, name, report): + """Write an unsigned report object, as a repository server could fabricate one.""" + data = bytes([FORMAT_VERSION, BODY_PLAIN]) + json.dumps(report).encode("utf-8") + with open(os.path.join(_monitoring_dir(archiver), name), "wb") as f: + f.write(data) + + def test_create_publishes_report_and_monitor_reads_it(archivers, request, monkeypatch): archiver = request.getfixturevalue(archivers) create_regular_file(archiver.input_path, "file1", contents=b"some data") @@ -254,6 +262,26 @@ def test_unsigned_replay_does_not_shadow_newer_report(archivers, request): assert [e["report"]["time"] for e in data["entries"]] == [newest_time] +def test_injected_unsigned_report_is_refused(archivers, request, monkeypatch): + archiver = request.getfixturevalue(archivers) + create_regular_file(archiver.input_path, "file1", contents=b"some data") + cmd(archiver, "repo-create", RK_ENCRYPTION) + cmd(archiver, "create", "series", "input") + key = _monitoring_key(archiver) + # the server drops the sealed report (which it can not forge) and puts an unsigned + # "success" of its own in its place - this must not be downgraded to a warning + for name in os.listdir(_monitoring_dir(archiver)): + os.remove(os.path.join(_monitoring_dir(archiver), name)) + _write_plain_report( + archiver, + f"{'1' * 20}.0badc0de", + {"command": "create", "archive": "series", "status": "success", "rc": 0, "time": "2286-11-20T17:46:39+00:00"}, + ) + monkeypatch.setenv("BORG_MONITORING_KEY", key) + output = cmd(archiver, "monitor", fork=True, exit_code=EXIT_ERROR) + assert "unsigned" in output + + def test_monitor_key_export_is_deterministic(archivers, request): archiver = request.getfixturevalue(archivers) cmd(archiver, "repo-create", RK_ENCRYPTION) diff --git a/src/borg/testsuite/monitoring_test.py b/src/borg/testsuite/monitoring_test.py index a119c525a6..232ef480ee 100644 --- a/src/borg/testsuite/monitoring_test.py +++ b/src/borg/testsuite/monitoring_test.py @@ -224,3 +224,17 @@ def test_plaintext_report_roundtrip_is_untrusted(): assert data[1] == m.BODY_PLAIN got, trusted = m.deserialize(None, repo_id, NAME, data) assert got == report and trusted is False + + +def test_plaintext_report_refused_when_monitor_key_is_configured(): + # A configured key means this repo's own reports are sealed, so an unsigned one is an + # injection attempt - it must not be downgraded to a mere "untrusted" warning. + plain_key = PlaintextKey.__new__(PlaintextKey) + plain_key.crypt_key = b"" + plain_key.id_key = b"" + repo_id = os.urandom(32) + data = m.serialize(plain_key, repo_id, NAME, sample_report(repo_id.hex())) + assert data[1] == m.BODY_PLAIN + monitor_key = mc.parse_monitor_key(mc.export_monitor_key(make_key())) + with pytest.raises(low_level.IntegrityError): + m.deserialize(monitor_key, repo_id, NAME, data) From 421d796f690acb43035299fc3329cf34e924f134 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Thu, 30 Jul 2026 16:26:39 +0200 Subject: [PATCH 7/7] monitoring: validate report contents before the reader uses them The body of an unsigned report is whatever the untrusted server chooses to serve, but the reader took its shape on faith. A body that is not a JSON object crashed borg monitor with an AttributeError traceback, a list where a string belongs (e.g. "archive") crashed it with an unhashable-type TypeError when grouping, and a report without a status was neither an error nor a warning - so it silently counted as a success. deserialize() now shape-checks every report: the fields the reader relies on (command, time, status, rc) must be present and of the right type, the optional ones (hostname, username, archive, archive_id, stats) must match their type if present, and status must be one of success/warning/error. Anything else is refused with a clean error. Reports from a real client always pass, so this only affects bodies nobody signed. An unparsable *value* in the time field is still tolerated (the report is skipped with a warning, see the previous commit); this is about the shape. Co-Authored-By: Claude Opus 5 --- src/borg/monitoring.py | 35 +++++++++++++- .../testsuite/archiver/monitor_cmd_test.py | 14 ++++++ src/borg/testsuite/monitoring_test.py | 47 +++++++++++++++++++ 3 files changed, 94 insertions(+), 2 deletions(-) diff --git a/src/borg/monitoring.py b/src/borg/monitoring.py index 911554ae07..517272bee0 100644 --- a/src/borg/monitoring.py +++ b/src/borg/monitoring.py @@ -26,7 +26,9 @@ A plaintext body is only legitimate for an unencrypted repository, which has no key to sign with. Once a monitoring key is configured, plaintext bodies are refused rather than reported as untrusted - otherwise the server could downgrade a failure it wants hidden -into a mere warning by replacing the sealed report with an unsigned "success". +into a mere warning by replacing the sealed report with an unsigned "success". What an +unsigned body does contain is not trusted either: every report is shape-checked before +the reader touches it, see validate_report(). """ import json @@ -109,6 +111,35 @@ def build_report( return report +# Fields the reader relies on: required ones must be present and of the given type, +# optional ones only have to match their type if they are present at all. Anything else a +# report carries is passed through untouched. +REQUIRED_FIELDS = {"command": str, "time": str, "status": str, "rc": int} +OPTIONAL_FIELDS = {"hostname": str, "username": str, "archive": str, "archive_id": str, "stats": dict} +STATUS_VALUES = {"success", "warning", "error"} + + +def validate_report(report): + """Return *report* if the reader can safely work with it, else raise ValueError. + + The body of an unsigned report is whatever the untrusted server chooses to serve, so + its shape must not be taken on faith: a non-object body or a list where a string + belongs crashes borg monitor with a traceback, and a missing status would pass as a + success. A sealed report from a real client always satisfies this. + """ + if not isinstance(report, dict): + raise ValueError("monitoring report: not a JSON object") + for field, field_type in REQUIRED_FIELDS.items(): + if not isinstance(report.get(field), field_type): + raise ValueError(f"monitoring report: missing or invalid {field!r}") + for field, field_type in OPTIONAL_FIELDS.items(): + if field in report and not isinstance(report[field], field_type): + raise ValueError(f"monitoring report: invalid {field!r}") + if report["status"] not in STATUS_VALUES: + raise ValueError(f"monitoring report: unknown status {report['status']!r}") + return report + + def report_aad(repo_id_bin, name): """Additional authenticated data a sealed report is bound to. @@ -165,7 +196,7 @@ def deserialize(monitor_key, repo_id_bin, name, data): trusted = False else: raise ValueError("monitoring report: unknown body type") - return json.loads(payload.decode("utf-8")), trusted + return validate_report(json.loads(payload.decode("utf-8"))), trusted def publish(repository, key, report): diff --git a/src/borg/testsuite/archiver/monitor_cmd_test.py b/src/borg/testsuite/archiver/monitor_cmd_test.py index 3349d82249..f719eb03a7 100644 --- a/src/borg/testsuite/archiver/monitor_cmd_test.py +++ b/src/borg/testsuite/archiver/monitor_cmd_test.py @@ -282,6 +282,20 @@ def test_injected_unsigned_report_is_refused(archivers, request, monkeypatch): assert "unsigned" in output +def test_malformed_unsigned_report_gives_a_clean_error(archivers, request): + archiver = request.getfixturevalue(archivers) + create_regular_file(archiver.input_path, "file1", contents=b"some data") + cmd(archiver, "repo-create", "--encryption=none") + cmd(archiver, "create", "series", "input") + # an unencrypted repo's reports are unsigned, so their contents are whatever the + # server serves: borg monitor must refuse them, not crash on them + name = sorted(os.listdir(_monitoring_dir(archiver)))[0] + _write_plain_report(archiver, name, ["not", "a", "dict"]) + output = cmd(archiver, "monitor", fork=True, exit_code=EXIT_ERROR) + assert "monitoring report" in output + assert "Traceback" not in output + + def test_monitor_key_export_is_deterministic(archivers, request): archiver = request.getfixturevalue(archivers) cmd(archiver, "repo-create", RK_ENCRYPTION) diff --git a/src/borg/testsuite/monitoring_test.py b/src/borg/testsuite/monitoring_test.py index 232ef480ee..95a9f3a576 100644 --- a/src/borg/testsuite/monitoring_test.py +++ b/src/borg/testsuite/monitoring_test.py @@ -238,3 +238,50 @@ def test_plaintext_report_refused_when_monitor_key_is_configured(): monitor_key = mc.parse_monitor_key(mc.export_monitor_key(make_key())) with pytest.raises(low_level.IntegrityError): m.deserialize(monitor_key, repo_id, NAME, data) + + +# --- validation of untrusted report contents ------------------------------------------- + + +def test_validate_report_accepts_a_real_report(): + report = sample_report("cc" * 32) + assert m.validate_report(report) is report + # the optional fields borg actually publishes are accepted, too + report["hostname"], report["username"] = "somehost", "someuser" + assert m.validate_report(report) is report + + +@pytest.mark.parametrize( + "body", + [ + ["not", "a", "dict"], # a non-object body would crash report.get() + "a string", + None, + {}, # no fields at all + {"command": "create", "status": "success", "rc": 0}, # no time + {"command": "create", "time": "2026-06-17T11:59:58+00:00", "status": "success"}, # no rc + {"command": "create", "time": 12345, "status": "success", "rc": 0}, # time not a string + {"command": ["create"], "time": "2026-06-17T11:59:58+00:00", "status": "success", "rc": 0}, + # an unhashable value in a grouping field would crash the reader + { + "command": "create", + "time": "2026-06-17T11:59:58+00:00", + "status": "success", + "rc": 0, + "archive": ["unhashable"], + }, + # an unknown status would count as "not an error", i.e. as a success + {"command": "create", "time": "2026-06-17T11:59:58+00:00", "status": "all good", "rc": 0}, + ], +) +def test_validate_report_rejects_unusable_bodies(body): + with pytest.raises(ValueError): + m.validate_report(body) + + +def test_deserialize_rejects_unusable_plaintext_body(): + # what an unencrypted repo (or an injecting server) can serve must not reach the reader + repo_id = os.urandom(32) + data = bytes([m.FORMAT_VERSION, m.BODY_PLAIN]) + b'["not", "a", "dict"]' + with pytest.raises(ValueError): + m.deserialize(None, repo_id, NAME, data)