. "$BST_LIB" # BST-TIMEOUT: 3000 # The ceiling is high because the payload is: data/libssh-probe is a 36 MB wasm # module (openssl-probe next door is 30 MB) and this suite loads it THREE times # -- offline, session, hostkey-mismatch -- on top of dropbear, dbclient, three # key generations and an openssh ssh-keygen. Measured at 1800 the suite scored # every case and was THEN failed for the wall clock, which reads as a hung # library and is not: the run had finished, a little over the ceiling. # # AND IT IS DELIBERATELY NOT HIGHER THAN THAT. 3600 was tried and is worse than # too low: brintos_local_verify's own --runner-timeout defaults to 3600 for the # WHOLE boot, so a suite allowed to run that long outlives the harness driving # it, the console is truncated mid-run, and the result is not a timeout red but # `pass=0 fail=0 total=0` -- a suite that reads as never written rather than as # failed, which is the hardest shape to notice in a report. # # 3000 is what is left once both ends are respected. Measured: the suite scored # all 36 cases and overran a 2700 ceiling, so it needs ~45 min; boot costs ~2.5 # min; 150 + 3000 = 3150 fits the 3600 harness budget with room, while a genuine # overrun still lands as a visible red instead of vanishing. The per-call # `timeout`s below, not this number, are what bound an actually-wedged exchange. # # IF THIS NEEDS RAISING AGAIN, SHRINK THE SUITE INSTEAD. The cost is dominated by # loading a 36 MB wasm module three times; stripping its ~11.7 MB of DWARF, or # giving the probe one mode that performs all three passes in a single process, # both buy more than another ceiling bump and neither has been done here. # libssh 0.10.6 (meta-oe recipes-support/libssh) -- the SSHv2 library that # implements BOTH sides of the protocol. It lands beside the libssh2 this image # already carried (BRINTOS-559); they are different projects with different APIs, # and libssh2 is client-only, so neither replaces the other. # # THE PACKAGE SHIPS NO EXECUTABLE. libssh is libssh.a, eight public headers and a # .pc file -- there is no command-line tool anywhere in it. So unlike openssh or # dropbear next door, nothing on this image can drive the library from a shell, # and data/libssh-probe -- cross-linked against the very /usr/lib/libssh.a that # ships -- is the only thing that can show libssh RUNS rather than merely # INSTALLS. Same call nettle, libsodium and libgcrypt made on this arm. # # THE SERVER IS DROPBEAR, NOT OPENSSH. openssh's sshd is not a candidate: it # cannot serve ANY connection here, because chroot(2) is unwired and its # privilege-separation preauth child fatal()s before authentication # (brintos/linux#58, and the permanent reds in 88-openssh.test that own it). # dropbear does no chroot, which is why it is the server this suite drives. # # BUT NEITHER HALF OF THIS IMAGE'S SSH SURFACE CAN AUTHENTICATE, AND THAT IS # MEASURED RATHER THAN ASSUMED. An earlier draft of this file asserted dropbear # "is the half that can actually answer". It is not. Its per-connection child # calls seteuid() to become the target user and dies before authentication: # # Exit before auth from <127.0.0.1:53666>: (user 'root', 0 fails): # Failed to set euid # # because the whole credential-setter family is unwired on this arm # (brintos/linux#19). THE CONTROL THAT SETTLES THE ATTRIBUTION is dropbear's own # dbclient, run against this same server, in the same boot, with a dropbearkey # key: it fails too ("Remote closed the connection"). A second, independent # client failing against the same server is what makes this the server's defect # and not libssh's -- so the session cases below are attributed reds against # linux#19, exactly as 88-openssh.test's are against #58, and NOT evidence about # the port. # # THE TWO CLIENTS DO NOT DIE AT THE SAME INSTRUCTION, and the difference is why # the control has to be a real client. dropbear reaches its seteuid() only when a # client OFFERS a public key, because that is when it tries to read # ~/.ssh/authorized_keys as the user -- so dbclient, which offers one, is what # provokes the "Failed to set euid" line. libssh leaves earlier: it reports # auth-methods=0, i.e. the server never advertised a method for it to use, and # dropbear logs that connection as a plain "Exited normally". What is established # is the server-level fact -- this dropbear authenticates nobody -- not a shared # instruction. Do not read the euid line as libssh's own stack trace. # # Everything up to authentication is still real evidence and is still scored: # the probe negotiates curve25519-sha256 with chacha20-poly1305, reads the # server's ed25519 host key, matches it against known_hosts, and imports the # client key -- all of which is libssh doing its job against a live peer. # # WHY EVERY STAGE IS ITS OWN CASE. The probe prints connect / banner / hostkey / # auth / channel / exec as separate facts, and this file scores each one. On a # platform this young the useful question is not "did the session work" but "how # far did it get" -- a red at `auth` and a red at `connect` have entirely # different owners, and a single rolled-up case would hide which one happened. D=${TMPDIR:-/tmp}/bst-libssh.$$ rm -rf "$D"; mkdir -p "$D" PORT=22022 # The payload ships 0644 into a rootfs that is read-only at browser runtime, so # it is copied to a writable path and made executable THERE -- never run in # place, and never asserted with `test -x` on the shipped file, which would pass # only for a hand-placed copy and fail on every image that really built it. PROBE=$D/libssh-probe check libssh/probe-shipped test -f "$BST_DATA/libssh-probe" if cp "$BST_DATA/libssh-probe" "$PROBE" 2>/dev/null && chmod +x "$PROBE" 2>/dev/null; then PROBE_OK=1 else PROBE_OK=0 bst_notok libssh/probe-stage "cannot stage $BST_DATA/libssh-probe" fi # 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 } # fact -- pull one `key=value` line out of a probe transcript. fact() { printf '%s\n' "$2" | sed -n "s/^$1=//p" | head -n 1 } # pf -- score a probe fact that must read exactly `ok`. pf() { _v=$(fact "$2" "$3") if [ "$_v" = "ok" ]; then bst_ok "$1" elif [ -z "$_v" ]; then bst_notok "$1" "probe printed no '$2' fact -- $(_bst_1line "$3")" else bst_notok "$1" "$2=$_v"; fi } # --- packaging: what the port actually installed -------------------------------- check libssh/archive test -f /usr/lib/libssh.a check libssh/header test -f /usr/include/libssh/libssh.h check libssh/header-server test -f /usr/include/libssh/server.h check libssh/header-sftp test -f /usr/include/libssh/sftp.h check libssh/pkgconfig test -f /usr/lib/pkgconfig/libssh.pc # Shape, not absence: this arm converts to shared libraries per recipe # (BRINTOS-1740), so a .so here is a conversion, not a policy breach. bst_lib_shape libssh/no-shared-object "/usr/lib/libssh.so" "/usr/lib/libssh.so.4" # --- the crypto backend, read off the shipped archive --------------------------- # THE BACKEND IS NOT A COSMETIC CHOICE HERE, so it is asserted from the artifact # rather than trusted from the recipe. A libgcrypt-backed libssh would trap in # __wasm32_main_with_ctors before main on this arm (hwjs-cc#26, the defect that # owns mpicalc in 79-libgcrypt.test), so every case below it would be red for a # reason that has nothing to do with libssh. The bbappend selects OpenSSL; these # two cases are what make that checkable on target. # Read with grep rather than `ar t`: this image ships no ar, and an ar archive # stores its member names as plain ASCII in the per-member headers, so grep -a # answers the same question with a tool that is actually here. The two names # cannot alias each other, and neither is a substring of pki_crypto.c.o. if grep -aq "libcrypto\.c\.o" /usr/lib/libssh.a 2>/dev/null; then bst_ok libssh/backend-openssl else bst_notok libssh/backend-openssl "no libcrypto.c.o member in the shipped archive" fi if grep -aq "libgcrypt\.c\.o" /usr/lib/libssh.a 2>/dev/null; then bst_notok libssh/backend-not-gcrypt "libgcrypt.c.o is in the shipped archive; the OpenSSL backend selection regressed" else bst_ok libssh/backend-not-gcrypt fi # --- the probe, offline: the control for everything that follows ---------------- # Nothing in this block needs a peer. If it is red the library itself is broken # on this arm, and no conclusion about the server half is available from the # session cases below -- which is exactly why it runs first. if [ "$PROBE_OK" -ne 1 ]; then for c in probe-runs version init session-new opt-host opt-port keygen \ key-type pubkey-export fingerprint cipher-ok cipher-refuse; do bst_notok "libssh/offline-$c" "data/libssh-probe could not be staged from this image" done else off=$("$PROBE" offline 2>"$D/off.err"); orc=$? if bst_execfail "$off$(cat "$D/off.err" 2>/dev/null)"; then for c in probe-runs version init session-new opt-host opt-port keygen \ key-type pubkey-export fingerprint cipher-ok cipher-refuse; do bst_unrun "libssh/offline-$c" "$off $(cat "$D/off.err" 2>/dev/null)" done else if [ "$orc" -eq 0 ]; then bst_ok libssh/offline-probe-runs else bst_notok libssh/offline-probe-runs "rc=$orc failures=$(fact probe-failures "$off") stderr:$(_bst_1line "$(cat "$D/off.err" 2>/dev/null)")"; fi has libssh/offline-version "0.10.6" "$(fact libssh-version "$off")" pf libssh/offline-init ssh-init "$off" pf libssh/offline-session-new session-new "$off" pf libssh/offline-opt-host opt-get-host "$off" pf libssh/offline-opt-port opt-get-port "$off" pf libssh/offline-keygen pki-generate-ed25519 "$off" pf libssh/offline-key-type key-type-ed25519 "$off" pf libssh/offline-pubkey-export pubkey-export-base64 "$off" pf libssh/offline-fingerprint fingerprint-format "$off" pf libssh/offline-cipher-ok cipher-opt-accepted "$off" pf libssh/offline-cipher-refuse cipher-opt-refused "$off" fi fi # --- a server on loopback, and a real session against it ------------------------ SESSION_CASES="server-can-auth connect banner hostkey-known server-key auth channel-open exec output-matches-local exit-status" # skip_session -- one attributed red per session case, so a server that # never came up is reported once per assertion it prevented rather than as a # single vague failure. skip_session() { for c in $SESSION_CASES; do bst_notok "libssh/$c" "$1"; done } if [ "$PROBE_OK" -ne 1 ]; then skip_session "data/libssh-probe could not be staged from this image" elif ! command -v dropbear >/dev/null 2>&1 || ! command -v dropbearkey >/dev/null 2>&1; then skip_session "dropbear not installed on this image" elif ! command -v dbclient >/dev/null 2>&1; then # Named separately from dropbear even though both are dropbearmulti symlinks: # dbclient is the control that decides whether a session red belongs to libssh # or to the server, so a build that shipped the server without the client must # say so rather than quietly scoring reds with nothing to attribute them by. skip_session "dbclient not installed; no independent client to control the server's auth path against" elif ! command -v ssh-keygen >/dev/null 2>&1; then skip_session "ssh-keygen (openssh) not installed; no OpenSSH-format client key" elif ! command -v nc >/dev/null 2>&1; then skip_session "nc not installed; no way to probe the server's banner (ssh-keyscan cannot run -- brintos/linux#20)" else # The client key is generated with openssh's ssh-keygen because libssh imports # the OpenSSH private-key format, which dropbearkey does not write. # # NOTE the mode this lands at: umask(2) returns -ENOSYS here (brintos/linux#18), # so ssh-keygen writes an 0644 private key. openssh REFUSES to use such a key -- # that is 88-openssh.test's keygen-mode-0600 red. libssh does not check the # mode of a private-key file at all, so the same key works here. That is a real # behavioural difference between the two libraries on this image, not an # oversight in this test. ssh-keygen -t ed25519 -N "" -C bst-libssh -f "$D/id_ed25519" >/dev/null 2>&1 check libssh/client-key test -s "$D/id_ed25519" USER_NAME=$(id -un 2>/dev/null) || USER_NAME=root # dropbear reads authorized_keys from the home in /etc/passwd, which on this # image is NOT $HOME. # # `id -un` is root and /etc/passwd says root:x:0:0:root:/home/root:/bin/sh -- # but the shell's $HOME is `/`, because nothing in this image's init exports # HOME for pid 1's shell. So `cd ~ && pwd` yields `/`, and an authorized_keys # written there lands in //.ssh, where dropbear never looks: it calls # getpwnam() and uses pw_dir. The result is an authentication that fails with # the key nowhere near the server, which then reads as a libssh defect. It is # not one, and the fix is to ask the same source dropbear asks. HOMEDIR=$(awk -F: -v u="$USER_NAME" '$1 == u { print $6; exit }' /etc/passwd 2>/dev/null) [ -n "$HOMEDIR" ] || HOMEDIR=$(cd ~ 2>/dev/null && pwd) || HOMEDIR=/home/root mkdir -p "$HOMEDIR/.ssh" AK=$HOMEDIR/.ssh/authorized_keys AK_SAVED="" if [ -f "$AK" ]; then AK_SAVED=$D/authorized_keys.saved; cp "$AK" "$AK_SAVED"; fi cat "$D/id_ed25519.pub" > "$AK" 2>/dev/null chmod 600 "$AK" 2>/dev/null chmod 700 "$HOMEDIR/.ssh" 2>/dev/null dropbearkey -t ed25519 -f "$D/hostkey" > "$D/hostkey.out" 2>&1 check libssh/host-key test -s "$D/hostkey" # -F foreground, -E log to stderr, -p bind, -r hostkey. # # NO -s HERE. Upstream dropbear's -s disables password logins, and the # instinct is to pass it so only the pubkey path can succeed -- but THIS # build does not have it: `dropbear --help` on this image (v2022.83) prints # "Invalid option --" and its usage lists no -s. Passing it makes dropbear # exit on its usage message instead of binding, which reads downstream as # "the server never greeted" and looks like a libssh failure. Password auth # being available costs nothing here: the probe only ever offers a key. dropbear -F -E -p 127.0.0.1:$PORT -r "$D/hostkey" > "$D/dropbear.log" 2>&1 & DBPID=$! # Wait for the port to answer with a banner rather than sleeping a fixed time. # # NOT WITH ssh-keyscan, WHICH CANNOT RUN ON THIS IMAGE. openssh's keyscan # calls fdlim_get() at startup, which is getrlimit(RLIMIT_NOFILE), and # getrlimit is ENOSYS here -- brintos/linux#20, the same gap that owns the six # ssh-agent reds in 88-openssh.test. It exits with # # ssh-keyscan: fdlim_get: bad value # # before opening a socket, so using it as the readiness probe reports "the # server never greeted" no matter how healthy the server is. That is a # measurement fault, not a finding, and it would have been attributed to # libssh or to dropbear -- neither of which is at fault. nc reads the same # banner and works here. banner="" i=0 while [ $i -lt 20 ]; do banner=$(echo | timeout 3 nc 127.0.0.1 $PORT 2>/dev/null | head -n 1) case "$banner" in SSH-2.0-*) break ;; *) banner="" ;; esac kill -0 "$DBPID" 2>/dev/null || break i=$((i + 1)) sleep 1 done # srvlog -- the server's own first complaint, for attributing a client-side red. srvlog() { _s=$(grep -m1 -e 'fatal' -e 'not implemented' -e 'Error' "$D/dropbear.log" 2>/dev/null) [ -n "$_s" ] || _s=$(tail -n 3 "$D/dropbear.log" 2>/dev/null) _bst_1line "$_s" } if [ -z "$banner" ]; then bst_notok libssh/server-up "dropbear never greeted on $PORT -- $(srvlog)" skip_session "dropbear never greeted on $PORT -- $(srvlog)" else bst_ok libssh/server-up # known_hosts, seeded from the host key dropbear was STARTED with, read back # out of that same file by dropbearkey -y. ssh-keyscan would have been the # obvious way to ask the server directly, and it is unavailable here for the # getrlimit reason above -- so the key is taken from the source dropbear # itself is serving, which is the same key by construction. # # The bracketed [host]:port form is what both openssh and libssh use off 22. hostpub=$(dropbearkey -y -f "$D/hostkey" 2>/dev/null \ | grep -o 'ssh-ed25519 [A-Za-z0-9+/=]*' | head -n 1) printf '[127.0.0.1]:%s %s\n' "$PORT" "$hostpub" > "$D/known_hosts" check libssh/known-hosts-seeded test -s "$D/known_hosts" # The command is run BOTH ways and the two outputs compared -- which is the # ticket's actual criterion. `uname -sm` is chosen because it is deterministic # on a given machine and cannot accidentally agree: it is the one string the # channel must carry back byte-for-byte. CMD="uname -sm" local_out=$($CMD 2>&1) # --- THE CONTROL, AND IT RUNS FIRST --------------------------------------- # It answers "can this server authenticate ANYONE?" with a client that is not # the one under test: dropbear's own dbclient, same server, same boot, its own # dropbearkey key. Without it, every red below is ambiguous -- a client-side # symptom of a server-side gap is indistinguishable from a client-side bug, # and the honest reading of "auth failed" would be "libssh failed". # # DO NOT REPLACE THIS WITH A grep OF THE SERVER LOG. An earlier revision # inferred the gap by looking for dropbear's "Failed to set euid" line, and # it silently mis-scored: dropbear only reaches its seteuid() -- taken to read # ~/.ssh/authorized_keys as the user -- once a client actually OFFERS a public # key. libssh never gets that far here (it reports auth-methods=0 and leaves), # so the line was absent and the case went GREEN on a server that cannot # authenticate at all. dbclient does offer one, which is precisely why the # control has to be a real client and not a log search. dropbearkey -t ed25519 -f "$D/dbkey" > "$D/dbkey.out" 2>&1 dropbearkey -y -f "$D/dbkey" 2>/dev/null \ | grep -o 'ssh-ed25519 [A-Za-z0-9+/=]*' | head -n 1 >> "$AK" ctl=$(timeout 120 dbclient -y -y -i "$D/dbkey" -p "$PORT" \ "$USER_NAME@127.0.0.1" "$CMD" 2>"$D/ctl.err") if [ "$ctl" = "$local_out" ]; then SERVER_AUTHS=1 bst_ok libssh/server-can-auth else SERVER_AUTHS=0 bst_notok libssh/server-can-auth \ "dropbear cannot authenticate its OWN client here, so no libssh red below is evidence about libssh: dbclient got '$(_bst_1line "$(head -c 200 "$D/ctl.err" 2>/dev/null)")' -- server: $(srvlog) (credential setters unwired, brintos/linux#19)" fi # BOUNDED, because an unauthenticatable server does not fail fast. With no # method ever advertised, ssh_userauth_publickey() sits in libssh's documented # AGAIN loop until dropbear's own 300 s pre-auth timeout fires -- which is # what made an earlier revision report the exhaustion INSTEAD of the cause. # 120 s is many times a working handshake on this arm and caps the damage. ses=$(timeout 120 "$PROBE" session 127.0.0.1 "$PORT" "$USER_NAME" "$D/id_ed25519" \ "$D/known_hosts" "$CMD" 2>"$D/ses.err") printf '%s\n' "$ses" > "$D/ses.out" pf libssh/connect connect "$ses" pf libssh/banner banner-is-ssh2 "$ses" pf libssh/hostkey-known hostkey-known "$ses" pf libssh/server-key server-key-readable "$ses" # att -- the owner of a session-stage red. Every case from auth onward is # unreachable while the server cannot authenticate, so each says so rather # than reporting a bare symptom that reads as a libssh fault. att() { if [ "${SERVER_AUTHS:-1}" -eq 0 ]; then printf 'held by brintos/linux#19 -- this server authenticates no one (dbclient control failed too), so this red is not about libssh' else printf 'dropbear: %s' "$(srvlog)" fi } if [ "$(fact auth-publickey "$ses")" = "ok" ]; then bst_ok libssh/auth else bst_notok libssh/auth "auth-publickey=$(fact auth-publickey "$ses") rc=$(fact auth-rc "$ses") err:$(fact auth-error "$ses") methods=$(fact auth-methods "$ses") -- $(att)" fi pf libssh/channel-open channel-open "$ses" pf libssh/exec channel-exec "$ses" remote_out=$(sed -n '/^remote-output-begin$/,/^remote-output-end$/p' "$D/ses.out" \ | sed '1d;$d') if [ -z "$remote_out" ]; then bst_notok libssh/output-matches-local "the channel carried no output; local was '$local_out' -- $(att)" else check_eq libssh/output-matches-local "$local_out" "$remote_out" fi es=$(fact remote-exit-status "$ses") if [ "$es" = "0" ]; then bst_ok libssh/exit-status else bst_notok libssh/exit-status "remote exit status '$es' (want 0) -- $(att)"; fi # --- the refusal path ------------------------------------------------------- # A known_hosts entry naming this host under a DIFFERENT key must be reported # as CHANGED. The check runs inside ssh_connect(), after the key exchange and # before any authentication, so this case is answerable even where the session # cases above are not -- and it is the more security-relevant half of the two. ssh-keygen -t ed25519 -N "" -C bst-wrong -f "$D/wrong" >/dev/null 2>&1 wrongkey=$(awk '{print $1" "$2}' "$D/wrong.pub" 2>/dev/null) printf '[127.0.0.1]:%s %s\n' "$PORT" "$wrongkey" > "$D/known_hosts.bad" # Bounded like the session above, though this one does answer: the CHANGED # verdict lands inside ssh_connect(), before any authentication, which is # why this case is green while every case after auth is not. mm=$(timeout 60 "$PROBE" hostkey-mismatch 127.0.0.1 "$PORT" "$D/known_hosts.bad" 2>"$D/mm.err") if [ "$(fact connect "$mm")" = "ok" ]; then khv=$(fact known-hosts "$mm") if [ "$khv" = "CHANGED" ]; then bst_ok libssh/hostkey-mismatch-refused else bst_notok libssh/hostkey-mismatch-refused "known-hosts=$khv (want CHANGED)" fi else bst_notok libssh/hostkey-mismatch-refused "the probe could not connect: $(fact connect-error "$mm") -- dropbear: $(srvlog)" fi fi kill "$DBPID" 2>/dev/null wait "$DBPID" 2>/dev/null if [ -n "$AK_SAVED" ]; then cp "$AK_SAVED" "$AK"; else rm -f "$AK"; fi fi rm -rf "$D"