# Security Policy — ZUPT 5.2.8

## Reporting vulnerabilities

Report suspected vulnerabilities privately to **zupt@riseup.net** with
`[security]` in the subject. Do not open a public issue before coordinated
disclosure.

Include the output of `zupt --version`, operating system and architecture,
impact, and the smallest safe reproducer. Remove passwords, keys, tokens,
personal data, and confidential archive contents.

The project aims to acknowledge reports within five business days and to target
high-severity fixes within 30 days, with the disclosure timeline agreed case by
case. These are targets, not a warranty.

ZUPT has not had an independent third-party security audit or certification.
Treat the in-repository review and tests as reproducible project evidence, not
as external assurance.

## Supported security modes

| Mode | CLI | Key establishment / derivation | Payload protection |
|---|---|---|---|
| Plain | no encryption option | none | compression checksums only |
| Password | `-p/--password`, `--password-prompt`, `--pass-file`, or `--pass-fd` | PBKDF2-SHA256, 600,000 iterations | AES-256-CTR + HMAC-SHA256 |
| Native hybrid PQ | `--pq` | ML-KEM-768 + X25519, SHA3-512 combiner | AES-256-CTR + HMAC-SHA256 |
| Native PQ only | `--pq-only` | ML-KEM-768, SHA3-512 derivation | AES-256-CTR + HMAC-SHA256 |

The native hybrid mode is the recommended post-quantum mode unless a policy
forbids a classical component. `--pq-only` removes the X25519 fallback: a break
of ML-KEM-768 alone would then compromise key establishment. Password security
is bounded by password entropy; PBKDF2 cannot make a short or reused password
safe against offline guessing.

The `-p/--password PASSWORD` argument form can be visible to process-list users
and shell history. Prefer `--password-prompt`, `--pass-file` with restrictive
permissions, or `--pass-fd` with a descriptor inherited from a trusted caller.
The file/descriptor forms read one line, remove LF and an optional preceding CR,
and reject empty, NUL-containing, or overlong input. ZUPT does not enforce
password-file ownership or mode; the caller remains responsible for creating,
protecting, and deleting that file. The descriptor form duplicates the supplied
descriptor and does not close the caller's original descriptor.
The duplicate shares the same underlying stream and offset, and buffered input
may consume beyond the password line. Pass a descriptor dedicated to this one
password read; do not reuse it as a multi-record protocol channel.

On POSIX terminals, the explicit prompt saves terminal state and installs
signal-aware cleanup so a handled interruption restores echo and other changed
settings before termination. This behavior is covered by a PTY regression and
passed in the full local Linux gate for commit `ff99770`.
On Windows, the prompt requires a real console input handle before entering
`_getch`; redirected input and console EOF fail instead of blocking a native
release gate.

## Native key files

Native private keys use no-replace creation: POSIX files are mode `0600` and
Windows files receive a current-user-only DACL. An existing destination is
never truncated. If write, flush/fsync, or close fails, ZUPT deliberately leaves
the exclusively created incomplete or durability-uncertain file at that path
for the user to inspect and remove. It does not unlink by pathname after close,
which avoids deleting a replacement installed during a race. Public keys may be
shared deliberately and are not treated as secret. ZKEY and ZPQK readers
validate the checksum, format version, flags, reserved bytes, exact encoded
size, and public/private role before using any key material. A truncated,
extended, structurally invalid, or role-confused key is rejected rather than
partially accepted.

### Optional integrations

The 5.2.8 default is `WITH_SDK=0 WITH_PQBOX=0`:

- `WITH_SDK=1` enables libvuptsdk-backed features, including the SDK PQ mode
  and Argon2id support, using a separately installed system development package.
- `WITH_PQBOX=1` independently enables the libpqvaptvupt sealed-box mode using
  its separately installed system development package.

Neither library is committed as a precompiled artifact, and no build path
downloads it. A missing requested dependency is a build error. Security
properties of these optional libraries are outside the source-only CLI audit
unless their exact source package and version are reviewed separately.

The in-repository SDK adapter saves copied keys through the core atomic output
publisher. POSIX permissions are applied to its already-open temporary
descriptor, and publication replaces only the requested directory entry after
copy/close checks succeed. `make sdk-test` exercises private/public modes and
preservation of pre-existing symlink and hardlink targets. This covers the
adapter boundary; it does not certify the separately installed SDK library.

## Cryptographic construction

Encrypted blocks use a fresh 128-bit nonce, AES-256-CTR, and HMAC-SHA256. The
MAC binds the encrypted payload, canonical block metadata, and the frame's
logical position, and is checked before restored data is accepted. In 5.2.2,
this positional AAD applies to DATA and DEDUP_REF frames. An authenticated
reference is bound to its own position and carries the authenticated source
position needed to verify the referenced DATA frame.

An archive-integrity trailer (AIT) covers global metadata. The 5.2.2
`extract`, `list`, `test`, and `disk restore` paths refuse a no-AIT layout by
default, without trusting the archive's unauthenticated `ENCRYPTED` bit to
decide whether that check matters. `--allow-legacy-no-ait` is accepted only by
those commands; it is a recovery-only opt-in for a known, trusted pre-AIT
archive and emits a downgrade warning. Never use it for an archive from
untrusted or attacker-writable storage. `info` only reports unauthenticated
framing and apparent AIT presence; it does not validate the trailer or archive
contents. Plain archives use non-cryptographic checksums and do not provide
protection against an attacker who can rewrite the archive.

Archive comments are authenticated according to the archive mode, but they are
still untrusted display data. ZUPT renders control bytes safely when showing a
comment and does not emit raw terminal-control sequences. This prevents a valid
or attacker-supplied comment from rewriting terminal output; it does not make a
plain archive cryptographically authentic.

In new 5.2.2 encrypted+dedup archives, each reference offset is included in
the authenticated reference payload. New encrypted disk archives also
authenticate their index; the index binds the image size, block count, and a
chained XXH64 hash of the complete restored byte stream. The writer
additionally requires an XXH64 and SHA-256/128 match before emitting a dedup
reference, but that SHA-256/128 digest is an in-memory collision guard and is
not stored as an on-disk integrity claim. XXH64 is non-cryptographic: in a
plain archive it
detects accidental corruption but can be recomputed by an attacker.

The native hybrid derivation implemented by 5.2.2 is:

```text
ml_ss       = ML-KEM-768 shared secret
x25519_ss   = X25519 shared secret
hybrid_ikm  = ml_ss XOR x25519_ss
archive_key = SHA3-512(hybrid_ikm || ml_ct || ephemeral_pk ||
                       "ZUPT-HYBRID-v1")
```

`--pq-only` derives the archive key as
`SHA3-512(ml_ss || ml_ct || "ZUPT-PQ-ONLY-v1")`.

The native modes are at-rest archive encryption. They do not provide protocol
session forward secrecy: later compromise of the relevant long-term private key
can compromise archives encrypted to it.

## Constant-time and side-channel scope

Portable C is the 5.2.8 default. Sensitive comparisons and selections use
branchless helpers, but generated machine-code behavior remains dependent on
the compiler and platform. This is not a formal whole-program constant-time
claim. The C AES implementation uses table lookups and is unsuitable for a
claim of cache-timing resistance on hostile shared hardware.

Sensitive VaptVupt working buffers are cleared through a compiler-resistant
wipe helper. Platforms with a guaranteed libc `explicit_bzero` use it; macOS
and NetBSD use the portable volatile-write fallback because the supported
deployment targets do not guarantee that symbol. This source-level choice
resists ordinary dead-store elimination but is not a formal claim about every
compiler binary.

Textual assembly under `jasmin/` can be enabled explicitly with
`WITH_JASMIN=1` on a supported x86_64 compiler target. The directory contains
Jasmin-generated output and separately identified hand-written assembly; all of
it is disabled by default and its inclusion must be confirmed in the exact
binary being assessed. Its availability does not imply formal verification of
the archive parser, compression codec, or the whole program.

## Security boundary and limitations

ZUPT is designed for backups created and restored on trusted endpoints. It
does not protect against:

- malware, keyloggers, memory inspection, or a compromised user account on the
  machine handling plaintext or keys;
- disclosure of a password or private key;
- denial of service from arbitrarily large or adversarial input;
- traffic analysis from archive size and visible framing metadata;
- hiding that a file is a ZUPT archive;
- compression-length side channels when attacker-controlled and secret data are
  compressed together and an attacker can observe output length;
- network transport attacks, multi-party access control, threshold recovery, or
  key rotation;
- every compiler-, microarchitecture-, power-, or speculative-execution side
  channel.

Archive entry paths reject traversal, absolute paths, control characters,
Windows alternate streams/device names, and ambiguous trailing dot/space
components. POSIX extraction resolves each parent relative to a pinned file
descriptor with no-follow semantics after canonicalizing the user-selected
output root once; symlinks below that root remain forbidden. Windows resolves
each parent and temporary
file relative to a directory handle and performs the final no-replace rename by
handle, so a checked path is not looked up again through a mutable junction or
reparse point. An existing destination leaf is never overwritten.

Decoded bytes first go 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 by descriptor/handle. These controls reduce traversal, link, race,
and partial-output risks, but they do not make privileged extraction
appropriate. Extract untrusted archives as a dedicated unprivileged user into
a new empty directory, inspect the result before moving it, and apply OS
sandboxing where available.

Benchmark workspaces are random private directories. POSIX cleanup opens each
directory component without following links and removes entries relative to
pinned descriptors. Windows retains no-delete-sharing handles for the resolved
ancestors and refuses reparse-point recursion. After emptying a directory, it
reopens that entry relative to the pinned parent, verifies the volume and file
index against the traversal handle, and marks only the identity-checked handle
for deletion. An injected link is removed as a link rather than traversed to
its target.

Disk restore has a separate destructive-device boundary. It measures and
copies the compacted archive to an exclusively created, auto-deleted private
scratch file, then performs validation and restoration from that same snapshot.
`ZUPT_TMPDIR` is an explicit existing scratch-directory override; an invalid
override fails without fallback. On POSIX, the target is opened once without
truncation or final-symlink following, classified with `fstat`, and—when it is
a supported Linux, macOS, or FreeBSD raw block device—the same descriptor is
retained through capacity checks and writes. Regular files continue through
atomic publication. Unknown device capacity, an undersized device, a
source/destination identity match, or a snapshot failure stops before the
first target write. These checks do not
make raw-device restore non-destructive: verify both operands and keep recovery
media before proceeding.

These changes address the three 5.2.8 CodeQL High reports: #5 at SDK key
publication, #6 at POSIX disk-target classification/use, and #7 at benchmark
workspace cleanup. The regressions and source review are project evidence, not
an independent certification or a claim that exact-v5.2.8 CI has passed.

The C/C++ default-branch analysis of commit `69fc26b` closed #5, #6, and #7,
then opened test-only High #8, #9, and #10 because the new SDK regression used
path-level `stat`/`lstat` before later reads or cleanup. Each individual test
check now uses a no-follow descriptor with `fstat` or descriptor reads; the
static gate rejects reintroduction of path-level metadata checks there. The
subsequent C/C++ default-branch scan run `33452563116` completed successfully at
commit `7a8e5c5`; alerts #5 through #10 are fixed, and the authenticated
code-scanning API reported zero open alerts.

The Windows handle-relative implementation is scoped to normal local Win32
paths. Win32 extended-length and device-namespace paths, raw UNC output roots,
and mapped/network-drive output are not supported in 5.2.8. Cross-build and
Wine results are not native-Windows evidence; the `windows-latest` package gate
must pass its Unicode round trip before Windows assets are published. Restore
to a normal local directory first and move verified output to network storage
afterward.

## Historical compatibility and fixes

These statements are historical release records, not claims that every current
gate was rerun on every platform:

- In 4.2.0, encrypted deduplication changed from a repeated derived nonce to a
  fresh random per-block nonce. Re-encrypt encrypted `--dedup` archives written
  by releases through 4.1.0.
- In 5.0.0, native ML-KEM was corrected from round-3 CRYSTALS-Kyber semantics
  to FIPS 203 ML-KEM-768. Native `--pq` and `--pq-only` keys and archives from
  releases through 4.2.1 are not compatible with the corrected mode. Password
  and plain archive paths were not affected by that KEM change.
- Releases predating the archive-integrity trailer may have a structurally
  valid no-AIT layout. Such an archive now fails closed unless the caller uses
  `--allow-legacy-no-ait` on a supported read command. This option permits
  recovery of trusted old media; it is not a general compatibility mode and
  does not make unauthenticated header/footer metadata safe.
- Readers since 5.2.2 accept the fixed-width disk index and encrypted-dedup linear
  AAD sequence published through 5.2.1 and warns that the legacy index has no
  whole-image content hash. Its regression fixture is an actual v5.2.1
  password-encrypted DATA/DATA/REF/DATA disk archive stored as hexadecimal text with
  source and hash provenance. The candidate lists, tests, extracts, and restores it
  byte-exact; the full local Linux gate passed on commit `ff99770`. This does not
  claim that 5.2.1 readers accept the new flag-gated 5.2.2 records or that every
  historical encrypted+dedup combination was validated.

The repository contains NIST/RFC vector tests and an OpenSSL 3.5 ML-KEM
interoperability test. The OpenSSL test can only execute when the environment
provides an ML-KEM-capable OpenSSL; otherwise it must be reported as skipped.

## Source and release integrity

Git and upstream source archives contain no compiled executable, object,
shared/static library, or distribution package. Audit them with:

```sh
scripts/check-source-only.sh
scripts/check-source-only.sh --archive /path/to/zupt-5.2.8.tar.gz
```

Nested archive inspection is required to enforce bounded recursion, member
count, per-entry expanded size, and total expanded size, and to fail closed on
limit violations. On commit `ff99770`, the source-only scanner suite passed
39/39, including GNU thin archives, resource-limit cases, and safe diagnostics.

DEB, binary RPM, SRPM, notice-bearing Linux tar.xz, source-only portable GUI
ZIP, Windows ZIP, and macOS DMG release assets are separate outputs. An
AppImage is not promoted for 5.2.8. A bare Linux or Windows executable is also
excluded; executables are distributed only inside their notice-bearing
archives. Trust an artifact only when its exact format has a recorded build,
content/metadata inspection, extracted or installed smoke test, and applicable
archive round trip. Never treat an unexecuted platform as passing.

The gated 5.2.8 set is the CLI package/archive set plus the exact GUI DEB,
noarch/source RPM, and source-only portable ZIP documented in the README. The
portable GUI ZIP contains no compiled runtime and is scanned as source before
and after extraction. Other GUI packages, AppImage, AppDir and Flatpak bundles,
and GUI platform installers are excluded. Windows ZIP and macOS DMG artifacts
remain CLI-only.

## Reproducing project checks

Start with the baseline source-only build:

```sh
make clean
make -j"$(getconf _NPROCESSORS_ONLN 2>/dev/null || printf 1)" \
  WITH_SDK=0 WITH_PQBOX=0 V=1
make WITH_SDK=0 WITH_PQBOX=0 check
make WITH_SDK=0 WITH_PQBOX=0 test-all
make sdk-test
```

Where the compiler supports them, run the sanitizer target separately:

```sh
make test-asan
make test-asan-run
```

The first command builds the sanitizer configuration; the second executes its
test suite. Neither substitutes for the normal optimized build and tests.

The full local Linux `make release-check` passed on the immutable, non-promoted
5.2.2 candidate at `ff99770`. Its recorded evidence includes packaging
`PASS=49 FAIL=0 SKIP=0`,
the 39/39 source-only scanner suite, strict GCC and Clang builds, GCC
`-fanalyzer`, 9/9 static analysis in a tool-enabled run, ASan/UBSan/LSan, and
1,000 mutation-fuzz iterations without a sanitizer-detected crash. An earlier
off-screen GUI smoke run is supporting evidence, not an exact-candidate package
result.

Post-tag CI integration failures prevented 5.2.2 promotion. Those upstream
self-audit results are not independent certification and do not transfer to
5.2.8. The immutable 5.2.3 candidate was not promoted because its source-policy
test assumed LF for a Windows `.bat` file checked out as CRLF. The immutable
v5.2.4 candidate was not promoted after exact-tag GitHub Actions run
`33431386002`: 12 jobs succeeded, the sole openSUSE job failed in its
standalone source-service harness because it did not enter the service
directory, and dependent Windows and macOS jobs were skipped. A local
Tumbleweed reproduction confirmed that `refs/tags/v5.2.4` is valid and that
`os.chdir(service_dir)` lets `obs_scm`, `tar`, and `recompress` complete with a
source-scanned archive. This was a release/test integration defect, not a
product, archive, cryptographic, codec, or SDK ABI change, and its evidence does
not transfer automatically to 5.2.8. The immutable v5.2.5 candidate was also
not promoted: exact-tag GitHub Actions run `33434986357` recorded 13 successful
jobs and failed native Windows/macOS jobs. Its Windows fixture-byte and macOS
secure-wipe/Bash 3.2 defects were corrected for 5.2.6. A targeted clean-clone
run of the corrected scanner under genuine GNU Bash 3.2.57 passed repository, standalone
tree, standalone archive, and root-plus-tag modes; that local compatibility
result does not transfer to any other gate. Exact-tag v5.2.6 run `33442264243`
then completed 13 jobs successfully but failed native macOS because x86 SHA-NI
test helpers were unused on arm64 under `-Werror`, and failed native Windows
when argv transcoding aborted the safe UTF-8 fixture. Those are test-harness
integration defects, not product, archive, cryptographic, codec, or SDK ABI
changes; v5.2.6 remained unpromoted. The exact 5.2.8 candidate must
repeat the required suite. The immutable v5.2.7 candidate was likewise not
promoted: exact-tag run `33445470664` concluded `cancelled` at
`2026-08-31T23:11:19Z`, with 13 successful jobs, one failed macOS job after
raw-C1 fixture 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`. Version 5.2.8 makes
both test boundaries fail or skip without hanging. Manual pre-tag run
`33452602634` subsequently passed 14 of 15 jobs, including the native macOS
DMG and the Windows source audit, build, and full distribution checks. The
remaining Windows smoke failure was an old MSYS `grep` non-BMP pattern boundary
after ZUPT had compressed and verified all inputs; MinGW/Wine confirmed ZUPT's
byte-exact UTF-8 listing. The corrected gate validates Latin-1, BMP, and
non-BMP listing bytes without locale-sensitive matching, then requires
extraction and a full tree diff. The failed run is not exact-candidate
evidence. Exact-tag native gates, hosted CI, authenticated OBS service
execution, and release promotion remain pending. An unavailable or unexecuted
environment remains `SKIP`, never `PASS`.

Run target-native static analyzers and package checks as additional evidence.
Do not infer x86_64, aarch64, ppc64le, s390x, riscv64, macOS, Windows, Leap, or
SLE success from these commands unless that exact environment produced a
successful recorded result.

ZUPT application code is distributed under AGPL-3.0-or-later. The bundled
VaptVupt codec source is GPL-3.0-or-later. The two xxHash-derived XXH64 units
also carry BSD-2-Clause. The pq-crystals/kyber-derived portions of native
ML-KEM carry CC0-1.0 in addition to the application license, and the x86 BCJ
state machine is adapted from public-domain LZMA SDK source. Native X25519
portions adapted from curve25519-donna conservatively retain BSD-3-Clause. See
`LICENSE`, `LICENSE-GPL-3.0`, `LICENSE-BSD-2-Clause`, `LICENSE-BSD-3-Clause`,
`LICENSE-CC0-1.0`, `NOTICE`, and `THIRD-PARTY-NOTICES.md`. Historical license
grants for exact earlier material are recorded in the 5.2.2 licensing erratum;
the current notices do not revoke them.
