Skip to content

feat(crypto): optional transparent at-rest AES-256-GCM encryption - #84

Closed
Xyvran wants to merge 28 commits into
TheodoreKrypton:masterfrom
Xyvran:master
Closed

feat(crypto): optional transparent at-rest AES-256-GCM encryption#84
Xyvran wants to merge 28 commits into
TheodoreKrypton:masterfrom
Xyvran:master

Conversation

@Xyvran

@Xyvran Xyvran commented May 16, 2026

Copy link
Copy Markdown

Summary

Adds an opt-in, transparent at-rest encryption layer that sits between
the file content API and the Telegram storage backend. When enabled, every
byte uploaded to Telegram is encrypted client-side -- the channel and the
metadata store only ever see ciphertext. Disabled by default; behavior is
unchanged for existing deployments.

Design

  • Cipher: AES-256-GCM in 64 KiB chunks, each with its own nonce + auth
    tag. Random-access decryption (HTTP Range, video streaming) keeps working.
  • Key hierarchy:
    • passphrase → master key via Argon2id at startup
    • master key + 32-byte random per-file salt → per-file key via
      HKDF-SHA256
  • File header: 60-byte, self-describing, HMAC'd, prepended inline to
    the first Telegram message. A file can be decrypted from the channel
    alone even if the TGFS metadata store is lost.
  • Tamper detection: every chunk has its own GCM tag plus an HMAC over
    the header; flipped bits or chunk reordering are caught before plaintext
    is returned.
  • Integration: decorator pattern around IFileContentRepository; the
    rest of TGFS is untouched. Adds IFileContentRepository.content_length()
    so HEAD / Content-Range report the plaintext size, not the ciphertext
    size.

Configuration

New optional tgfs.encryption block (off by default):

tgfs:
  encryption:
    enabled: true
    passphrase_env: TGFS_MASTER_PASSPHRASE   # or passphrase / passphrase_file
    master_salt_file: master.salt
    chunk_size: 65536

master_salt_file is auto-generated on first start (16 random bytes,
chmod 0600) relative to TGFS_DATA_DIR. The salt is not secret but must be
backed up alongside the passphrase -- losing it makes re-deriving the
master key impossible. Full docs in the updated README.md and
demo-config.yaml.

Backwards compatibility

  • Encryption is off by default. Existing configs keep working
    byte-identically; no migration required.
  • Legacy plaintext files stay readable when encryption is later turned
    on.
    Reads sniff the first four bytes: a TGFS magic routes through the
    decrypt path, anything else is streamed back from the inner repo
    unchanged. Writes always encrypt while the wrapper is active, so
    overwriting a legacy plaintext file produces ciphertext.
  • Loud failure for ambiguous cases. A file whose first four bytes are
    TGFS but whose header is truncated / malformed / fails MAC verification
    raises InvalidHeaderError rather than silently falling back to
    plaintext, which would mask a wrong master key.

Dependencies

  • cryptography (AES-GCM, HKDF, HMAC)
  • argon2-cffi (Argon2id master KDF)

Both are pinned in pyproject.toml; poetry.lock is regenerated. No
version bumps for existing direct dependencies.

Test plan

80 new tests added (49 unit + 18 end-to-end + 13 legacy-passthrough);
607/607 total pass, including all 487 pre-existing tests.

  • Round-trip across multiple chunk sizes and file sizes (including
    empty files and files smaller than one chunk)
  • Chunk-boundary HTTP Range requests (inclusive end-range semantics,
    matches Telegram backend's bytes_to_read = end - begin + 1)
  • HEAD / Content-Length report plaintext size, not ciphertext size
  • Tampering detection (flipped byte in header, in chunk body, in tag)
  • Wrong master key / wrong passphrase is rejected at header MAC
  • Legacy plaintext passthrough: full reads, range reads, short files
  • Overwrite a legacy plaintext file → ciphertext on the wire
  • Ambiguous TGFS-prefixed plaintext fails loudly
  • Header-detection cache: magic-byte probe runs at most once per file
  • Master salt auto-creation on first start; reload across restart

Commits

  • feat(crypto): transparent at-rest AES-256-GCM encryption -- core layer
  • feat(crypto): read legacy plaintext files transparently -- migration
  • fix(crypto): use inclusive end ranges; expose plaintext content-length
  • build(deps): refresh poetry.lock for cryptography and argon2-cffi
  • docs: explain how the encryption master salt is created

Claude and others added 28 commits May 15, 2026 14:28
Add an optional, transparent encryption layer that sits between the file
content API and the Telegram storage backend. When enabled via
`tgfs.encryption.enabled`, every byte uploaded to Telegram is encrypted
client-side; the channel and the metadata store only ever see ciphertext.

Design:
* AES-256-GCM in 64 KiB chunks, each with its own nonce + auth tag.
* Per-file key via HKDF-SHA256 from master key + 32-byte random salt.
* Master key derived from passphrase via Argon2id at startup.
* 60-byte self-describing file header (with HMAC) prepended inline to the
  first Telegram part, so a file can be decrypted from the channel alone
  even if TGFS metadata is lost.
* Decorator pattern around IFileContentRepository -- the rest of TGFS is
  unchanged. WebDAV range requests still work via per-chunk random access.

Configuration:
* New `tgfs.encryption` block in config.yaml with passphrase /
  passphrase_env / passphrase_file options (mutually exclusive).
* `master_salt_file` holds the 16-byte Argon2 salt; created on first run.

Test coverage: 67 new tests (49 unit + 18 end-to-end) including round-trip
across multiple chunk sizes, chunk-boundary range requests, tampering
detection, wrong master key detection, and empty-file handling. All 487
existing tests continue to pass.
When the encryption decorator is installed, reads now sniff the first
four bytes of each file: a TGFS magic match routes through the existing
decrypt path, anything else is streamed back from the inner repo
unchanged. This lets a deployment turn encryption on without losing
access to files that were uploaded before the switch.

Writes are unaffected -- save() and update() keep encrypting whenever
the wrapper is active, so overwriting a legacy plaintext file produces
ciphertext as expected.

Detection failures are loud, not silent: a file whose first four bytes
are "TGFS" but whose header is truncated, malformed, or fails MAC
verification still raises InvalidHeaderError. Silently falling back to
plaintext there would mask a wrong master key.

The header cache now stores a None sentinel for plaintext-detected
files, so the magic-byte probe runs at most once per file id.

13 new tests cover the passthrough path (full reads, range reads, files
shorter than the header / magic), the overwrite-plaintext-with-cipher
case, two loud-failure cases for ambiguous TGFS-prefixed plaintext, and
the detection cache.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The encryption decorator was internally treating `end` as a Python-slice
exclusive bound, while the rest of `IFileContentRepository` (and the
underlying Telegram `download_file` API: `bytes_to_read = end - begin + 1`)
uses HTTP-Range inclusive ends. The off-by-one made the wrapper pull one
extra ciphertext byte at chunk boundaries, which threw AES-GCM tag
verification on the following chunk and aborted the stream mid-response.
WebDAV range reads on encrypted files larger than one chunk returned
zero or truncated bytes; the final chunk happened to work because the
Telegram backend clamped at EOF.

Switch the wrapper to the inclusive convention end-to-end:

* `last_chunk = plaintext_offset_to_chunk(end, chunk_size)`
* `trim_total = end - begin + 1`
* normalise out-of-range / "to EOF" requests to `plaintext_total - 1`
* pass `ct_end_excl - 1` to the inner repo so the on-wire byte count
  matches a whole number of GCM chunks

Also expose the plaintext size to WebDAV clients: add an overridable
`IFileContentRepository.content_length(fv)` (default: `fv.size`),
override it in `EncryptingFileContentRepository` to subtract the file
header and per-chunk overhead, and wire `Resource.content_length()` to
call through to `Client.fc_repo`. HEAD requests and Content-Range now
report the plaintext byte count instead of the ciphertext size.

Tests updated to the inclusive convention; the in-memory mock now also
uses `end - begin + 1` so it matches the real backend. 607/607 pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The encryption feature added `cryptography` and `argon2-cffi` to
pyproject.toml but the lock file was not regenerated, so a clean
`poetry install --no-root` against this repo failed with
"pyproject.toml changed significantly since poetry.lock was last
generated". Regenerated with `poetry lock` (no version bumps for
existing direct deps).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The README mentioned ``master.salt`` had to be backed up but never said
where it comes from. Document that TGFS auto-generates it on first start,
where it gets written relative to ``TGFS_DATA_DIR``, how to seed it
manually, and why it must never be rotated in place.
Add ``telegram.delete_messages_on_remove`` (default ``false``). When
enabled, removing a file or directory via TGFS also issues
``messages.deleteMessages`` for the backing channel messages -- the
file-descriptor message plus every content message of every version --
instead of leaving them on the channel as historical behavior does.

Deletes are best-effort: failures are logged and never roll back the
metadata change, and the bot must be admin of the file channel with
"Delete Messages" permission for the API call to succeed.
Add ``telegram.delete_messages_on_remove`` (default ``false``). When
enabled, removing a file or directory via TGFS also issues
``messages.deleteMessages`` for the backing channel messages -- the
file-descriptor message plus every content message of every version --
instead of leaving them on the channel as historical behavior does.

Deletes are best-effort: failures are logged and never roll back the
metadata change, and the bot must be admin of the file channel with
"Delete Messages" permission for the API call to succeed.
Directories used to share a hardcoded `created_at_timestamp` of
`FIRST_DAY_OF_EPOCH`, so WebDAV clients displayed 1970-01-01 for every
folder while files showed real dates. Store `created_at` and
`modified_at` on `TGFSDirectory`, persist them through the
serialized form (`createdAt` / `modifiedAt` in ms, optional fields so
old metadata blobs deserialize unchanged), and expose `modified_at` as
WebDAV `getlastmodified`.

`modified_at` is bumped on the directly affected directory whenever its
contents change (file/subdir added or removed) — POSIX mtime semantics,
no cascade up the tree. Legacy directories saved without timestamps
continue to read back at the epoch until an organic write pushes a
fresh timestamp.
Adds created_at / modified_at timestamps to TGFS directories so WebDAV
clients display real dates for folders. See upstream PR.
Adds opt-in deletion of channel messages when a file or directory is
removed. See upstream PR.
- Type to_dict() of TGFSFileRef and TGFSDirectory with their TypedDicts
  so callers (including the round-trip test) pass the right type.
- Refactor _read_ts to accept an int directly; with literal keys mypy
  can infer the value type from TGFSDirectorySerialized.
Replace internal-invariant asserts with explicit RuntimeError raises so
they survive optimized bytecode (python -O) and stop tripping the
ruff S101 lint. Also drop the unused imports that ruff's autofix
flagged in stream.py.
Type serialization for directory and file reference models
Add message deletion support when removing files/directories
The TGFS encryption module already exists in the Python backend
(``EncryptionConfig``) but the config-generator on the docs site had no
UI for it, so users had to hand-edit ``encryption:`` into config.yaml.

This adds a new ``EncryptionField`` covering every option from
``EncryptionConfig``: the enable toggle, the three mutually-exclusive
passphrase sources (inline / env var / file), master_salt_file and
chunk_size. Only the active passphrase source is emitted to the YAML,
matching the loader's first-match semantics in tgfs/config.py.

The form also surfaces the operational pitfalls (back up the salt,
prefer env/file over inline) and a short explainer of the key
hierarchy so first-time operators understand what they are turning on.
Pulls in the ``encrypt_names`` option that the encrypt-filenames branch
adds to ``EncryptionConfig``. When enabled (default off), every uploaded
document name -- including the pinned metadata blob -- is replaced with
an AES-GCM ciphertext token (``TGFS1_<base64url>``), so a passive
observer of the channel cannot read file or directory names from the
document metadata.

The UI exposes it as a sub-toggle inside the encryption block (it only
applies when the master switch is on) and notes that the change is
forward-only: existing parts in the channel keep their original names.
Add at-rest encryption configuration UI to config generator
Aligns Dockerfile.frontend and the GitHub Pages workflow on Node 24,
the current Active LTS, replacing the EOL Node 18 base image and the
older Node 20 in CI.
Bumps @types/node to ^24 so type definitions match the Node 24 runtime
used in Docker and CI, and pins engines.node to >=24 so local installs
warn on incompatible versions.
Upgrade Node.js version requirement to 24
…#2)

* feat(crypto): opt-in encryption of telegram document names

A new ``encryption.encrypt_names`` config flag replaces every
Telegram-visible document name with an AES-256-GCM ciphertext token
(domain-separated name key derived via HKDF from the master key).
Plaintext names continue to live inside the already-encrypted
metadata.json blob, so WebDAV and the manager UI stay unaffected
while a passive observer of the channel no longer reads file or
directory names from Telegram document metadata.

Only new uploads are obfuscated; pre-existing files keep their
original document name in the channel.

* test(crypto): assert encrypted name stays consistent across multipart split

* docs(encryption): explain how to set TGFS_MASTER_PASSPHRASE across deployments

---------

Co-authored-by: Claude <noreply@anthropic.com>
)

* fix(github-meta): restore real directory timestamps from git history

The github_repo metadata backend rebuilds the directory tree from the
repo's folder structure on every load and constructed each GithubDirectory
without timestamps, so created_at/modified_at fell back to datetime.now().
WebDAV therefore reported the server-start time as every folder's
creationdate, resetting on each restart.

Recover the real dates from the repo's git history: created_at from the
commit that introduced the directory's immutable `.gitkeep` placeholder,
modified_at from the newest commit under the directory path. Falls back
gracefully (keeps the default) when a path has no history, and never lets
a history lookup crash the metadata load.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(github-meta): never let timestamp lookup abort the directory load

Broaden the commit-date lookup to swallow any error (not just
IndexError/GithubException) so a failed history query degrades to the
default timestamp instead of aborting the whole metadata rebuild. This
also fixes the existing github_repo tests, whose mocked get_commits()
returned a non-subscriptable Mock. Add a test asserting created/modified
are restored from commit dates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The root has no .gitkeep to derive a creation date from, so it still fell
back to now(). Use the backing repository's own created_at / pushed_at
timestamps instead. Best-effort with an isinstance guard so it never sets
a bogus value or breaks the load.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…epo (#8)

With the github_repo backend the whole directory tree and all filenames
were stored as plaintext folders/files in the GitHub repo. When
encrypt_names is enabled we now also encrypt those path components, so the
metadata repo no longer reveals the structure.

- New tgfs/crypto/path_names.py: deterministic AES-256-SIV name encryption
  (TGFSP1_ prefix, base64url). Deterministic — unlike the random-nonce
  document-name scheme — because path components must round-trip to a
  stable storage identifier.
- client.py derives a domain-separated path-name key from the master key
  and passes it to the github backend when encrypt_names is set.
- gh_directory stores encrypted segments on write (dirs and file refs) and
  tracks per-directory whether it is stored encrypted, so encrypted and
  legacy-plaintext entries coexist (mixed read/write); delete tries the
  encrypted then the plaintext name.
- The loader decrypts names on read and keeps using the storage path for
  the git-history date lookup, so the directory-timestamp fix still works.
- Tests for the crypto module and the backend round-trip / mixed-read.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(scripts): add metadata path-name migration tool

One-off, idempotent migration that re-encrypts existing plaintext
directory/file names in the GitHub metadata repo to match what the server
writes once encrypt_names is enabled. Defaults to a dry run; --apply
rewrites the tree in a single commit after creating a pre-name-encryption
backup tag. Run it only after deploying the name-encryption feature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(scripts): allow print() in the migration CLI tool (ruff T201)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#10)

_delete_github_directory used per-entry delete_file, which can only remove
files (GitHub returns 422 on a subtree) and never recursed, so deleting a
folder with subfolders left the nested .gitkeep/files behind — the folder
kept reappearing in the repo (and after a reload, in listings).

Rewrite the git tree without any blob under the directory's path and commit
it once via the Git Data API, dropping arbitrarily-nested content
atomically. Update the two affected tests.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Xyvran

Xyvran commented Jun 13, 2026

Copy link
Copy Markdown
Author

Closing in favour of a focused, rebased PR: #89 (at-rest encryption) and #90 (document-name encryption). This PR tracked my fork's default branch and had accumulated unrelated changes; the new PRs are scoped to one feature each and based on current master.

@Xyvran Xyvran closed this Jun 13, 2026
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.

2 participants