From 8db7e5ab8cbd51e4cbe1d0bf93329eb4a5dcc812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Le=20Goffic?= Date: Mon, 6 Jul 2026 20:52:33 +0200 Subject: [PATCH 2/2] Add container-based validation script for canonicalize determinism validate-hokey-canonicalize.sh reproduces Debian bug #1141576 and validates the fix in the previous commit, using throwaway Debian containers (docker or podman, auto-detected or forced via ENGINE): 1. reproduce the bug: the stock hopenpgp-tools packages from trixie (hashable 1.4.4.0, FNV) and sid (hashable 1.5.0.0, XXH3) produce different output for the same input certificate; 2. build the patched package in both suites, taking the patch from the most recent commit touching hokey.hs; 3. verify the patched binaries produce byte-identical output across suites, idempotent and packet-preserving. A test certificate (1 primary key + 3 subkeys) is generated inside a container when no certificate is supplied on the command line. Co-Authored-By: Claude Fable 5 --- validate-hokey-canonicalize.sh | 157 +++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100755 validate-hokey-canonicalize.sh diff --git a/validate-hokey-canonicalize.sh b/validate-hokey-canonicalize.sh new file mode 100755 index 000000000000..8bba71e6c481 --- /dev/null +++ b/validate-hokey-canonicalize.sh @@ -0,0 +1,157 @@ +#!/bin/sh +# validate-hokey-canonicalize.sh — reproduce Debian bug #1141576 and validate +# the "make output independent of the hashable version" patch. +# +# The bug: `hokey canonicalize` sorts packets via hOpenPGP's Ord instances, +# whose tie-breaker is Data.Hashable.hash. hashable 1.4.5.0 switched byte +# hashing from FNV to XXH3, so builds linking hashable < 1.4.5 (Debian trixie) +# and >= 1.4.5 (Debian sid) produce different "canonical" output for the same +# input certificate. +# +# This script, using throwaway Debian containers: +# 1. reproduces the divergence with the stock hopenpgp-tools packages +# from trixie (hashable 1.4.4.0, FNV) and sid (hashable 1.5.0.0, XXH3); +# 2. builds the patched package in both suites (the patch is taken from +# the HEAD commit of this repository via `git format-patch`); +# 3. verifies that both patched binaries produce byte-identical and +# idempotent output, with no packet lost. +# +# Usage: +# [ENGINE=docker|podman] ./validate-hokey-canonicalize.sh [certificate...] +# +# With no argument a fresh test certificate (1 primary key + 3 subkeys) is +# generated inside a container. Note: step 1 is probabilistic for an +# arbitrary certificate — the FNV and XXH3 orders can coincide by chance; +# steps 2-3 (the actual validation) are deterministic. +# +# Requires: docker or podman, network access. Runtime: ~10-20 min +# (two package builds). Everything happens in a temp dir printed at start. + +set -eu + +if [ -n "${ENGINE:-}" ]; then + : +elif command -v podman >/dev/null 2>&1; then + ENGINE=podman +elif command -v docker >/dev/null 2>&1; then + ENGINE=docker +else + echo "error: neither podman nor docker found" >&2 + exit 1 +fi + +SUITES="trixie sid" +REPO_DIR="$(cd "$(dirname "$0")" && pwd)" +WORK="$(mktemp -d)" +echo "== engine: $ENGINE, workdir: $WORK" + +run() { # run SUITE SCRIPT + "$ENGINE" run --rm -e DEBIAN_FRONTEND=noninteractive -e TERM=dumb \ + -v "$WORK":/work -w /work "debian:$1" sh -ec "$2" +} + +# --- step 0: patch and input certificate -------------------------------- + +fixcommit="$(git -C "$REPO_DIR" log -1 --format=%H -- hokey.hs)" +git -C "$REPO_DIR" format-patch -1 --stdout "$fixcommit" > "$WORK/fix.patch" +echo "== patch: $(git -C "$REPO_DIR" log --format=%s -1 "$fixcommit") ($(wc -l < "$WORK/fix.patch") lines)" + +if [ $# -gt 0 ]; then + i=0 + for f in "$@"; do + i=$((i + 1)) + cp "$f" "$WORK/cert$i.pgp" + done + echo "== using $i user-supplied certificate(s)" +else + echo "== generating a test certificate (1 primary + 3 subkeys)" + run trixie ' + apt-get update -qq && apt-get install -y -qq gnupg >/dev/null + export GNUPGHOME=$(mktemp -d) + gpg -q --batch --pinentry-mode loopback --passphrase "" \ + --quick-gen-key "Test User " ed25519 cert 0 + fpr=$(gpg --with-colons --list-keys | awk -F: "/^fpr/ {print \$10; exit}") + for algo in ed25519 cv25519 nistp256; do + gpg -q --batch --pinentry-mode loopback --passphrase "" \ + --quick-add-key "$fpr" "$algo" "" 0 + done + gpg --export "$fpr" > /work/cert1.pgp + ' +fi + +# --- step 1: reproduce the bug with stock hokey -------------------------- + +for suite in $SUITES; do + echo "== [$suite] canonicalizing with stock hopenpgp-tools" + run "$suite" ' + apt-get update -qq && apt-get install -y -qq hopenpgp-tools >/dev/null + for c in cert*.pgp; do + hokey canonicalize < "$c" 2>/dev/null > "$c.stock-'"$suite"'" + done + ' +done + +bug=0 +for c in "$WORK"/cert*.pgp; do + if ! cmp -s "$c.stock-trixie" "$c.stock-sid"; then + bug=1 + fi +done +if [ $bug -eq 1 ]; then + echo "== BUG REPRODUCED: stock trixie and sid outputs differ" +else + echo "== note: stock outputs happen to agree for this input (possible" \ + "by chance); the validation below is still meaningful" +fi + +# --- step 2: build and run the patched package in both suites ------------ + +for suite in $SUITES; do + echo "== [$suite] building patched hopenpgp-tools (~5-10 min)" + run "$suite" ' + sed -i "s/^Types: deb$/Types: deb deb-src/" \ + /etc/apt/sources.list.d/debian.sources + apt-get update -qq + apt-get install -y -qq dpkg-dev build-essential fakeroot gnupg \ + >/dev/null 2>&1 + apt-get build-dep -y -qq hopenpgp-tools >/dev/null 2>&1 + mkdir /build && cd /build + apt-get source hopenpgp-tools >/dev/null 2>&1 + cd haskell-hopenpgp-tools-* + patch -p1 < /work/fix.patch + dpkg-buildpackage -us -uc -b > /work/build-'"$suite"'.log 2>&1 + dpkg -i /build/hopenpgp-tools_*.deb >/dev/null 2>&1 + cd /work + for c in cert*.pgp; do + hokey canonicalize < "$c" 2>/dev/null > "$c.patched-'"$suite"'" + # idempotency: canonicalizing twice must be a no-op + hokey canonicalize < "$c.patched-'"$suite"'" 2>/dev/null \ + | cmp -s - "$c.patched-'"$suite"'" + # packet preservation: same number of packets in and out + in=$(gpg --list-packets "$c" 2>/dev/null | grep -c "^:") + out=$(gpg --list-packets "$c.patched-'"$suite"'" 2>/dev/null \ + | grep -c "^:") + [ "$in" = "$out" ] + done + ' +done + +# --- step 3: verdict ------------------------------------------------------ + +fail=0 +for c in "$WORK"/cert*.pgp; do + if cmp -s "$c.patched-trixie" "$c.patched-sid"; then + echo "== PASS: $(basename "$c"): patched outputs are byte-identical" + else + echo "== FAIL: $(basename "$c"): patched outputs differ" + fail=1 + fi +done + +if [ $fail -eq 0 ]; then + echo "== RESULT: PASS — patched hokey canonicalize is independent of" \ + "the hashable version (and idempotent, packet-preserving)" +else + echo "== RESULT: FAIL — see $WORK" +fi +exit $fail -- 2.55.0