Skip to content

seal the files cache with the repository key, fixes #5041 - #10497

Merged
ThomasWaldmann merged 2 commits into
borgbackup:masterfrom
ThomasWaldmann:encrypt-files-cache
Oct 10, 2026
Merged

ThomasWaldmann merged 2 commits into
borgbackup:masterfrom
ThomasWaldmann:encrypt-files-cache

Conversation

@ThomasWaldmann

Copy link
Copy Markdown
Member

The files cache (files.<suffix> in the local cache directory) was stored as plaintext msgpack: file sizes, timestamps, inode numbers, chunk lists and, with borg create --digests, unkeyed digests of the whole file contents. Its only protection was a checksum without a key in the cache config. So anyone who could read the cache directory could read that metadata, and anyone who could write to it could make borg treat changed files as unchanged.

This PR protects the files cache with the repository key, like the index/ and cache/ objects in the repository (#9819, #10235).

Fixes #5041.

Changes

Commit 1: sealed streams (borg.crypto.sealed_stream)

A reusable layer that cuts a byte stream into 1 MiB frames and puts every frame into the key's envelope. Frames are encrypted and authenticated in the encrypting modes, authenticated only in the authenticated-* modes.

stream := frame+
frame  := flag (1 byte: 0 = more, 1 = last) + length (4 bytes LE) + envelope
AAD    := caller context + frame number (8 bytes BE) + flag
  • The reader rejects:
    • changed, reordered, dropped or duplicated frames;
    • a stream that ends before its last frame;
    • a "last" flag on an earlier frame;
    • data after the last frame;
    • an envelope larger than the frame size limit.
  • Not detected: replacing a whole stream with an older stream that has the same context. Each user of the layer decides whether that matters.
  • Every frame is sealed with encrypt_oneshot(): its own random session id, IV 0. That makes the layer thread-safe. It costs about 3 µs extra per frame, so about 3 ms per GiB, and nothing extra when reading.
  • Why frames rather than one envelope: one envelope needs the whole serialized cache in memory 2-3 times while writing it. That measured +3 GiB peak RSS for a 1 GiB cache, at Cache.close(), when the in-memory files cache and chunks index are still loaded.
  • The encrypt_oneshot() docstring is reworded: frames of about 1 MiB are fine, millions of chunks are not.

Commit 2: the files cache uses it

  • Context: b"borg-files-cache\0" + repository id + the file name of the files cache. A files cache copied from another repository (with the same key material) or from another archive series is rejected.
  • A files cache that fails the verification is ignored with a warning; the exit code stays the same. Then it is rebuilt from the previous archive of the series, as for a missing files cache. A plaintext files cache from an older borg 2 beta takes this path once.
  • Putting back an older sealed copy of a files cache is not detected, but harmless. Its entries were correct when written, and entries that refer to chunks no longer in the repository are dropped when loading.
  • The [integrity] checksum section of the cache config is removed (IntegrityCheckedFile stays for reading borg 1.x repos). Nothing in the config changes after its creation any more, so Cache.close() no longer rewrites it, and the "Saving cache config" progress message is gone.
  • File names are unchanged: files.<store hash of the series name>.
  • Docs:
    • data-structures.rst: new "Sealed streams" section, files cache on-disk format, the checksumming section (now only about borg 1.x repos);
    • security.rst;
    • the cache security FAQ (it said the cache is not encrypted);
    • the frontends.rst progress example.

Measurements

Serialization layer only, 1M files cache entries (98 MiB on disk), on a ramdisk:

write read extra peak RSS
master (IntegrityCheckedFile) 1.25 s 0.39 s 0 MiB
this PR (sealed stream, AES-OCB) 0.89 s 0.36 s 5-7 MiB

Writing got faster, because IntegrityCheckedFile hashed every small write() separately, while the new writer collects data into 1 MiB frames first.

Tests

  • testsuite/crypto/sealed_stream_test.py: round trips at and around the frame boundaries, the frame size limit, and the one-off sessions.
    • Error cases: tampering in each region of a frame, truncation, trailing data, reordered or duplicated frames, a forged "last" flag, a context mismatch, and writer misuse.
    • Run with AES-OCB, ChaCha20-Poly1305 and both authenticated-* key types.
  • cache_test.py: a round trip over many frames, an empty cache, a missing cache (no warning), and that no plaintext is left in the file.
    • Rejected files caches: tampered, from another series, from another repository, plaintext, and authentic but invalid content.
    • The cache config has no [integrity] section and is not rewritten by close().
  • create_cmd_test.py: a corrupted files cache gives a warning and rc 0, and the unchanged file is still found unchanged.

Local run on macOS: full suite green, except test_migrate_lock_alive[archiver], a FUSE mount timeout that also fails locally without this change.

🤖 Generated with Claude Code

@codecov

codecov Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.41379% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.25%. Comparing base (f3c82cd) to head (185c5a3).
⚠️ Report is 37 commits behind head on master.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/borg/cache.py 92.30% 2 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #10497      +/-   ##
==========================================
+ Coverage   89.06%   89.25%   +0.18%     
==========================================
  Files         103      104       +1     
  Lines       19685    19893     +208     
  Branches     3081     3118      +37     
==========================================
+ Hits        17533    17756     +223     
+ Misses       1490     1478      -12     
+ Partials      662      659       -3     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

ThomasWaldmann and others added 2 commits October 11, 2026 00:06
A sealed stream cuts a byte stream into 1 MiB frames and puts every
frame into the key's envelope (encrypted and authenticated in the
encrypting modes, authenticated only in the authenticated-* modes),
so neither the writer nor the reader needs memory for more than a frame.

The AAD of a frame is a caller-given context plus the frame number and
a "last frame" flag, so the reader detects changed, reordered, dropped
or duplicated frames, truncation and trailing data. Every frame is
sealed with encrypt_oneshot(), so sealed streams are thread-safe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#5041

The files cache (file sizes, timestamps, inode numbers, chunk lists and
--digests content digests) was stored as plaintext msgpack in the local
cache directory, protected only by a checksum without a key, so anyone
who could read the cache directory could read it, and anyone who could
write it could make borg skip changed files.

It is now written as a sealed stream: encrypted and authenticated with
the repository key (authenticated only in the authenticated-* modes),
bound to the repository id and the file name of the files cache. A files
cache that fails the verification is ignored with a warning (the exit
code is not changed) and rebuilt from the previous archive.

The checksums in the [integrity] section of the cache config are not
needed any more, so the cache config is no longer rewritten on every
cache close().

The FAQ about cache security now describes what is protected and what is not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ThomasWaldmann
ThomasWaldmann merged commit 306a3c8 into borgbackup:master Oct 10, 2026
28 checks passed
KazukiKandaKK added a commit to KazukiKandaKK/borg that referenced this pull request Nov 1, 2026
…r last_written

PR borgbackup#10497 (sealed files cache) removed CacheConfig.save(), so on
current master the borg2 cache config file's mtime stays at cache
creation time and no longer tracks last use.

last_written is now the newer of:
- the security location file's mtime (rewritten by Cache.close() via
  security_manager.save() on every run; also gives security-only
  repos a value)
- the newest mtime of the regular files below the cache dir, tracked
  during the existing cache_stats() walk that already computes size

The JSON field is renamed from cache_config_mtime to last_written.
Epilog/docs/man page say where the timestamp comes from and say
"regular file" to match what the code tracks. The long run-on epilog
sentence is split into shorter sentences.

Tests assert which source wins (location-newer, cache-newer including
a nested file) and the cache-only case now checks the exact mtime
instead of only non-null.
ThomasWaldmann pushed a commit that referenced this pull request Nov 1, 2026
…0453

* known-repos: new command to list locally known repositories

Lists repositories this local Borg installation has state for (security
info and/or a local cache) without accessing any repository, local or
remote. Scans both the current borg2 security/cache directories and,
separately, the legacy borg 1.x security/cache directories (which can be
different default locations, e.g. on macOS).

Does not follow symlinks anywhere in the scanned trees (repo-id
directories, or the location/key-type files inside them), since borg
only ever writes plain directories and regular files there.

Key-type labels are derived from the key classes themselves
(LEGACY_KEY_TYPES + AVAILABLE_KEY_TYPES), matching how "borg repo-info"
names crypto suites, rather than a separately hand-maintained table.

dir_size() pushes each directory handle (root, and any subdirectory
opened while walking the cache tree) onto its explicit stack before
calling list_names() on it, not after, so cleanup can always close it
even if list_names() raises something other than OSError (it only
catches OSError itself) - e.g. KeyboardInterrupt. Covered by two unit
tests that monkeypatch list_names() to raise and then check the fd via
os.fstat().

has_cache and has_security_info are tri-state (True/False/None, not a
plain bool): an inspection failure (e.g. EACCES on a repo-id directory,
or on the security/cache root itself, including a root that opens but
fails during its own listing) is reported as unknown rather than being
collapsed into a false "no". A repo-id directory that could be listed
but not opened/classified is still included in the results with its
fields set to unknown, instead of silently disappearing from the
output. The JSON/text field for which state-directory layout a
repository was discovered under is named "layout" (values "borg2" /
"legacy" / null), not "version", so it cannot be confused with Borg's
actual repository format version (see "borg repo-info").

Adds docs/usage/known-repos.rst{,.inc} and the usage.rst toctree entry, and
the generated man page.

* known-repos: simplify after maintainer review, drop leftover defenses

Rewrite the scanner around ordinary OS file access instead of extra
symlink/size defenses, collapse duplicated scan/table/test logic, and
surface real inspection failures (ENOTDIR, unparseable cache config)
instead of treating them as absence.

Also forward create=False through legacy get_keys_dir(), refresh the
usage example, keep tests to the remaining behavioral surface, and
shorten a few overly verbose test docstrings.

* known-repos: fix black formatting, restore generated usage/man docs

black wants the process_epilog("""...""") call's opening triple-quote on
its own line and the closing paren on a separate line.

docs/usage/known-repos.rst still includes known-repos.rst.inc, which a
previous commit deleted along with the generated man page - this broke
the docs build (InputError: no such file). Regenerate both with
scripts/make.py build_usage/build_man, following the convention other
recently added commands (copy, find) already use.

* known-repos: address second-review minor points

- location_mismatch now compares the security location and cache
  previous_location through normalize_local_path(), the same way
  assert_location_matches() does, so an NFC vs. NFD unicode-form
  difference alone isn't flagged as a mismatch on macOS (#2913).
  Added a regression test for it.
- The table no longer truncates the repository id to 15 chars - users
  acting on an entry (e.g. removing a stale cache dir) need the full id.
  Strengthened the existing test assertion to match. Updated the
  epilog and the usage example accordingly (full id was previously
  JSON-only), and regenerated docs/usage/known-repos.rst.inc and
  docs/man/borg-known-repos.1 to match.
- legacy/fs.py: dropped the "(and now also the known-repos command)"
  historical phrasing from the module docstring; the following
  paragraph already lists both callers.
- Epilog: mentioned that a security-only repo gets layout "unknown"
  when BORG_SECURITY_DIR makes the borg2 and legacy security roots
  coincide. Thomas noted the behavior itself is fine either way, just
  worth a brief mention.

* known-repos: address PyPy msgpack warning and Windows encoding failures

Fixes two CI failures reported in maintainer follow-up review:

- PyPy: test_known_repos_without_repo_argument and
  test_known_repos_multiple_sorted_by_id failed with JSONDecodeError
  because pure-python msgpack emits PURE_PYTHON_MSGPACK_WARNING on PyPy,
  which contaminated exec_cmd() output. Introduce cmd_without_repo()
  helper that filters the warning line identically to cmd() while avoiding
  --repo injection, as suggested.
- Windows: test_known_repos_location_mismatch_ignores_nfc_nfd_difference
  crashed with UnicodeEncodeError writing NFD path in cp1252. Skip the NFD
  test on Windows, as suggested.
- Windows/scanner: read state files using default locale encoding matching
  borg's SaveFile/SyncFile implementation, dropping explicit encoding="utf-8",
  as suggested.

* known-repos: use security location mtime + cache newest-file mtime for last_written

PR #10497 (sealed files cache) removed CacheConfig.save(), so on
current master the borg2 cache config file's mtime stays at cache
creation time and no longer tracks last use.

last_written is now the newer of:
- the security location file's mtime (rewritten by Cache.close() via
  security_manager.save() on every run; also gives security-only
  repos a value)
- the newest mtime of the regular files below the cache dir, tracked
  during the existing cache_stats() walk that already computes size

The JSON field is renamed from cache_config_mtime to last_written.
Epilog/docs/man page say where the timestamp comes from and say
"regular file" to match what the code tracks. The long run-on epilog
sentence is split into shorter sentences.

Tests assert which source wins (location-newer, cache-newer including
a nested file) and the cache-only case now checks the exact mtime
instead of only non-null.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Should borg assume that it's okay to store unencrypted metadata information of the repositories in the cache without asking?

1 participant