Skip to content

Qsafe post-quantum encryption, signed manifests, streaming AES v2, manifest hardening#14

Open
SP1R4 wants to merge 14 commits into
mainfrom
feature/qsafe-integration
Open

Qsafe post-quantum encryption, signed manifests, streaming AES v2, manifest hardening#14
SP1R4 wants to merge 14 commits into
mainfrom
feature/qsafe-integration

Conversation

@SP1R4

@SP1R4 SP1R4 commented Jul 13, 2026

Copy link
Copy Markdown
Owner

Summary

Integrates the Qsafe project as a post-quantum encryption backend and hardens the manifest/verify/restore pipeline. Three commits:

1. Qsafe encryption backend + ML-DSA-87 signed manifests

  • [ENCRYPTION] backend = qsafe: backups encrypt to recipient public keys (X25519 + ML-KEM-1024 + AES-256-GCM, NIST FIPS 203 Level 5). The backup host never holds the decryption secret; qsafe_secret_key is needed only for restore/verify. Multi-recipient escrow via comma-separated qsafe_recipients.
  • decrypt_file dispatches by magic bytes (QSAFE00x / BHE2 / BHE1 / legacy), so all formats coexist and old backups keep restoring.
  • Optional manifest signing (qsafe_sign_key/qsafe_sign_pub): detached ML-DSA-87 signature per manifest; invalid signatures mark the manifest corrupted in --verify and abort point-in-time restores.
  • Config schema bumped to 5.

2. In-place verification, fail-fast preflight, key-management runbook

  • --verify authenticates Qsafe files in place via the AEAD tag — no plaintext ever written during verification.
  • Qsafe readiness preflight: a run aborts (exit 2 + critical alert + sentinel) before copying files when the engine or key files are missing — previously encryption failed after the copy, leaving a plaintext backup on disk.
  • RUNBOOK section 6: key inventory, key ceremony, Shamir escrow custody, rotation rules, escrow-key drill requirements.

3. Streaming AES v2 + manifest hardening

  • Streaming AES v2 (BHE2, default): chunked AES-256-GCM, per-chunk counter nonce with final-chunk flag, header bound as AAD. Constant memory (300 MB file roundtrips in ~34 MB RSS); truncation/extension/reorder/KDF-downgrade all fail authentication.
  • rel_path in manifests: restore/verify resolve entries exactly instead of rglob(name)[0] — same-named files can no longer restore the wrong content.
  • enc_checksum in manifests: ciphertext SHA-256 recorded post-encryption (signing runs after). --verify proves encrypted-backup integrity with no keys, and detects swapped valid ciphertexts, which AEAD alone cannot. Restore also compares restored plaintext against the manifest checksum and fails on mismatch.
  • CI qsafe-integration job: builds liboqs (cached) + the Qsafe CLI from source so the roundtrip/tamper/signature tests actually run on every PR.

Test plan

  • 222 tests pass locally (37 new: qsafe roundtrips, multi-recipient escrow, tamper rejection, signature gates, v2 streaming edge cases, rel_path collisions, keyless checksum verification).
  • Live E2E drives against the real qsafe CLI: backup → keyless verify → tampered-manifest abort → swapped-ciphertext detection → escrow-key restore, all exercised.
  • Strict mypy clean on qsafe_backend.py, manifest.py; ruff/format clean on all changed files.
  • The new CI job runs the Qsafe integration tests on ubuntu for the first time — watch its first run on this PR.

https://claude.ai/code/session_01WDbZuLNLRv2W3rdsS3wX4j

SP1R4 added 14 commits May 25, 2026 11:36
Five changes that close concrete weaknesses in the backup pipeline:

- Replace paramiko.WarningPolicy with RejectPolicy. Unknown SSH hosts are
  refused with an actionable known_hosts hint instead of silently accepted.
  New config: [SSH] known_hosts. Shared factory in src/ssh_client.py keeps
  sync.py and restore.py from drifting apart.
- Encryption format v1 with a 4-byte magic ("BHE1") and a 1-byte KDF id.
  Magic + KDF bytes are bound into the AES-GCM associated_data so flipping
  the KDF byte invalidates the tag (prevents downgrade). Legacy .enc files
  (no magic, salt+nonce+ct) auto-detected and decryptable for backward
  compatibility.
- Argon2id alongside PBKDF2 as a passphrase KDF. Opt-in via
  [ENCRYPTION] kdf = argon2id and `pip install 'backup-handler[argon2]'`.
  Per-file KDF id stored in the header — switching the default is safe.
- Atomic .enc writes: write to .tmp, fsync, os.replace, then unlink the
  plaintext. A crash mid-write no longer leaves a half-written .enc
  alongside the original.
- assert_config_safe_for_hooks() refuses to run shell hooks when the
  config file is group- or world-writable. Hooks run with shell=True so
  the config is the trust boundary; bypass with BACKUP_HANDLER_TRUST_CONFIG=1.

Config schema bumped to v4. New tests cover the v1 header, downgrade
attempts, legacy decrypt, atomic-write cleanup, and the hook-safety check.
All 141 tests pass.
…anup

- _copy_single_file: hash source and destination once each instead of
  three times (verify_backup() rehashed, then we rehashed again for the
  manifest). Halves disk I/O on every local copy.
- update_last_backup_time / update_last_full_backup_time now write the
  JSON via tmp + fsync + os.replace, so a concurrent reader or power-cut
  never sees a half-written timestamp file.
- Manifest loading enforces backup_manifest_YYYYMMDD_HHMMSS.json exactly.
  Old code did a string prefix-strip on names matching the glob, which
  would silently include foreign files or truncated names.
- s3_sync: sweep multipart uploads older than 24h under our prefix at
  the start of every run. boto3's TransferManager aborts on failures it
  sees, but SIGKILL/OOM kills bypass that path and leave billable parts.
- show_status reads total_bytes from the latest manifest when present;
  rglob().stat() walks were blocking --status for tens of seconds on
  multi-TB trees.

New tests cover the manifest regex (malformed names ignored) and basic
BackupManifest save/load.
Move everything under src/backup_handler/ so the wheel installs as a
clean ``backup_handler`` package instead of a ``src/`` directory at the
site-packages root. External submodules (banner, bot, email attachments)
move in alongside the core modules.

Split the 1,314-line cli.py into focused units:

- cli.py            entrypoint + argv routing (~170 lines)
- dispatch.py       early-exit handlers (--install, --status, --verify,
                    --snapshot, --restore-snapshot, --snapshot-diff,
                    --restore)
- orchestrator.py   backup_operation + scheduled_operation + helpers
- status.py         the --status dashboard
- lock.py           single-instance PID lock
- _paths.py         single source of truth for filesystem locations

Knock-on changes:

- pyproject.toml: packages = ["src/backup_handler"], version path moved,
  scripts entry points at backup_handler.cli:main, lint/format/bandit
  excludes pruned of the merged directories.
- Dockerfile: drop the stale ``COPY main.py``.
- tests/: ``from src.X`` -> ``from backup_handler.X``;
  conftest.py puts src/ on sys.path.
- email_nots/email.py renamed to backup_handler/email_attachments.py
  (the email module name shadowed the stdlib ``email`` package).

PR 6 will replace the hard-coded PROJECT_ROOT paths in _paths.py with
XDG-aware lookups so installed wheels stop pointing into /tmp.

All 132 tests still pass.
- tests/test_e2e_backup.py: full -> verify -> restore roundtrip against a
  temp tree, encrypted and unencrypted variants. Catches the cross-module
  bugs (checksum mismatch, header drift, manifest path encoding, decrypt
  flow) that unit tests miss. The single most valuable test in the suite.
- tests/test_sync.py: _copy_single_file copies, records manifest, handles
  symlinks, records failure on unwritable destinations; sync respects
  exclude patterns.
- tests/test_s3_sync.py: stale-multipart sweep aborts old uploads, leaves
  fresh ones alone, swallows list/abort errors so a flaky AWS API doesn't
  abort the backup.
- pyproject [tool.coverage.report].fail_under = 25. Below current 26.4%
  so CI fails when coverage regresses; raise as coverage grows.
- pyproject [[tool.mypy.overrides]] adds a strict block for the leaf
  modules (_paths, encryption, lock, manifest, ssh_client, status) that
  already have full type hints. A second CI step gates on this strict
  check (the broader mypy run stays advisory).
- compression.py: pyminizip is now a lazy optional import. Plain
  compression works without it; zip_pw raises a clear error if the C
  extension is missing. Unblocks PR 5's pyzipper swap.
…blocks

pyminizip is a C extension stuck on ZipCrypto (weak, brute-forced in
seconds with known plaintext) and stopped building cleanly on Python
3.13+. Swap it for pyzipper — pure Python, WinZip AES-256, drops a
build dep:

- compression.py: _write_aes_encrypted_zip() writes pyzipper archives
  with ZIP_DEFLATED + WZ_AES at 256 bits. Arcnames are relative to
  src_dir so the archive mirrors the source tree.
- tests/test_compression.py covers the happy path and the wrong-password
  rejection.

Dependency layout fixed:

- requirements.in lists only the direct deps (10 lines, with floors).
- requirements.txt is now pip-compile output: every transitive annotated
  with what brought it in. Old requirements.txt mixed direct and
  transitive without provenance; bumping anything was a guessing game.
- pyproject floors bumped to current stable: cryptography 43->45,
  paramiko 3.4->3.5, boto3 1.28->1.34, pyTelegramBotAPI 4.22->4.27.

CI:

- pip-audit no longer continue-on-error — known CVEs in cryptography or
  paramiko block the merge. Backup tools must not ship with unpatched
  TLS/crypto libraries.

12 of the 13 open Dependabot PRs on the repo target transitives that
this change removes from requirements.txt. They will close automatically.
- _paths.py: deterministic search order for config/data/lock locations.
  $BACKUP_HANDLER_CONFIG_DIR -> $XDG_CONFIG_HOME/backup-handler ->
  /etc/backup-handler -> <package_root>/config. Same shape for data
  (XDG_DATA_HOME -> /var/lib/backup-handler), preserving existing
  package-root installs first so nobody loses their Logs/ on upgrade.
  Lock follows XDG_RUNTIME_DIR -> /var/run -> data dir.
- cli.py: banner only prints under sys.stdout.isatty(). Cron firings
  no longer log 30 lines of ANSI escapes per invocation.
- dispatch.handle_restore: refuse when --from-dir resolves to the same
  path as --to-dir or contains it. The old behavior happily overwrote
  the backup tree mid-read. Skipped for ssh://, s3://, user@host:
  sources since they cannot collide with a local destination.
- dispatch.py: drop the duplicate stderr prints — logger already has a
  StreamHandler() (stderr) attached, so logger.error() reaches the
  user terminal without the second print().
- New tests cover the XDG search order, lock fallback, the remote-path
  detector, and the self-overwriting-restore refusal.
- README:
  - New "Why this vs. restic / borg / duplicity?" section near the top —
    states what this tool is for and what it isn't, so evaluators can
    self-select instead of scrolling 1,200 lines.
  - Preflight, --install, system snapshot, and restore drill sections
    reduced to one-paragraph teasers that link to the corresponding
    docs/ files. README is 1,035 lines now, down from 1,198.
  - Python badge corrected to 3.10+ (matches pyproject).
  - Compression feature row references pyzipper / AES-256, not pyminizip.

- docs/: new directory with the extracted deep dives —
  preflight.md, install.md, snapshots.md, restore-drill.md. These cover
  the long-form material that was crowding out the quickstart.

- CHANGELOG: comprehensive [Unreleased] entry covering the security,
  correctness, refactor, deps, and UX changes from the six prior PRs in
  this series. Grouped per Keep a Changelog (Security / Changed / Added).

- CONTRIBUTING: dev workflow now references the post-refactor module
  paths and the new strict-mypy leaf-module check; release process
  points at the new __version__ location.
Integrates the Qsafe project (X25519 + ML-KEM-1024 + AES-256-GCM hybrid,
NIST FIPS 203 Level 5) as an encryption-at-rest backend and manifest
signer.

Encryption ([ENCRYPTION] backend = qsafe):
- Backups encrypt to recipient *public* keys (qsafe_recipients,
  comma-separated for multi-recipient escrow) — the backup host never
  holds the decryption secret; qsafe_secret_key is needed only for
  restore/verify and can live off-host.
- decrypt_file dispatches by magic bytes (QSAFE00x vs BHE1 vs legacy),
  so AES and Qsafe .enc files coexist and old backups keep restoring.
- Engine: libqsafe Python bindings, falling back to the qsafe CLI
  (passphrase via QSAFE_PASSPHRASE env, never argv).

Signed manifests ([ENCRYPTION] qsafe_sign_key / qsafe_sign_pub):
- Each backup_manifest_*.json gets a detached ML-DSA-87 signature at
  backup time; works with either encryption backend.
- --verify marks a manifest with an invalid signature corrupted;
  point-in-time --restore aborts on one. Missing signatures only warn.
- Manifest .sig files are excluded from encryption so they stay
  verifiable without decryption keys.

Config schema bumped to 5. qsafe_backend.py and manifest.py are
strict-mypy gated (pyproject overrides + CI blocking list).

Claude-Session: https://claude.ai/code/session_01WDbZuLNLRv2W3rdsS3wX4j
…t runbook

- --verify now authenticates Qsafe .enc files in place via the AEAD tag:
  no plaintext is ever written to disk during verification. AES files
  keep the decrypt-to-temp + size-check path.
- Qsafe readiness preflight in backup_operation: aborts with exit 2
  (critical alert + status sentinel) before any files are copied when
  the engine (bindings/CLI) or configured key files are missing.
  Previously encryption failed after the copy, leaving a plaintext
  backup on disk.
- RUNBOOK section 6: key inventory, key ceremony, Shamir escrow custody,
  rotation rules (retain old secret keys until retention expires), and
  escrow-key drill requirements. Restore-drill doc cross-references it.

Claude-Session: https://claude.ai/code/session_01WDbZuLNLRv2W3rdsS3wX4j
…safe CI

Four hardening/performance improvements:

Streaming AES format v2 (magic BHE2, default for new .enc files):
chunked AES-256-GCM, per-chunk counter nonce with a final-chunk flag,
full header bound as AAD. Constant-memory encrypt/decrypt (300 MB file
roundtrips in ~34 MB RSS vs whole-file-in-RAM), and truncation,
extension, reordering, or KDF-downgrade all fail authentication.
v1 and legacy files remain decryptable.

Exact file resolution (manifest rel_path): every record_copy call site
(local full/incremental/differential, SSH, S3, DB dump) records the
backup-relative path. Restore and verify resolve entries exactly;
previously restore took rglob(filename)[0], so two same-named files in
different subdirectories could silently restore the wrong content.
Old manifests fall back to the filename heuristics.

Ciphertext binding (manifest enc_checksum): after encryption, the
SHA-256 of each .enc file is recorded and the manifest is signed after
that pass. --verify now proves encrypted-backup integrity with no keys,
and detects swapped valid ciphertexts (AEAD alone cannot). Point-in-time
restore additionally compares restored plaintext against the manifest
checksum and fails on mismatch instead of restoring altered content.

CI qsafe-integration job: builds liboqs (cached) + the Qsafe CLI from
source on ubuntu, so the roundtrip/tamper/signature tests actually run
on every PR instead of taking their skip path.

Claude-Session: https://claude.ai/code/session_01WDbZuLNLRv2W3rdsS3wX4j
The PR lineage included seven commits that had never run through CI;
this makes every job green:

- sync.py: import verify_backup — incremental and differential backups
  raised NameError at the post-copy verification step (real bug, masked
  by untested paths).
- Readiness check: validate config completeness (recipients, key files)
  before engine availability, so missing-key errors are actionable on
  hosts without qsafe — also makes the readiness tests engine-agnostic.
- test extra: add argon2-cffi so Argon2id KDF paths run in CI and the
  AAD-downgrade test fails on the tag, not on a missing import.
- Strict mypy: annotate logger params (lock, status, ssh_client,
  encryption), coerce argon2/json returns, os.walk str() — plus
  types-tqdm in the dev extra.
- Ruff/black: fix the remaining 48 findings — banner trailing
  whitespace, unused imports/variables, undefined main in orchestrator
  __main__, contextlib.suppress, N999/S103/S108 per-file-ignores with
  rationale, black-compatible noqa placement.
- Bandit B105 / ruff S105: mark the YOUR_APP_PASSWORD template
  placeholder comparison as not-a-credential.
- .gitleaks.toml: allowlist qsafe CLI --key-file/--pub-file doc
  examples that trip the generic-api-key rule (scanned historically,
  so an inline fix could not work).

Claude-Session: https://claude.ai/code/session_01WDbZuLNLRv2W3rdsS3wX4j
…p-audit, private Qsafe clone

- scripts/simulate_unmounted_disk.py still imported the pre-src-layout
  `src.preflight` module — broken since the refactor. Point it at
  backup_handler.preflight (and satisfy import sorting).
- gitleaks: the flagged "secret" was documentation prose ("key ceremony,
  escrow/Shamir"), not the CLI example — and it lives in a historical
  commit, so rewording cannot fix it. Allowlist the project's own
  markdown docs; code/config/history remain fully scanned. Verified
  clean with gitleaks 8.24.3 over all 46 commits.
- pip-audit: --strict treats skipped editable dists as fatal, and the
  job installed with -e. Install non-editable so the project itself is
  audited, and drop the now-contradictory --skip-editable.
- qsafe-integration: SP1R4/Qsafe is a private repo, so the anonymous
  clone exits 128. Use the QSAFE_REPO_TOKEN secret when present, with
  an anonymous fallback should the repo go public.

Claude-Session: https://claude.ai/code/session_01WDbZuLNLRv2W3rdsS3wX4j
…transitives

- tests/test_config.py: black 26 hugs multiline-string args; reformat so
  the CI formatter (installed at latest) agrees.
- pip-audit: audit requirements.txt instead of the installed environment.
  The project itself is not on PyPI, so --strict flagged it unauditable
  regardless of editable/non-editable install.
- Regenerate requirements.txt with pip-compile --upgrade: clears 18 known
  vulnerabilities across urllib3 (2.2.2), cryptography (45.0.7), idna,
  jaraco-context, pynacl, and requests. pip-audit --strict is now clean
  against the lockfile.

Claude-Session: https://claude.ai/code/session_01WDbZuLNLRv2W3rdsS3wX4j
gitleaks-action lists PR commits through the GitHub API; that worked
implicitly while the repo was public, but on a private repo it fails
with "Resource not accessible by integration" unless the job token has
pull-requests: read.

Claude-Session: https://claude.ai/code/session_01WDbZuLNLRv2W3rdsS3wX4j
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.

1 participant