Files
VeraCrypt/Tests/test_byte_order_interop.py
Mounir IDRASSI 37ce73775f Tests: verify rejected passwords leave volumes unchanged
Compare volume contents before and after a rejected password change.
Check before reopening, which rewrites both headers and could hide damage.
2026-10-09 21:16:17 +09:00

242 lines
10 KiB
Python

#!/usr/bin/env python3
"""Check that volume headers are compatible between little- and big-endian builds.
Usage:
python3 Tests/test_byte_order_interop.py --binary-a /path/to/veracrypt \
[--binary-b /path/to/other/veracrypt] [--skip-official] \
[--fixture test.sha512.hc ...]
Each binary is a console (or GUI) veracrypt. A binary built for another CPU
can be run through qemu-user binfmt support. No root is needed: volumes are
created with --filesystem=none and only their passwords are changed, which
decrypts and re-encrypts the volume headers without mounting anything.
For every binary, the volumes in Tests/test.*.hc (created by little-endian
VeraCrypt, password "test") must open with the KDF named in the file name,
accept a new password, and then open with the new password (with KDF
autodetection for the first volume).
With two binaries, for every encryption algorithm and cascade (the PRFs are
used in turn, so every PRF is covered too), in both directions:
- a volume created by one binary opens with the other,
- after that password change, the first binary opens it again,
- a wrong password is rejected without changing the volume's contents,
and the volume still opens.
The KDF is passed with --hash, except in one case per direction, which
exercises KDF autodetection.
Build one binary on a little-endian and one on a big-endian CPU (or
cross-compile one) to check byte-order compatibility. Only the headers are
exercised here; the data-area XTS code is covered by the self-test vectors
(--test). Volumes are created in a temporary directory that is removed
afterwards. VeraCrypt runs with the C locale and an empty configuration
directory, so its messages are in English whatever the user's settings.
"""
import argparse
import os
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
OFFICIAL_PASSWORD = "test"
PASSWORD = "abcdefghijklmnopqrstuvwxyz0123"
NEW_PASSWORD = "zyxwvutsrqponmlkjihgfedcba9876"
WRONG_PASSWORD = "wrongwrongwrongwrongwrongwrong"
ALGORITHMS = [
"AES", "Serpent", "Twofish", "Camellia", "Kuznyechik",
"AES-Twofish", "AES-Twofish-Serpent", "Camellia-Kuznyechik",
"Camellia-Serpent", "Kuznyechik-AES", "Kuznyechik-Serpent-Camellia",
"Kuznyechik-Twofish", "Serpent-AES", "Serpent-Twofish-AES",
"Twofish-Serpent",
]
PRFS = ["sha512", "sha256", "blake2s", "whirlpool", "streebog", "argon2"]
COMMON = ["--text", "--non-interactive", "--keyfiles=",
"--random-source=/dev/urandom"]
class StepFailed(Exception):
pass
class Runner:
def __init__(self, workdir, timeout):
self.timeout = timeout
# VeraCrypt takes its language from LANG and from the saved
# preferences, so use the C locale and an empty config directory.
config = workdir / "config"
config.mkdir()
self.env = os.environ.copy()
self.env.update(LANG="C", LC_ALL="C", XDG_CONFIG_HOME=str(config))
def run(self, binary, args):
command = [binary] + COMMON + args
try:
return subprocess.run(command, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True,
env=self.env, timeout=self.timeout,
check=False)
except subprocess.TimeoutExpired:
raise StepFailed(f"timed out after {self.timeout} s: "
f"{' '.join(command)}")
def step(self, name, binary, args):
result = self.run(binary, args)
if result.returncode != 0:
raise StepFailed(f"{name}: exit {result.returncode}\n"
f"{result.stderr.strip() or result.stdout.strip()}")
def change_args(volume, kdf, old, pim, new, new_pim):
args = ["--change", str(volume), f"--password={old}", f"--pim={pim}",
f"--new-password={new}", f"--new-pim={new_pim}", "--new-keyfiles="]
if kdf:
args.append(f"--hash={kdf}")
return args
def official_volumes(runner, binary, fixtures, workdir):
failures = 0
for i, source in enumerate(fixtures):
kdf = source.name.split(".")[1]
volume = workdir / source.name
shutil.copyfile(source, volume)
start = time.monotonic()
try:
# The fixtures use PIM 0; the new header uses PIM 1, so only
# opening the original header costs a full key derivation.
runner.step("open and change password", binary,
change_args(volume, kdf, OFFICIAL_PASSWORD, 0,
PASSWORD, 1))
# The command line has no way to only check a password without
# mounting, so another password change verifies the new header.
# The first volume is opened without --hash, which covers KDF
# autodetection (cheap with PIM 1).
runner.step("open with the new password", binary,
change_args(volume, kdf if i else None, PASSWORD, 1,
NEW_PASSWORD, 1))
print(f"ok {binary}: {source.name} "
f"({time.monotonic() - start:.0f} s)", flush=True)
except StepFailed as e:
print(f"FAIL {binary}: {source.name} "
f"({time.monotonic() - start:.0f} s): {e}", flush=True)
failures += 1
volume.unlink()
return failures
def interop_case(runner, maker, opener, algorithm, prf, kdf, volume):
volume.unlink(missing_ok=True)
runner.step(f"create by {maker}", maker,
["--create", str(volume), "--size=1M", "--volume-type=normal",
f"--encryption={algorithm}", f"--hash={prf}",
"--filesystem=none", f"--password={PASSWORD}", "--pim=1"])
runner.step(f"open by {opener}", opener,
change_args(volume, kdf, PASSWORD, 1, NEW_PASSWORD, 1))
runner.step(f"reopen by {maker}", maker,
change_args(volume, kdf, NEW_PASSWORD, 1, PASSWORD, 1))
before_wrong_password = volume.read_bytes()
result = runner.run(opener, change_args(volume, kdf, WRONG_PASSWORD, 1,
NEW_PASSWORD, 1))
if result.returncode != 1 or "Incorrect password" not in result.stderr:
raise StepFailed(f"wrong password by {opener}: exit "
f"{result.returncode}\n{result.stderr.strip()}")
# Check before another password change can repair a damaged header.
if volume.read_bytes() != before_wrong_password:
raise StepFailed(f"wrong password by {opener}: volume contents changed")
runner.step(f"open by {opener} after a wrong password", opener,
change_args(volume, kdf, PASSWORD, 1, NEW_PASSWORD, 1))
def interop(runner, maker, opener, workdir):
failures = 0
for i, algorithm in enumerate(ALGORITHMS):
prf = PRFS[i % len(PRFS)]
# One case leaves out --hash to exercise KDF autodetection. sha512
# is tried first anyway, so use the second case (sha256).
kdf = None if i == 1 else prf
name = f"{algorithm}/{prf}{'' if kdf else ' (autodetect)'}"
start = time.monotonic()
try:
interop_case(runner, maker, opener, algorithm, prf, kdf,
workdir / "interop.hc")
print(f"ok {name}: {maker} -> {opener} "
f"({time.monotonic() - start:.0f} s)", flush=True)
except StepFailed as e:
print(f"FAIL {name}: {maker} -> {opener} "
f"({time.monotonic() - start:.0f} s): {e}", flush=True)
failures += 1
return failures
def select_fixtures(names):
available = sorted((ROOT / "Tests").glob("test.*.hc"))
if not names:
if not available:
sys.exit(f"No Tests/test.*.hc volumes found in {ROOT / 'Tests'}")
return available
selected = []
for name in names:
path = ROOT / "Tests" / name
if path not in available:
sys.exit(f"Fixture not found: {path}")
selected.append(path)
return selected
def main():
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--binary-a", required=True, help="veracrypt binary")
parser.add_argument("--binary-b",
help="second veracrypt binary (other byte order)")
parser.add_argument("--skip-official", action="store_true",
help="do not open the Tests/test.*.hc volumes")
parser.add_argument("--fixture", action="append", default=[],
metavar="NAME",
help="open only this volume from Tests/, given by "
"file name, e.g. test.sha512.hc (repeatable; "
"default: all)")
parser.add_argument("--timeout", type=int, default=900,
help="seconds per veracrypt call (default: 900)")
args = parser.parse_args()
if args.skip_official and not args.binary_b:
parser.error("--skip-official needs --binary-b, "
"otherwise there is nothing to test")
if args.skip_official and args.fixture:
parser.error("--fixture and --skip-official exclude each other")
binaries = [str(Path(args.binary_a).resolve())]
if args.binary_b:
binaries.append(str(Path(args.binary_b).resolve()))
fixtures = [] if args.skip_official else select_fixtures(args.fixture)
failures = 0
with tempfile.TemporaryDirectory(prefix="vc-byte-order-") as tmp:
workdir = Path(tmp)
runner = Runner(workdir, args.timeout)
for binary in binaries:
failures += official_volumes(runner, binary, fixtures, workdir)
if len(binaries) == 2:
failures += interop(runner, binaries[0], binaries[1], workdir)
failures += interop(runner, binaries[1], binaries[0], workdir)
if failures:
sys.exit(f"Failed: {failures} check(s)")
print("All checks passed", flush=True)
if __name__ == "__main__":
main()