# ZUPT 5.2.8 threat model

This document defines the security boundary of the ZUPT archive tool. It is
not a certification, a guarantee against every hostile input, or a substitute
for reviewing the exact source and binary used for important data.

## Intended use

ZUPT is intended for at-rest backup archives created and restored on
machines controlled by the user. It can be used when the storage provider or
physical medium is not trusted, provided encryption is enabled and credentials
remain secret.

It is not a network protocol, a full-disk encryption system, a multi-party or
threshold scheme, a password manager, or a way to make an archive's existence
plausibly deniable.

## Baseline considered here

The upstream baseline is built from the 5.2.8 source with:

```sh
make WITH_SDK=0 WITH_PQBOX=0
```

It contains the native password, ML-KEM-768 + X25519 hybrid `--pq`, and
ML-KEM-768-only `--pq-only` modes. It does not load a precompiled library from
the repository and does not download a dependency while building.

`WITH_SDK=1` and `WITH_PQBOX=1` add separately installed system libraries and
change the assessed code boundary. The SDK and PQBOX integrations must be
reviewed with their exact packaged source and version; success of the baseline
tests is not evidence for them.

Textual assembly under `jasmin/` is a separate `WITH_JASMIN=1` option for
supported x86_64 compiler targets. The directory contains both generated and
separately identified hand-written assembly. Portable C is the default.
Architecture portability is a source property, not evidence that an unexecuted
architecture passed.

## Assets

The assets ZUPT tries to protect are:

- archived file contents and encrypted index data;
- the integrity and ordering of encrypted archive blocks and current global
  metadata covered by the archive integrity trailer;
- private keys, passwords, and derived encryption/MAC keys while held by the
  trusted caller;
- safe placement of extracted entries within the requested destination.

The archive's existence, total byte length, magic, encryption/framing flags, and
some size/structure information are observable. Plain archives provide
corruption detection, not cryptographic protection against an active attacker.

## Adversaries considered

The design considers an adversary who can read, copy, truncate, reorder, or
modify stored archive bytes but cannot read the encryption endpoint's memory or
credentials. It also considers accidental corruption and malicious archive
entry paths during extraction.

The following adversaries are outside the protection boundary:

- malware, a keylogger, or an administrator on the source or restore endpoint;
- an attacker who obtains the password or matching private key;
- a malicious compiler, kernel, CPU, firmware, or random-number generator;
- an attacker with unrestricted side-channel observation of a shared machine;
- an attacker allowed unbounded CPU, memory, or storage denial of service.

## Security properties

### Encrypted archive confidentiality

Password and native PQ modes encrypt blocks with AES-256-CTR and authenticate
them with HMAC-SHA256. Confidentiality depends on unique nonces, correct
implementations, OS randomness, and credential secrecy. In password mode it
also depends on password entropy; PBKDF2-SHA256 slows but cannot prevent offline
guessing of a weak password.

Prefer `--password-prompt`, `--pass-file`, or `--pass-fd`. A password supplied
through `-p/--password` can be visible through process inspection or shell
history. A password file is protected only by the caller's filesystem choices;
ZUPT does not validate its ownership or permission bits. A descriptor is
trusted input inherited from the caller. Both non-interactive forms read one
line and reject empty, NUL-containing, or overlong values. The descriptor form
duplicates but shares the underlying stream/offset and may buffer beyond the
line, so callers should provide a descriptor dedicated to that password read.
On POSIX, handled prompt interruptions restore the saved terminal state before
termination; an exact-candidate PTY regression is required before release.
On Windows, a prompt is entered only for a real console input handle;
redirected input and console EOF fail instead of blocking in `_getch`.

Native private-key generation uses no-replace creation with POSIX mode `0600`
or a Windows current-user-only DACL. A failed write, flush/fsync, or close leaves
the incomplete or durability-uncertain exclusive file for manual review and
removal instead of risking an unlink-after-close race against a replacement
pathname. ZKEY and ZPQK inputs
are accepted only after checksum, version, flags, reserved bytes, exact size,
and public/private role validation. This prevents role confusion and
partial/trailing-key acceptance; it does not protect a key after endpoint or
account compromise.

When the optional system SDK is enabled, the in-repository adapter copies a key
through the core atomic publisher, applies POSIX mode through the already-open
temporary descriptor, and publishes only after copy/close checks succeed. Its
`sdk-test` regression preserves existing symlink/hardlink targets and verifies
private/public modes. This narrows the adapter boundary; it does not extend the
baseline assessment to the external SDK implementation.

### Encrypted archive integrity

Current encrypted archives authenticate ciphertext, canonical block metadata,
and each frame's logical position. DATA and DEDUP_REF frames both receive this
positional AAD. A reference is authenticated at its own position and carries
the authenticated source position needed to verify the referenced DATA frame,
so exchanging otherwise equivalent frames is not accepted.

Current archives carry an archive-integrity trailer for global metadata. The
`extract`, `list`, `test`, and `disk restore` paths refuse any no-AIT layout by
default without relying on an unauthenticated header flag.
`--allow-legacy-no-ait` is a narrowly scoped, warning-producing recovery option
for those commands when the caller already trusts a pre-AIT archive. Selecting
it for attacker-controlled storage removes the header/footer authentication
assumption and is outside this threat model. `info` is an unauthenticated
framing inspection that reports apparent AIT presence but validates neither the
trailer nor archive contents. These checks do not prevent deletion of the
entire archive, rollback to an older valid archive, or storage-layer replay.

Archive comments remain untrusted presentation data even when they are
authenticated. Display paths render control bytes without emitting raw terminal
control sequences, limiting terminal-output injection while leaving the stored
and authenticated comment bytes unchanged.

New 5.2.2 encrypted+dedup archives authenticate each reference offset. New
encrypted disk archives also authenticate an index that binds image size,
block count, and a chained XXH64 hash of the complete restored stream. The
writer's additional SHA-256/128 comparison is only an in-memory collision guard
before deduplication; it is not an on-disk cryptographic hash. XXH64 is not
cryptographic, so a writer who controls a plain archive can recompute it.

Plain archives use non-cryptographic checksums. A writer who controls a plain
archive can recompute them.

### Native hybrid post-quantum mode

The `--pq` mode combines an ML-KEM-768 shared secret and an X25519 shared secret
as implemented in 5.2.2:

```text
hybrid_ikm  = ml_ss XOR x25519_ss
archive_key = SHA3-512(hybrid_ikm || ml_ct || ephemeral_pk ||
                       "ZUPT-HYBRID-v1")
```

Its goal is harvest-now/decrypt-later resistance if ML-KEM-768 remains secure,
with X25519 as a classical hedge under the combiner assumptions. This is not
session forward secrecy: compromise of the recipient's long-term private key
can compromise previously captured archives encrypted to it.

The native `--pq-only` mode removes X25519 and derives a key from ML-KEM-768
alone. Use it only when a policy specifically excludes the classical component;
it loses the hybrid hedge.

The in-tree ML-KEM code has project tests, including known-answer vectors and a
conditional OpenSSL 3.5 interoperability test. It has not been independently
audited or formally verified as a whole implementation.

### Extraction containment

The reader rejects absolute paths, traversal components, control characters,
ambiguous trailing dot/space components, NTFS alternate-stream syntax, and
reserved Windows device names. POSIX extraction resolves every parent below a
pinned destination descriptor with no-follow operations after canonicalizing
the user-selected root once. Windows extraction
uses handle-relative traversal, rejects reparse-point parents, and publishes the
final name by handle without replacing an existing leaf. A checked path is not
re-resolved through a mutable parent.

Decoded bytes are first written to a private, exclusively created temporary
file. The final name is published only after the expected decoded size and
chained checksum match and the stream closes successfully; failures remove the
temporary through its descriptor or handle. These controls reduce traversal,
link, race, and partial-output risks, but do not establish that no parser or
filesystem bug can exist.

Benchmark scratch data lives in a random private directory. Cleanup resolves
POSIX components without following links and deletes relative to pinned
descriptors. On Windows it retains no-delete-sharing ancestor handles, refuses
reparse-point recursion, then reopens each emptied directory relative to its
pinned parent and verifies its filesystem identity before handle-based
deletion. An attacker who inserts a link can cause cleanup failure, but the
cleanup must not traverse to the link target.

The Windows handle-relative boundary in 5.2.8 covers normal local Win32 paths.
Win32 extended-length and device-namespace paths, raw UNC output roots, and
mapped/network-drive output are not supported. Cross-build and Wine results are
not a substitute for the required native `windows-latest` Unicode package
gate. Restore locally before moving verified output to network storage.

Disk restore copies the measured compacted archive into one exclusively
created, auto-deleted scratch file. Preflight and restoration consume that same
open snapshot. An explicit `ZUPT_TMPDIR` selects an existing scratch directory;
failure there does not fall back to consuming the mutable source pathname. On
POSIX, the destination is opened once without truncation or final-symlink
following, classified with `fstat`, and the same raw-device descriptor is
retained for supported Linux, macOS, and FreeBSD capacity checks and writes.
Regular-file output retains atomic publication. A raw target is rejected before
writing if its capacity is unknown or smaller than the image. These controls
reduce source exchange, target exchange, and immediate overrun risk but do not
protect against a compromised kernel/device, a wrongly selected sufficiently
large device, power loss, or hardware failure.

The SDK publication, POSIX disk-target, and benchmark-cleanup changes address
CodeQL High #5, #6, and #7 respectively. Their source review and regressions
are project evidence, not independent certification or proof that the exact
5.2.8 hosted/native gates passed.

For an untrusted archive:

1. use a new empty destination outside sensitive trees;
2. run as a dedicated unprivileged user, never root;
3. apply a container, sandbox, resource limits, and a storage quota when
   available;
4. inspect extracted paths, types, permissions, and content before moving them;
5. never restore a disk image to a device without independently confirming both
   source and destination.

## Non-goals and residual risks

ZUPT does not claim to provide:

- resistance to cache, power, EM, acoustic, speculative-execution, or all
  compiler-introduced timing side channels;
- bounded resource consumption for every malformed archive;
- confidentiality of archive size or complete framing metadata;
- protection against compression-length oracles when secret and
  attacker-controlled data are compressed together;
- rollback detection across multiple valid versions of a backup;
- forward-secure sessions, remote authentication, replay protection, or secure
  transport;
- automatic key rotation, recovery, escrow, threshold access, or secure
  deletion;
- preservation of every operating-system ACL, ownership attribute, extended
  attribute, or special-file semantic;
- safe operation on a compromised host.

## Credential handling

- Generate PQ keys on a trusted system using the OS CSPRNG.
- Keep private keys separate from the archive and from release/package inputs.
- Store an offline recovery copy and test recovery before relying on a backup.
- Use a distinct high-entropy credential where compromise isolation matters.
- Re-encrypt under a new credential after suspected disclosure; there is no
  in-place key rotation.
- Never include credentials or sensitive archives in bug reports or CI logs.

## Supply-chain boundary

Git and upstream source archives are source-only. They must pass
`scripts/check-source-only.sh` and must not contain executable code artifacts,
objects, shared/static libraries, distribution packages, unsafe symlinks, or Git
LFS pointers.

Nested inspection is itself an untrusted-input boundary. The release scanner
must cap recursion depth, archive members, per-entry expansion, and total
expanded bytes and fail closed when a cap is reached. Commit `ff99770` passed
all 39 source-only scanner cases, including GNU thin archives, scanner-bomb
limits, and safe diagnostic cases.

DEB, binary RPM, SRPM, notice-bearing Linux tar.xz, source-only portable GUI
ZIP, Windows ZIP, and macOS DMG files can be published separately from the
tagged source. Each artifact extends the trust boundary to its builder,
toolchain, runner image, and packaging scripts. Treat it as validated only when
the exact target has a recorded build, content/package inspection, extracted or
installed smoke test, and applicable archive round trip. An AppImage is not
promoted for 5.2.8; bare Linux and Windows executables are also excluded.

For 5.2.8, that gated artifact scope covers the CLI files plus the exact GUI
DEB, noarch/source RPM, and source-only portable ZIP named in the README. The
portable ZIP contains no compiled runtime and crosses the release boundary only
after source scans and an exact safe-member check. AppDir and Flatpak bundles
and GUI platform installers remain excluded; Windows ZIP and macOS DMG outputs
remain CLI-only.

The immutable, non-promoted 5.2.2 candidate at `ff99770` passed the full local
`make release-check`: packaging reported `PASS=49 FAIL=0 SKIP=0`; strict GCC,
strict Clang, GCC `-fanalyzer`, the 9/9 tool-enabled static-analysis run,
ASan/UBSan/LSan, and 1,000 mutation-fuzz iterations passed. Earlier off-screen
GUI smoke evidence is retained separately. Post-tag CI integration failures
prevented 5.2.2 promotion. This upstream self-review is not an independent
certification and is not 5.2.8 evidence. The immutable 5.2.3 candidate was not
promoted because its source-policy test assumed LF for a Windows `.bat` checkout
that correctly used CRLF. The immutable v5.2.4 candidate was not promoted after
exact-tag GitHub Actions run `33431386002`: 12 jobs succeeded, the sole openSUSE
service-harness job failed because its standalone executor did not enter the
service directory, and dependent Windows/macOS jobs were skipped. A local
Tumbleweed reproduction established that the explicit `refs/tags/v5.2.4`
revision works and that `os.chdir(service_dir)` completes the source-service
chain. This narrows the failure to release/test integration; it changes no
product, archive, cryptographic, codec, or SDK ABI boundary and supplies no
automatic 5.2.8 evidence. The immutable v5.2.5 candidate was not promoted after
exact-tag GitHub Actions run `33434986357`: 13 jobs succeeded, but native
Windows and macOS failed on fixture-byte preservation and Darwin/Bash 3.2
portability respectively. The corresponding 5.2.6 corrections were followed by
exact-tag run `33442264243`: 13 jobs succeeded, while native macOS failed on
x86-only SHA-NI helper declarations unused on arm64 under `-Werror`, and native
Windows aborted during safe UTF-8 fixture argv transcoding. The v5.2.6 tag was
not promoted. Version 5.2.7 corrected those two boundaries, but its exact-tag
run `33445470664` concluded `cancelled` at `2026-08-31T23:11:19Z`, with 13
successful jobs, one failed macOS job after raw-C1 filename creation returned
`EILSEQ`, and one cancelled Windows job after the hosted job stalled in `make
check`; a MinGW/Wine reproduction isolated the cause to a redirected password
prompt entering `_getch`.
The corresponding 5.2.8 fixture and prompt corrections do not establish their
own test result. CI now exercises `sdk-test`, but its inclusion is not a pass.
Hosted GitHub CI and release promotion, native
Windows/macOS, authenticated OBS, and the openSUSE automatic `debugsource`
rpmlint `no-binary` finding remain pending until an exact 5.2.8 candidate
records them.

## Historical compatibility notes

These are historical facts about earlier releases, retained to support recovery:

- Releases through 4.1.0 could reuse an AES-CTR nonce in encrypted `--dedup`
  archives. Release 4.2.0 changed to fresh random per-block nonces. Re-encrypt
  affected older archives.
- Releases through 4.2.1 used round-3 CRYSTALS-Kyber semantics in the native PQ
  path. Release 5.0.0 corrected the implementation to FIPS 203 ML-KEM-768,
  changing native PQ key/archive compatibility. See `CHANGELOG.md` before
  planning cross-version restoration.
- Pre-AIT archive layouts now fail closed by default. The explicit
  `--allow-legacy-no-ait` read option is only for recovery from a known, trusted
  historical archive and leaves its header/footer metadata outside the current
  authenticated boundary.
- The 5.2.2 reader retains compatibility parsers for the fixed-width disk index
  and encrypted-dedup linear AAD sequence published through 5.2.1. An actual
  v5.2.1 password-encrypted DATA/DATA/REF/DATA disk fixture is stored as hexadecimal
  text with source and hash provenance. The candidate lists, tests, extracts, and restores
  it byte-exact, with a warning that the legacy index has no whole-image hash;
  the full local Linux gate passed on commit `ff99770`. Older readers are not
  claimed to accept new flag-gated 5.2.2 records, and untested historical mode
  combinations remain unclaimed.

Historical test counts in the changelog describe those releases. They do not
automatically become 5.2.8 results; current outcomes belong in the release
validation record, with unavailable environments marked `SKIP`. In particular,
runs made before the final positional-AAD and mandatory-AIT changes are not
final release gates for the resulting candidate.

## Reporting security issues

Email **zupt@riseup.net** with `[security]` in the subject. Include the version,
platform, impact, and a minimal non-sensitive reproducer. Do not disclose the
issue publicly until a coordinated timeline has been agreed.

Document version: 5.2.8, 2026-08-31.
