. "$BST_LIB" # BST-TIMEOUT: 900 # openssh 9.6p1 (poky recipes-connectivity/openssh) -- the OpenSSH client and # server suite, ported alongside the dropbear this image already carried rather # than in place of it. Both stay; update-alternatives resolves /usr/bin/ssh and # /usr/bin/scp to openssh on priority, and dbclient/dropbear keep their own names. # # WHAT IS ASSERTED WITHOUT A PEER, AND WHY THAT IS MOST OF IT. This machine has # lo and no route off it, and no operator at a keyboard -- so the suite is built # around the parts of OpenSSH that are answerable offline, which turns out to be # nearly all of the interesting surface: # # * Key handling is pure computation over a file. A fingerprint is a # format-defined function of the public-key blob (base64 of SHA256 over its # wire encoding), so the vectors below are the same string on every # conforming ssh-keygen on earth. There is no approximately-right answer # available to a broken build, and no target-side reference file is needed. # * SSHSIG sign/verify is a complete cryptographic round trip with no network # in it at all, and it is checked BOTH ways -- a fixed off-target signature # this image must accept, and a tampered payload it must reject. # * `sftp -D` runs sftp-server on a pipe instead of dialling a host, so the # whole SFTP protocol -- realpath, open/write/read/close, stat, rename, # remove -- runs for real against this filesystem with no peer of any kind. # * ssh-agent is a forked daemon speaking over AF_UNIX, both of which work # here (brintos/linux#14's working half, and the socket family sysklogd and # log4c already drive). # # The one thing that does need a server is a real ssh session, so the suite # stands one up on loopback (127.0.0.1, a high port) and drives scp and sftp # through it. That is the ticket's named pass criterion and it is asserted # rather than assumed; where it fails, the failure is attributed at the case. require ssh-keygen openssh/present D=${TMPDIR:-/tmp}/bst-openssh.$$ rm -rf "$D"; mkdir -p "$D" GREET="tclsh $BST_DATA/openssh-greeting.tcl" # has -- substring assertion over a value already read. has() { case "$3" in *"$2"*) bst_ok "$1" ;; *) bst_notok "$1" "want:'$2' got:'$(_bst_1line "$3")'" ;; esac } # gap -- the red for a case this port has already traced to # a platform gap, named at the case rather than left for a reader to re-derive. # An unrecognised failure falls through to a plain red on purpose: that is what a # regression in openssh itself should look like, and it must not be absorbed by # an attribution written for a different cause. gap() { case "$2" in *"undumpable"*"not implemented"*) bst_notok "$1" "brintos/linux#29 (prctl PR_SET_DUMPABLE ENOSYS): sftp-server fatals at startup -- $(_bst_1line "$2")" ;; *"getrlimit"*"not implemented"*) bst_notok "$1" "brintos/linux#20 (getrlimit ENOSYS): ssh-agent fatals at startup -- $(_bst_1line "$2")" ;; *"chroot"*"not implemented"*) bst_notok "$1" "brintos/linux#58 (chroot ENOSYS): sshd privsep preauth child fatals -- $(_bst_1line "$2")" ;; *) bst_notok "$1" "$(_bst_1line "$2")" ;; esac } # --- identity and inventory --------------------------------------------------- # ssh -V writes to stderr and exits 0. check_out openssh/version "OpenSSH_9.6" sh -c "ssh -V 2>&1" inv="" for f in /usr/bin/ssh /usr/bin/scp /usr/bin/sftp /usr/bin/ssh-keygen \ /usr/bin/ssh-agent /usr/bin/ssh-add /usr/bin/ssh-keyscan \ /usr/sbin/sshd /usr/libexec/sftp-server; do [ -e "$f" ] || inv="$inv $f" done check_eq openssh/inventory "" "$inv" # update-alternatives: openssh registers ssh and scp at priority 90, dropbear at # 20, so the bare names must resolve to openssh's binaries. Read it off the # BEHAVIOUR (which implementation answers) rather than off the symlink, because # that is what a user typing `ssh` actually gets. check_out openssh/alternative-ssh "OpenSSH_9.6" sh -c "ssh -V 2>&1" check_out openssh/alternative-scp "usage: scp" sh -c "scp 2>&1; :" # ...and dropbear is still here under its own names. The ticket keeps both, so # this is the assertion that the RCONFLICTS removal added openssh rather than # quietly displacing what was already shipping. check_out openssh/dropbear-coexists "Dropbear" sh -c "dbclient --help 2>&1; :" # Nothing in these binaries is an unresolved-symbol trapping stub (BRINTOS-197). check_eq openssh/no-trap-stubs "" \ "$(strings /usr/sbin/sshd /usr/bin/ssh /usr/bin/ssh-keygen 2>/dev/null | grep signature_mismatch)" # --- deterministic vectors ---------------------------------------------------- check_out openssh/fp-sha256 \ "SHA256:olyOQ9OZlaTqPR487qhDbpyi7Rsmw79cG5dN2Cf3TGM" \ ssh-keygen -l -f "$BST_DATA/openssh-vector.pub" check_out openssh/fp-md5 \ "MD5:30:23:80:ca:cf:b1:b5:ef:51:57:b5:9a:fc:07:20:6e" \ ssh-keygen -l -E md5 -f "$BST_DATA/openssh-vector.pub" check_out openssh/fp-bubblebabble \ "xesek-silan-dalic-lynak-gehuh-vulis-kodog-zycug-conyr-nocab-raxyx" \ ssh-keygen -B -f "$BST_DATA/openssh-vector.pub" # ed25519 VERIFICATION against a signature made off-target: the curve arithmetic # and the SSHSIG framing, with no private key shipped in the image. check_out openssh/sig-verify-vector "Good \"brintos-selftest\" signature" \ sh -c "ssh-keygen -Y verify -f '$BST_DATA/openssh-allowed-signers' \ -I brintos-selftest-vector -n brintos-selftest \ -s '$BST_DATA/openssh-vector.sig' < '$BST_DATA/openssh-vector.txt' 2>&1" # --- keygen: the ticket's headline check -------------------------------------- check_out openssh/keygen-ed25519 "The key fingerprint is:" \ ssh-keygen -t ed25519 -N "" -C bst-ed -f "$D/id_ed25519" check_out openssh/keygen-ecdsa "The key fingerprint is:" \ ssh-keygen -t ecdsa -b 256 -N "" -C bst-ec -f "$D/id_ecdsa" # RSA is the slow one -- a 2048-bit keypair is a probabilistic prime search, and # this is the case that measures it on this arm. 2048 rather than the 3072 # default deliberately: the default costs minutes here and buys nothing the # smaller modulus does not already prove about the code path. check_out openssh/keygen-rsa "The key fingerprint is:" \ ssh-keygen -t rsa -b 2048 -N "" -C bst-rsa -f "$D/id_rsa" # A private key must land 0600. It does not here, and the cause is not openssh: # umask(2) is unwired and glibc hands the raw -ENOSYS back AS the mask # (brintos/linux#18), so every file openssh creates keeps the 0644 the open(2) # mode argument asked for. openssh then applies its own permission check and # refuses to read the key it just wrote -- so `ssh-keygen` on this image produces # a keypair no openssh tool will load. That is a genuine user-visible defect and # it is asserted here in its own right, rather than being hidden by the chmod # every case below applies. m=$(stat -c '%a' "$D/id_ed25519" 2>/dev/null) if [ "$m" = "600" ]; then bst_ok openssh/keygen-mode-0600 else bst_notok openssh/keygen-mode-0600 \ "brintos/linux#18 (umask ENOSYS): private key created mode ${m:-?}, openssh refuses its own key; cases below chmod 600 to reach the crypto"; fi # BRINTOS-WORKAROUND: brintos/linux#18 revisit -- with the mode gap asserted # above, every private key this suite generates is chmod'ed explicitly so the # cases that follow measure the crypto and the protocol rather than re-measuring # one umask bug. Drop the chmods when #18 closes. for k in "$D/id_ed25519" "$D/id_ecdsa" "$D/id_rsa"; do chmod 600 "$k" 2>/dev/null; done # -y re-derives the public key from the private half; it must reproduce the # stored .pub byte-for-byte (type + blob; the comment is not part of -y output). check_eq openssh/keygen-pubread \ "$(cut -d' ' -f1,2 "$D/id_ed25519.pub" 2>/dev/null)" \ "$(ssh-keygen -y -f "$D/id_ed25519" 2>&1 | cut -d' ' -f1,2)" # ...and the fingerprint is the same whether read from the private or public half check_eq openssh/keygen-fp-stable \ "$(ssh-keygen -l -f "$D/id_ed25519.pub" 2>&1 | cut -d' ' -f2)" \ "$(ssh-keygen -l -f "$D/id_ed25519" 2>&1 | cut -d' ' -f2)" # passphrase change exercises the bcrypt_pbkdf KDF and the encrypted private-key # format; the public half must survive it unchanged. ssh-keygen -q -t ed25519 -N "oldpass" -C bst-pw -f "$D/id_pw" 2>/dev/null chmod 600 "$D/id_pw" 2>/dev/null check openssh/keygen-passwd-change ssh-keygen -q -p -P oldpass -N newpass -f "$D/id_pw" check_eq openssh/keygen-passwd-pubstable \ "$(cut -d' ' -f1,2 "$D/id_pw.pub" 2>/dev/null)" \ "$(ssh-keygen -y -P newpass -f "$D/id_pw" 2>&1 | cut -d' ' -f1,2)" # the OLD passphrase must now be refused -- without this the case above would # pass against a build that silently ignored -N. check_rc openssh/keygen-passwd-old-refused 255 \ sh -c "ssh-keygen -y -P oldpass -f '$D/id_pw' "$D/msg" check openssh/sign-roundtrip sh -c "ssh-keygen -Y sign -q -f '$D/id_ed25519' -n bst '$D/msg'" printf 'bst-signer namespaces="bst" %s\n' "$(cut -d' ' -f1,2 "$D/id_ed25519.pub")" > "$D/allowed" check_out openssh/sign-verify "Good \"bst\" signature" \ sh -c "ssh-keygen -Y verify -f '$D/allowed' -I bst-signer -n bst \ -s '$D/msg.sig' < '$D/msg' 2>&1" # a tampered payload must FAIL: without it the case above would pass against a # verifier that returned success unconditionally. printf 'tampered payload\n' > "$D/msg2" check_rc openssh/sign-rejects-tamper 255 \ sh -c "ssh-keygen -Y verify -f '$D/allowed' -I bst-signer -n bst \ -s '$D/msg.sig' < '$D/msg2'" # --- certificates ------------------------------------------------------------- ssh-keygen -q -t ed25519 -N "" -C bst-ca -f "$D/ca" 2>/dev/null chmod 600 "$D/ca" 2>/dev/null check openssh/cert-sign \ ssh-keygen -q -s "$D/ca" -I bst-cert-id -n bstuser -V -5m:+60m "$D/id_ed25519.pub" check_out openssh/cert-inspect "bst-cert-id" ssh-keygen -L -f "$D/id_ed25519-cert.pub" check_out openssh/cert-principal "bstuser" ssh-keygen -L -f "$D/id_ed25519-cert.pub" # --- known_hosts -------------------------------------------------------------- printf 'bsthost,10.0.0.1 %s\n' "$(cut -d' ' -f1,2 "$D/id_ed25519.pub")" > "$D/known" check_out openssh/knownhosts-find "bsthost" ssh-keygen -F bsthost -f "$D/known" check openssh/knownhosts-hash ssh-keygen -q -H -f "$D/known" check_out openssh/knownhosts-hashed "|1|" cat "$D/known" # a hashed entry is still findable -- proves the HMAC lookup, not just the write check_out openssh/knownhosts-find-hashed "bsthost" ssh-keygen -F bsthost -f "$D/known" check openssh/knownhosts-remove ssh-keygen -q -R bsthost -f "$D/known" # --- client-side config and algorithm surface --------------------------------- check_out openssh/config-dump "hostname localhost" sh -c "ssh -G localhost 2>&1" check_out openssh/query-cipher "chacha20-poly1305@openssh.com" ssh -Q cipher check_out openssh/query-kex "curve25519-sha256" ssh -Q kex check_out openssh/query-key "ssh-ed25519" ssh -Q key # --- sftp against a directly-executed server: no sshd, no network ------------- # 256 distinct byte values, so the transfer is asserted binary-transparent # rather than merely text-carrying. awk 'BEGIN{for(i=0;i<256;i++) printf "%c", i}' > "$D/src.bin" mkdir -p "$D/remote" cat > "$D/batch" <&1 | head -3) out=$(sftp -b "$D/batch" -D /usr/libexec/sftp-server &1); rc=$? if [ "$rc" -eq 0 ]; then bst_ok openssh/sftp-direct-rc else gap openssh/sftp-direct-rc "rc=$rc $srv $out"; fi # the uploaded copy must EXIST before anything below claims to move or delete it if [ -s "$D/remote/up.bin" ]; then bst_ok openssh/sftp-direct-put else gap openssh/sftp-direct-put "nothing uploaded: $srv $out"; fi if cmp -s "$D/src.bin" "$D/down.bin"; then bst_ok openssh/sftp-direct-roundtrip else gap openssh/sftp-direct-roundtrip "get differs from source: $srv $out"; fi # rename+rm are the filesystem half of the protocol, and they run as their OWN # session against the file the session above left behind. Folding them into that # batch made the assertion vacuous -- "no leftovers" is equally true of a # transfer that never happened, and it passed on a host with no sftp-server at # all. Split like this, `up.bin gone AND moved.bin gone` can only hold if the # rename found its source and the rm found the renamed file. cat > "$D/batch2" <&1); rc2=$? if [ "$rc2" -eq 0 ]; then bst_ok openssh/sftp-direct-rename-rm-rc else gap openssh/sftp-direct-rename-rm-rc "rc=$rc2 $srv $out2"; fi if [ ! -e "$D/remote/up.bin" ] && [ ! -e "$D/remote/moved.bin" ]; then bst_ok openssh/sftp-direct-rename-rm else gap openssh/sftp-direct-rename-rm "leftover under $D/remote: $(ls "$D/remote") $srv $out2"; fi # --- ssh-agent over AF_UNIX --------------------------------------------------- AS="$D/agent.sock" ssh-agent -a "$AS" >"$D/agent.env" 2>&1; arc=$? aout=$(cat "$D/agent.env" 2>/dev/null) if [ "$arc" -eq 0 ] && [ -S "$AS" ]; then bst_ok openssh/agent-start-rc else gap openssh/agent-start-rc "rc=$arc $aout"; fi if [ -S "$AS" ]; then bst_ok openssh/agent-socket APID=$(sed -n 's/.*SSH_AGENT_PID=\([0-9]*\).*/\1/p' "$D/agent.env" | head -1) check_out openssh/agent-add "Identity added" \ sh -c "SSH_AUTH_SOCK='$AS' ssh-add '$D/id_ed25519' 2>&1" # the agent must report back the key it was handed, by fingerprint check_out openssh/agent-list \ "$(ssh-keygen -l -f "$D/id_ed25519.pub" 2>/dev/null | cut -d' ' -f2)" \ sh -c "SSH_AUTH_SOCK='$AS' ssh-add -l 2>&1" check openssh/agent-delete sh -c "SSH_AUTH_SOCK='$AS' ssh-add -D 2>&1" check_out openssh/agent-empty "no identities" \ sh -c "SSH_AUTH_SOCK='$AS' ssh-add -l 2>&1; :" [ -n "$APID" ] && kill "$APID" 2>/dev/null else gap openssh/agent-socket "no socket at $AS: $aout" for c in agent-add agent-list agent-delete agent-empty; do gap "openssh/$c" "ssh-agent did not create its socket: $aout" done fi # --- sshd on loopback: the ticket's named pass criterion ---------------------- PORT=22022 ssh-keygen -q -t ed25519 -N "" -f "$D/hostkey" 2>/dev/null chmod 600 "$D/hostkey" 2>/dev/null mkdir -p "$D/home/.ssh" cat "$D/id_ed25519.pub" > "$D/home/.ssh/authorized_keys" chmod 700 "$D/home/.ssh"; chmod 600 "$D/home/.ssh/authorized_keys" # sshd refuses to start unless its privilege-separation directory is uid 0 and # neither group- nor world-writable. Nothing under the virtiofs root can satisfy # that: every path there reads back uid 1000 (the host build user) and chown(2) # returns 0 while changing nothing. tmpfs entries ARE uid 0, so the directory is # made there and linked into the compiled-in path. The image's own # /etc/default/volatiles/99_sshd would create /var/run/sshd at boot, but nothing # replays volatiles on this image, so it is absent either way. # BRINTOS-WORKAROUND: brintos/linux#59 remove -- when the root filesystem # presents uid 0, `mkdir -p /var/run/sshd && chmod 755` is all this needs. PSP=${TMPDIR:-/tmp}/.bst-sshd-psp rm -rf "$PSP" /var/run/sshd mkdir -p "$PSP"; chmod 755 "$PSP" ln -s "$PSP" /var/run/sshd 2>/dev/null # No UsePAM here: this recipe builds openssh without PAM, so the option is not # merely inert but a parse error that stops sshd before it binds. cat > "$D/sshd_config" <&1" # -D foreground, -e log to stderr, so this shell owns it and can signal it. -r # suppresses the per-connection re-exec: sshd normally re-execs its own binary # for each accepted connection, and at 40 MB that child dies before it can write # its version string (the large-binary exec ceiling, brintos/linux#44 / #15), so # without -r the connection fails one step earlier than the gap this suite is # trying to reach and reports the wrong cause. /usr/sbin/sshd -D -e -r -f "$D/sshd_config" >"$D/sshd.log" 2>&1 & SPID=$! # Bounded poll on the port actually greeting -- sleep(1) does not sleep on this # image (~0.29 s, linux-in-the-browser#120), so this is a real-condition poll # and never a fixed delay. i=0; banner="" while [ $i -lt 40 ]; do banner=$($GREET $PORT 2000 2>&1) case "$banner" in *"banner=SSH-"*) break ;; esac i=$((i+1)); sleep 1 done has openssh/sshd-banner "banner=SSH-2.0-OpenSSH_9.6" "$banner" case "$banner" in *"banner=SSH-"*) # A greeting means sshd bound, accepted and forked far enough to write its # version string. Everything below needs the connection to survive PAST that # -- key exchange, then the privilege-separation handover. SSHOPTS="-o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o LogLevel=ERROR" # The client only ever sees "Connection closed by remote host" -- whatever # killed the connection did so in sshd's forked child, and says why on the # server's stderr. The whole log is searched for the diagnostic rather than # its last few lines: the child writes after the client has already given up, # so a tail taken at the moment of failure routinely misses the one line that # names the cause and the red then reads as unexplained. One bounded retry # covers the first exchange, where nothing has been written yet. srvlog() { _s=$(grep -m1 -e 'chroot' -e 'not implemented' -e 'fatal' "$D/sshd.log" 2>/dev/null) if [ -z "$_s" ]; then sleep 1 _s=$(grep -m1 -e 'chroot' -e 'not implemented' -e 'fatal' "$D/sshd.log" 2>/dev/null) fi [ -n "$_s" ] || _s=$(tail -4 "$D/sshd.log" 2>/dev/null) _bst_1line "$_s" } out=$(ssh $SSHOPTS -p $PORT -i "$D/id_ed25519" 127.0.0.1 \ "echo BST-SSH-OK" &1); rc=$? if [ "$rc" -eq 0 ]; then case "$out" in *BST-SSH-OK*) bst_ok openssh/sshd-session ;; *) bst_notok openssh/sshd-session "rc=0 but no marker: $(_bst_1line "$out")" ;; esac else gap openssh/sshd-session "rc=$rc $(_bst_1line "$out") -- sshd: $(srvlog)" fi printf 'scp-payload\n' > "$D/scp-src" o=$(scp $SSHOPTS -P $PORT -i "$D/id_ed25519" "$D/scp-src" 127.0.0.1:"$D/scp-dst" &1); rc=$? if [ "$rc" -eq 0 ]; then bst_ok openssh/scp-to-target else gap openssh/scp-to-target "rc=$rc $o -- sshd: $(srvlog)"; fi o=$(scp $SSHOPTS -P $PORT -i "$D/id_ed25519" 127.0.0.1:"$D/scp-dst" "$D/scp-back" &1); rc=$? if [ "$rc" -eq 0 ]; then bst_ok openssh/scp-from-target else gap openssh/scp-from-target "rc=$rc $o -- sshd: $(srvlog)"; fi if cmp -s "$D/scp-src" "$D/scp-back"; then bst_ok openssh/scp-roundtrip else gap openssh/scp-roundtrip "round trip differs or never completed -- sshd: $(srvlog)"; fi cat > "$D/nbatch" <&1); rc=$? if [ "$rc" -eq 0 ]; then bst_ok openssh/sftp-over-sshd else gap openssh/sftp-over-sshd "rc=$rc $o -- sshd: $(srvlog)"; fi if cmp -s "$D/src.bin" "$D/net-down.bin"; then bst_ok openssh/sftp-over-sshd-roundtrip else gap openssh/sftp-over-sshd-roundtrip "round trip differs or never completed -- sshd: $(srvlog)"; fi ;; *) why="sshd never greeted on $PORT: $(_bst_1line "$(tail -4 "$D/sshd.log" 2>/dev/null)")" for c in sshd-session scp-to-target scp-from-target scp-roundtrip \ sftp-over-sshd sftp-over-sshd-roundtrip; do gap "openssh/$c" "$why" done ;; esac kill -TERM "$SPID" 2>/dev/null rm -rf "$D" "$PSP" /var/run/sshd