# BST-TIMEOUT: 900 # ~45 cases, each a gpg spawn (~2-3 s apiece on this arm) plus keygen # # GnuPG 2.4.x, offline, in a scratch GNUPGHOME. The pass criterion is the ticket's: # generate a key in batch mode, sign and verify, encrypt and decrypt back to the # ORIGINAL BYTES, and confirm a tampered file FAILS verification. # # WHY EVERY SECRET-KEY CASE IS ALSO A gpg-agent CASE, which is the part a reader # should not have to infer: gpg 2.x does not touch a secret key itself. Keygen, # signing and decryption are all Assuan round-trips to gpg-agent, which gpg # auto-spawns over fork()+exec() and reaches through a UNIX socket in GNUPGHOME. # Both of those work on this arm (popen/system do NOT -- brintos/linux#14 -- but # gnupg's common/exechelp-posix.c uses fork/exec directly). So a red in this file # is either the crypto or the agent IPC, and the two are told apart by the # agent-free cases: gpgv/--verify need no agent at all. # # NO timeout(1) ANYWHERE, deliberately. Signals are unreliable on this arm and # `timeout N cmd` parks in the same idiom it is meant to bound # (brintos/linux-in-the-browser#161), so it would convert a hang into a longer # hang. The hang the ticket names -- keygen blocking on entropy -- is bounded two # other ways instead: the entropy source is probed NON-BLOCKINGLY before any key # is asked for (dd iflag=nonblock, which returns EAGAIN rather than waiting), and # the suite's own BST-TIMEOUT above is the backstop that turns a park into a # reported `not ok gnupg/suite-timeout` carrying the case count reached. . "$BST_LIB" require gpg gnupg/present TMP=${TMPDIR:-/tmp} GNUPGHOME=$TMP/bst-gnupg.$$ export GNUPGHOME mkdir -p "$GNUPGHOME" && chmod 700 "$GNUPGHOME" # gpg warns on a world-readable home and the warning lands in the output that # check_out greps; assert the scratch home is the shape gpg wants rather than # letting a umask surprise surface later as an unrelated keygen red. check_eq gnupg/home-mode 700 "$(stat -c %a "$GNUPGHOME" 2>/dev/null)" # Every invocation below is offline and non-interactive. --no-tty keeps gpg from # reaching for the guest console (which is the runner's own stdin). GPG="gpg --batch --yes --no-tty --homedir $GNUPGHOME" # ── the ported binaries ─────────────────────────────────────────────────────── check_out gnupg/version "gpg (GnuPG) 2.4" gpg --version check_out gnupg/version-home "Home: " $GPG --version # gpg links libgcrypt; --version reports the runtime it actually loaded, which is # the one assertion here that a mis-linked build could not fake. check_out gnupg/version-libgcrypt "libgcrypt" gpg --version # --version reports algorithm FAMILIES ("EDDSA"), not curve names; the curve list # is a separate query, and both matter -- EdDSA compiled in without Ed25519 among # the curves would still fail keygen below. check_out gnupg/algos-eddsa "EDDSA" gpg --version check_out gnupg/algos-aes256 "AES256" gpg --version check_out gnupg/curve-ed25519 "ed25519" gpg --with-colons --list-config curve check_out gnupg/curve-cv25519 "cv25519" gpg --with-colons --list-config curve # The tools this port actually ships on PATH, taken from the built package's file # list rather than from what a GnuPG install "usually" has. for t in gpg gpg-agent gpgv gpgconf gpg-connect-agent gpgsm gpgtar gpg-card \ gpgsplit kbxutil gpgparsemail watchgnupg; do if command -v "$t" >/dev/null 2>&1; then bst_ok "gnupg/have-$t" else bst_notok "gnupg/have-$t" "$t not installed by the gnupg packages"; fi done # The helpers gpg-agent execs are in /usr/libexec and deliberately NOT on PATH, # so command -v is the wrong probe for them; assert the files instead. for h in scdaemon gpg-protect-tool gpg-preset-passphrase gpg-check-pattern; do if [ -f "/usr/libexec/$h" ]; then bst_ok "gnupg/libexec-$h" else bst_notok "gnupg/libexec-$h" "/usr/libexec/$h missing"; fi done # dirmngr IS ABSENT ON PURPOSE, and that boundary is asserted rather than left as # a quiet hole. PACKAGECONFIG = "" drops gnutls (unported on this arm), dirmngr # requires NTBTLS or GnuTLS, and configure says so in as many words -- "Neither # NTBTLS nor GNUTLS available - not building dirmngr". Everything dirmngr does is # network (keyserver access, X.509 CRL/OCSP fetching), so an offline image loses # nothing it can use. Asserting the ABSENCE means a future image that gains TLS # turns this into a changed case rather than a silently different gnupg. if command -v dirmngr >/dev/null 2>&1; then bst_notok gnupg/dirmngr-absent "dirmngr is present, but this image builds gnupg with no TLS backend -- the port's boundary moved" else bst_ok gnupg/dirmngr-absent; fi # ── entropy, probed WITHOUT blocking ───────────────────────────────────────── # getrandom(2) is ENOSYS on this arm and libgcrypt falls through to /dev/random, # so this is the exact path keygen takes and the ticket names it as the hazard. # # NOTHING HERE MAY READ /dev/random UNCONDITIONALLY, which is the whole point: an # unseeded pool would park the read, and with timeout(1) unusable on this arm # (see the header) a parked read is a parked suite. The pool's state is read from # /proc instead, which cannot block, and the one direct read is gated on GNU dd's # iflag=nonblock -- gated because `dd` here may be busybox's applet, which has no # iflag at all and would fail in a way that reads like an entropy verdict. if [ -c /dev/urandom ]; then bst_ok gnupg/entropy-urandom-dev else bst_notok gnupg/entropy-urandom-dev "/dev/urandom is not a character device"; fi n=$(dd if=/dev/urandom bs=32 count=1 2>/dev/null | wc -c | tr -d ' ') check_eq gnupg/entropy-urandom-32 32 "$n" ea=$(cat /proc/sys/kernel/random/entropy_avail 2>/dev/null | tr -d ' ') case "$ea" in ''|*[!0-9]*) bst_notok gnupg/entropy-pool-readable "entropy_avail unreadable or non-numeric: '$ea'" ;; *) bst_ok gnupg/entropy-pool-readable if [ "$ea" -gt 0 ]; then bst_ok gnupg/entropy-pool-nonzero else bst_notok gnupg/entropy-pool-nonzero "entropy_avail is 0 -- the CRNG is unseeded and keygen would block on /dev/random"; fi ;; esac if dd --help 2>&1 | grep -q iflag; then n=$(dd if=/dev/random bs=32 count=1 iflag=nonblock 2>/dev/null | wc -c | tr -d ' ') if [ "$n" = "32" ]; then bst_ok gnupg/entropy-random-nonblock else bst_notok gnupg/entropy-random-nonblock "/dev/random gave $n of 32 bytes without blocking -- keygen would park here"; fi else bst_skip gnupg/entropy-random-nonblock "dd on this image has no iflag=nonblock (busybox applet); /proc entropy_avail above carries the verdict" fi # two draws must differ; a stuck source returning constant bytes would satisfy # the length assertions above and silently produce a predictable key. a=$(dd if=/dev/urandom bs=16 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n') b=$(dd if=/dev/urandom bs=16 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n') if [ -n "$a" ] && [ "$a" != "$b" ]; then bst_ok gnupg/entropy-distinct else bst_notok gnupg/entropy-distinct "two 16-byte draws were equal or empty: '$a' '$b'"; fi # ── batch key generation ───────────────────────────────────────────────────── # Ed25519/cv25519 rather than RSA: 32 bytes of very-strong random and no prime # search, where an RSA-3072 keygen on this arm would be minutes of MPI work and # would confound "slow" with "blocked on entropy" -- the very distinction the # ticket asks this suite to keep clean. # # %no-protection is load-bearing: this image ships no pinentry (the gnupg # bbappend drops the recommend), so a passphrase-protected key could never be # unlocked here. The key is a throwaway in a scratch home, so the missing # protection costs nothing. cat > "$GNUPGHOME/params" <<'EOF' Key-Type: eddsa Key-Curve: Ed25519 Key-Usage: sign Subkey-Type: ecdh Subkey-Curve: Curve25519 Subkey-Usage: encrypt Name-Real: brintOS Selftest Name-Email: selftest@brintos.invalid Expire-Date: 0 %no-protection %commit EOF UID_EMAIL=selftest@brintos.invalid genout=$($GPG --gen-key "$GNUPGHOME/params" 2>&1); genrc=$? if [ "$genrc" -eq 0 ]; then bst_ok gnupg/keygen elif bst_execfail "$genout"; then bst_unrun gnupg/keygen "$genout" else bst_notok gnupg/keygen "rc=$genrc $(_bst_1line "$genout")"; fi # gpg-agent is what actually made the key; say so as its own case so an agent # that never came up is not read as a crypto failure. if $GPG --list-secret-keys 2>/dev/null | grep -q "$UID_EMAIL"; then bst_ok gnupg/agent-made-key else bst_notok gnupg/agent-made-key "no secret key for $UID_EMAIL -- gpg-agent did not produce one"; fi check_out gnupg/list-public "$UID_EMAIL" $GPG --list-keys check_out gnupg/list-secret "$UID_EMAIL" $GPG --list-secret-keys check_out gnupg/key-algo-ed25519 "ed25519" $GPG --list-keys --with-colons check_out gnupg/key-algo-cv25519 "cv25519" $GPG --list-keys --with-colons # the colon listing is the machine interface; a `pub` record and a `sub` record # is the shape a primary+subkey generation must produce. check_out gnupg/colons-pub "pub:u:" $GPG --list-keys --with-colons check_out gnupg/colons-sub "sub:u:" $GPG --list-keys --with-colons check_out gnupg/trust-ultimate "uid:u:" $GPG --list-keys --with-colons FPR=$($GPG --list-keys --with-colons 2>/dev/null | awk -F: '/^fpr:/{print $10; exit}') if [ -n "$FPR" ]; then bst_ok gnupg/fingerprint else bst_notok gnupg/fingerprint "no fpr record in the colon listing"; fi check_eq gnupg/fingerprint-len 40 "$(printf '%s' "$FPR" | wc -c | tr -d ' ')" # ── the payload the round-trips run against ────────────────────────────────── # All 256 byte values, so a codec that is only 7-bit clean, or that mangles NUL # or CR/LF, fails the byte-identity compare rather than passing on ASCII. PLAIN=$GNUPGHOME/plain.bin i=0; : > "$PLAIN" while [ $i -lt 256 ]; do printf "\\$(printf '%03o' $i)"; i=$((i+1)); done >> "$PLAIN" # The payload's own shape is asserted, not assumed: a shell that dropped the NUL # or stopped at 0x7f would otherwise hand every round-trip below a shorter, all- # ASCII file and each would pass on it. Size and the first four bytes together # pin that the full 0x00..0xff really reached the file. check_eq gnupg/payload-size 256 "$(wc -c < "$PLAIN" | tr -d ' ')" check_eq gnupg/payload-head "00 01 02 03" "$(od -An -tx1 -v -N4 < "$PLAIN" | tr -s ' ' | sed 's/^ //;s/ $//')" check_eq gnupg/payload-tail "fc fd fe ff" "$(od -An -tx1 -v -j252 < "$PLAIN" | tr -s ' ' | sed 's/^ //;s/ $//')" # ── sign and verify ────────────────────────────────────────────────────────── check gnupg/sign-detached $GPG --local-user "$FPR" --detach-sign --output "$GNUPGHOME/plain.sig" "$PLAIN" if [ -s "$GNUPGHOME/plain.sig" ]; then bst_ok gnupg/sig-nonempty else bst_notok gnupg/sig-nonempty "detached signature is empty or absent"; fi check_out gnupg/verify-detached "Good signature" $GPG --verify "$GNUPGHOME/plain.sig" "$PLAIN" # gpgv is the agent-free verifier: it reads a keyring and nothing else, so a # green here with a red above would localise the fault to the agent IPC. $GPG --export --output "$GNUPGHOME/pub.gpg" 2>/dev/null check_out gnupg/gpgv-verify "Good signature" \ gpgv --keyring "$GNUPGHOME/pub.gpg" "$GNUPGHOME/plain.sig" "$PLAIN" # clearsign and inline-signed round-trips exercise the other two signature shapes check gnupg/clearsign $GPG --local-user "$FPR" --clearsign --output "$GNUPGHOME/plain.asc" "$PLAIN" check_out gnupg/clearsign-header "BEGIN PGP SIGNED MESSAGE" cat "$GNUPGHOME/plain.asc" check_out gnupg/verify-clearsign "Good signature" $GPG --verify "$GNUPGHOME/plain.asc" # ── a tampered file MUST fail verification ─────────────────────────────────── # The ticket names this case explicitly, and it is the one that would still pass # if the signature check were stubbed out to always succeed. cp "$PLAIN" "$GNUPGHOME/tampered.bin" # flip one byte in the middle, leaving the length identical dd if=/dev/zero of="$GNUPGHOME/tampered.bin" bs=1 seek=100 count=1 conv=notrunc 2>/dev/null if cmp -s "$PLAIN" "$GNUPGHOME/tampered.bin"; then bst_notok gnupg/tamper-differs "the tampered copy is byte-identical to the original -- the test would be vacuous" else bst_ok gnupg/tamper-differs; fi tout=$($GPG --verify "$GNUPGHOME/plain.sig" "$GNUPGHOME/tampered.bin" 2>&1); trc=$? if [ "$trc" -ne 0 ]; then bst_ok gnupg/verify-tampered-rc else bst_notok gnupg/verify-tampered-rc "gpg --verify accepted a tampered file (rc=0)"; fi case "$tout" in *"BAD signature"*) bst_ok gnupg/verify-tampered-msg ;; *) bst_notok gnupg/verify-tampered-msg "expected 'BAD signature', got: $(_bst_1line "$tout")" ;; esac # and the same through the agent-free verifier gout=$(gpgv --keyring "$GNUPGHOME/pub.gpg" "$GNUPGHOME/plain.sig" "$GNUPGHOME/tampered.bin" 2>&1); grc=$? if [ "$grc" -ne 0 ]; then bst_ok gnupg/gpgv-tampered-rc else bst_notok gnupg/gpgv-tampered-rc "gpgv accepted a tampered file (rc=0)"; fi # a truncated signature must also be refused, not silently accepted head -c 40 "$GNUPGHOME/plain.sig" > "$GNUPGHOME/trunc.sig" 2>/dev/null strc=0; $GPG --verify "$GNUPGHOME/trunc.sig" "$PLAIN" >/dev/null 2>&1 || strc=$? if [ "$strc" -ne 0 ]; then bst_ok gnupg/verify-truncated-sig else bst_notok gnupg/verify-truncated-sig "gpg --verify accepted a truncated signature"; fi # ── encrypt and decrypt back to the ORIGINAL BYTES ─────────────────────────── check gnupg/encrypt $GPG --recipient "$FPR" --trust-model always \ --encrypt --output "$GNUPGHOME/plain.gpg" "$PLAIN" if [ -s "$GNUPGHOME/plain.gpg" ]; then bst_ok gnupg/cipher-nonempty else bst_notok gnupg/cipher-nonempty "ciphertext is empty or absent"; fi # the ciphertext must not BE the plaintext -- a no-op "encrypt" would round-trip if cmp -s "$PLAIN" "$GNUPGHOME/plain.gpg"; then bst_notok gnupg/cipher-differs "ciphertext is byte-identical to the plaintext" else bst_ok gnupg/cipher-differs; fi check_out gnupg/cipher-is-pgp "pubkey enc packet" $GPG --list-packets "$GNUPGHOME/plain.gpg" check gnupg/decrypt $GPG --output "$GNUPGHOME/round.bin" --decrypt "$GNUPGHOME/plain.gpg" if cmp -s "$PLAIN" "$GNUPGHOME/round.bin"; then bst_ok gnupg/decrypt-byte-identical else bst_notok gnupg/decrypt-byte-identical "decrypted bytes differ from the original: $(_bst_1line "$(cmp "$PLAIN" "$GNUPGHOME/round.bin" 2>&1)")"; fi check_eq gnupg/decrypt-size "$(wc -c < "$PLAIN" | tr -d ' ')" "$(wc -c < "$GNUPGHOME/round.bin" 2>/dev/null | tr -d ' ')" # sign+encrypt in one pass is the combination real use takes check gnupg/sign-encrypt $GPG --local-user "$FPR" --recipient "$FPR" --trust-model always \ --sign --encrypt --output "$GNUPGHOME/both.gpg" "$PLAIN" both=$($GPG --output "$GNUPGHOME/both.bin" --decrypt "$GNUPGHOME/both.gpg" 2>&1); brc=$? if [ "$brc" -eq 0 ]; then bst_ok gnupg/sign-encrypt-decrypt else bst_notok gnupg/sign-encrypt-decrypt "rc=$brc $(_bst_1line "$both")"; fi case "$both" in *"Good signature"*) bst_ok gnupg/sign-encrypt-sigcheck ;; *) bst_notok gnupg/sign-encrypt-sigcheck "no 'Good signature' on the decrypt: $(_bst_1line "$both")" ;; esac if cmp -s "$PLAIN" "$GNUPGHOME/both.bin"; then bst_ok gnupg/sign-encrypt-identical else bst_notok gnupg/sign-encrypt-identical "sign+encrypt round-trip is not byte-identical"; fi # ── ASCII-armor round-trip ─────────────────────────────────────────────────── check gnupg/armor-encrypt $GPG --armor --recipient "$FPR" --trust-model always \ --encrypt --output "$GNUPGHOME/plain.gpg.asc" "$PLAIN" check_out gnupg/armor-header "BEGIN PGP MESSAGE" cat "$GNUPGHOME/plain.gpg.asc" check gnupg/armor-decrypt $GPG --output "$GNUPGHOME/armor.bin" --decrypt "$GNUPGHOME/plain.gpg.asc" if cmp -s "$PLAIN" "$GNUPGHOME/armor.bin"; then bst_ok gnupg/armor-identical else bst_notok gnupg/armor-identical "armored round-trip is not byte-identical"; fi # ── symmetric (passphrase) encryption, the no-key path ─────────────────────── # loopback pinentry is what makes this reachable on an image with no pinentry. check gnupg/symmetric-encrypt $GPG --pinentry-mode loopback --passphrase brintos-selftest \ --symmetric --cipher-algo AES256 --output "$GNUPGHOME/sym.gpg" "$PLAIN" check gnupg/symmetric-decrypt $GPG --pinentry-mode loopback --passphrase brintos-selftest \ --output "$GNUPGHOME/sym.bin" --decrypt "$GNUPGHOME/sym.gpg" if cmp -s "$PLAIN" "$GNUPGHOME/sym.bin"; then bst_ok gnupg/symmetric-identical else bst_notok gnupg/symmetric-identical "symmetric round-trip is not byte-identical"; fi # the wrong passphrase must be refused wrc=0; $GPG --pinentry-mode loopback --passphrase wrong-one \ --output "$GNUPGHOME/sym.bad" --decrypt "$GNUPGHOME/sym.gpg" >/dev/null 2>&1 || wrc=$? if [ "$wrc" -ne 0 ]; then bst_ok gnupg/symmetric-wrong-passphrase else bst_notok gnupg/symmetric-wrong-passphrase "gpg decrypted with the wrong passphrase"; fi # ── key export / import round-trip through a second home ───────────────────── check gnupg/export-armored $GPG --armor --export --output "$GNUPGHOME/pub.asc" "$FPR" check_out gnupg/export-header "BEGIN PGP PUBLIC KEY BLOCK" cat "$GNUPGHOME/pub.asc" H2=$TMP/bst-gnupg2.$$ mkdir -p "$H2" && chmod 700 "$H2" check_out gnupg/import "imported: 1" gpg --batch --yes --no-tty --homedir "$H2" --import "$GNUPGHOME/pub.asc" check_out gnupg/import-listed "$UID_EMAIL" gpg --batch --yes --no-tty --homedir "$H2" --list-keys # the imported public key alone verifies the signature made by the secret half check_out gnupg/import-verify "Good signature" \ gpg --batch --yes --no-tty --homedir "$H2" --verify "$GNUPGHOME/plain.sig" "$PLAIN" # ── digests, via gpg's own interface ───────────────────────────────────────── # gpg --print-md is the one place the libgcrypt digests are reachable from a # shell on this image, and the answers are published constants, not observations. check_out gnupg/md-sha256-empty \ "E3B0C442 98FC1C14 9AFBF4C8 996FB924 27AE41E4 649B934C A495991B 7852B855" \ sh -c 'printf "" | gpg --print-md SHA256' # Whitespace-stripped, unlike the two above, because gpg GROUPS this one # differently: print_hex switches on digest length, so the 20-byte SHA1 comes # back in fours ("A999 3E36 ...") where the 32- and 64-byte digests come back in # eights. check_out is a substring match, so a constant written in the RFC's # grouping is red forever against a perfectly correct digest. Deleting the # spaces asserts the VALUE and lets gpg format it however it likes. check_out gnupg/md-sha1-abc "A9993E364706816ABA3E25717850C26C9CD0D89D" \ sh -c 'printf abc | gpg --print-md SHA1 2>/dev/null | tr -d " \n"' check_out gnupg/md-sha512-abc "DDAF35A1 93617ABA" sh -c 'printf abc | gpg --print-md SHA512' # ── gpgconf / the component view ───────────────────────────────────────────── check_out gnupg/gpgconf-components "gpg-agent" gpgconf --list-components check gnupg/gpgconf-dirs gpgconf --list-dirs # ── gpgtar, the archive front end ──────────────────────────────────────────── # The one component that drives a whole DIRECTORY through the encrypt path, so it # covers the tar writer as well as the crypto. Skipped rather than failed if the # gnupg package split ever stops shipping it. if command -v gpgtar >/dev/null 2>&1; then mkdir -p "$GNUPGHOME/tardir" && cp "$PLAIN" "$GNUPGHOME/tardir/payload.bin" gtout=$(gpgtar --gpg-args "--batch --yes --no-tty --homedir $GNUPGHOME --trust-model always" \ --encrypt --recipient "$FPR" --output "$GNUPGHOME/dir.tar.gpg" "$GNUPGHOME/tardir" 2>&1); gtrc=$? if [ "$gtrc" -eq 0 ] && [ -s "$GNUPGHOME/dir.tar.gpg" ]; then bst_ok gnupg/gpgtar-encrypt else bst_notok gnupg/gpgtar-encrypt "rc=$gtrc $(_bst_1line "$gtout")"; fi mkdir -p "$GNUPGHOME/untar" utout=$(gpgtar --gpg-args "--batch --yes --no-tty --homedir $GNUPGHOME" \ --decrypt --directory "$GNUPGHOME/untar" "$GNUPGHOME/dir.tar.gpg" 2>&1); utrc=$? if [ "$utrc" -eq 0 ]; then bst_ok gnupg/gpgtar-extract else bst_notok gnupg/gpgtar-extract "rc=$utrc $(_bst_1line "$utout")"; fi ex=$(find "$GNUPGHOME/untar" -name payload.bin 2>/dev/null | head -n 1) if [ -n "$ex" ] && cmp -s "$PLAIN" "$ex"; then bst_ok gnupg/gpgtar-identical else bst_notok gnupg/gpgtar-identical "extracted payload missing or not byte-identical"; fi else bst_skip gnupg/gpgtar-encrypt "gpgtar not installed by the gnupg packages" fi # ── tear down the agent this suite started ─────────────────────────────────── # A gpg-agent left parked on a socket in a deleted GNUPGHOME outlives the suite # and costs the guest a process for the rest of the sweep. gpgconf --homedir "$GNUPGHOME" --kill all >/dev/null 2>&1 gpgconf --homedir "$H2" --kill all >/dev/null 2>&1 rm -rf "$GNUPGHOME" "$H2"