Public Access
ADR 0007 decision 4 refused this a commit ago, on the grounds that widening
access to the machine's TPM is a system-wide decision an application package
should not make for an administrator. That refusal is overturned here, and the
reasoning it is replaced with is narrower rather than louder — because the
refusal had treated "widen access" as one thing when it is two.
Adding a person to `tss` is what the refusal was really about, and it stays the
wrong answer: permanent, effective over SSH, applying whether or not they are at
the keyboard, and needing an administrator and a fresh login before the feature
appears at all.
Tagging /dev/tpmrm0 with `uaccess` is a different thing. It is systemd's own
mechanism for this exact question — the one /dev/dri, webcams, scanners and FIDO
keys already use — and logind grants the ACL to the user of the *active local
session*, then takes it back when that session ends. Not over SSH, not on an
inactive VT, not to daemons. The rule changes neither the mode nor the group, so
everything that worked before still works and the ACL is layered on top.
tpmrm and never tpm, which is what makes it defensible at all: /dev/tpm0 is the
raw device and is exclusive, where /dev/tpmrm0 is the kernel's resource manager,
which virtualises handles and isolates callers precisely so that more than one
unprivileged program can use the chip.
◆ THE COST IS A DENIAL OF SERVICE AND NOT A DISCLOSURE, AND IT IS WRITTEN DOWN.
TPM objects are protected by their own auth values, so this lets nobody read
anybody else's sealed data. What it allows is *failed* attempts — and a TPM
answers enough of those by locking out everyone, which is the same anti-hammering
this project relies on to make a four-digit PIN safe. On a machine that unlocks
its disk with the TPM at boot, somebody sitting at it can trip that and stop the
next boot from unlocking until it recovers.
An administrator who does not accept that masks the file:
sudo ln -s /dev/null /etc/udev/rules.d/71-dodossh-tpm.rules
which is the standard override, survives upgrades, and puts DodoSSH back to
behaving as though the machine had no TPM. That is in the rule's own comments,
because the person who needs it is reading the file rather than the ADR.
Numbered 71 because 60-tpm-udev.rules is what creates the node's ownership and
73-seat-late.rules is what turns the tag into an ACL; verified in that order on
Fedora 44, along with `udevadm verify` accepting the file and the mask being
reported as "masked by previous entry". %post reloads and replays it against the
tpmrm subsystem, because a rule that waits for a reboot is a feature that does
not work on the day it is installed.
Both channels install the same path deliberately: this is about the machine's
TPM rather than about either build, the file is byte-identical from both, and
giving each its own copy would mean removing one package silently revoked the
other's access.
Phase 19 grew a check that the ACL exists for the local session and does *not*
exist for an SSH one, and another that masking the rule really does put the
machine back — then tells you to undo it before the checks that follow.
Also recorded, after it cost five confusing failures: swtpm serves exactly one
client, so a shell readiness probe sitting on the port makes the whole device-key
suite fail with "The TPM closed the connection" and nothing pointing at why.
367 lines
17 KiB
Bash
Executable File
367 lines
17 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# Turns a published linux-x64 tree into an RPM, and then checks the RPM rather than the exit code.
|
|
#
|
|
# Called from two jobs in .github/workflows/ci.yml and written once for the reason the four copies of
|
|
# "ensure node and git" in that file are not: those cannot be factored out — a composite action would
|
|
# need the checkout it exists to unblock — and this can. The build job packages the release-shaped
|
|
# RPM and throws it away, exactly as it does for Windows and macOS; the desktop job packages the
|
|
# nightly-shaped one and publishes it. Everything that differs between those two is an argument.
|
|
#
|
|
# It is also runnable at a desk, which is the point of it being a script:
|
|
#
|
|
# dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
|
|
# --configuration Release --runtime linux-x64 --self-contained true \
|
|
# -p:RestoreLockedMode=false --output /tmp/linux-x64
|
|
# scripts/ci-linux-rpm.sh --payload /tmp/linux-x64 --version 1.2.3 --out /tmp/rpm
|
|
#
|
|
# Nothing here holds a secret and nothing here uploads anything. Publishing is the calling job's, and
|
|
# ADR 0013 rule 3 is what decides which channel may do it.
|
|
|
|
set -euo pipefail
|
|
|
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
|
|
payload=""
|
|
version=""
|
|
channel="release"
|
|
out=""
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--payload) payload="$2"; shift 2 ;;
|
|
--version) version="$2"; shift 2 ;;
|
|
--channel) channel="$2"; shift 2 ;;
|
|
--out) out="$2"; shift 2 ;;
|
|
*) echo "Unknown argument: $1" >&2; exit 2 ;;
|
|
esac
|
|
done
|
|
|
|
for required in payload version out; do
|
|
if [ -z "${!required}" ]; then
|
|
echo "--$required is required." >&2
|
|
exit 2
|
|
fi
|
|
done
|
|
|
|
case "$channel" in
|
|
release|nightly) ;;
|
|
*) echo "--channel must be release or nightly, not '$channel'." >&2; exit 2 ;;
|
|
esac
|
|
|
|
# The apphost, named rather than globbed. A publish that produced no apphost at all would otherwise
|
|
# package happily into an RPM whose /usr/bin symlink points at nothing, and the first thing anybody
|
|
# would learn about it is a shell saying "No such file or directory" for a file that is right there.
|
|
if [ ! -s "$payload/DodoSSH" ]; then
|
|
echo "There is no apphost at $payload/DodoSSH, so there is nothing to package." >&2
|
|
ls -la "$payload" >&2 || true
|
|
exit 1
|
|
fi
|
|
|
|
# ◆ THE FOUR THINGS THAT DIFFER BETWEEN THE CHANNELS, AND WHY IT IS FOUR AND NOT ONE.
|
|
#
|
|
# This is ADR 0013 rule 9's table, in the spelling this platform uses. The package name keeps dnf from
|
|
# treating one as an upgrade of the other; the directory under /opt keeps the two trees apart on disk;
|
|
# the /usr/bin name and the desktop entry keep them apart in front of a person, who would otherwise
|
|
# have two identical launchers and no way to tell which was which.
|
|
#
|
|
# The fifth separation — the profile directory holding the vault cache, the outbox and the device key
|
|
# — is not here and must not be, because it belongs to the build rather than to the package: the
|
|
# nightly is published with DodoChannel=nightly, and ClientPaths reads that to suffix the directory.
|
|
# Doing it here as well would be a second answer to a question that already has one.
|
|
if [ "$channel" = nightly ]; then
|
|
name=dodossh-nightly
|
|
dir=DodoSSH.Nightly
|
|
bin=dodossh-nightly
|
|
title="DodoSSH Nightly"
|
|
else
|
|
name=dodossh
|
|
dir=DodoSSH
|
|
bin=dodossh
|
|
title="DodoSSH"
|
|
fi
|
|
|
|
# ◆ SEMVER IS NOT AN RPM VERSION, AND THE TRANSLATION IS THE ONE PLACE THIS CAN GO QUIETLY WRONG.
|
|
#
|
|
# rpm forbids `-` in a version, which is exactly the character SemVer uses to introduce a prerelease.
|
|
# MinVer's answer on main is 0.0.1-alpha.0.143, so the naive move — strip the suffix — would give
|
|
# every nightly the version 0.0.1 and make each one look like a reinstall of the last.
|
|
#
|
|
# `~` is rpm's own answer and has been since 4.10: it sorts *before* the empty string, so
|
|
# 0.0.1~alpha.0.143 is older than 0.0.1 the way alpha.143 is older than the release. Heights keep
|
|
# rising within a floored version and the first real tag moves past all of them, which is the same
|
|
# ordering property the Velopack channels have and is worth having on both.
|
|
#
|
|
# Build metadata after `+` is dropped rather than translated. It is the one part of a SemVer string
|
|
# that is defined as not participating in ordering, so keeping it would put a character rpm has no
|
|
# rule for into the field ordering is done on.
|
|
rpm_version="${version%%+*}"
|
|
rpm_version="${rpm_version/-/\~}"
|
|
|
|
case "$rpm_version" in
|
|
*-*)
|
|
echo "The version $version translates to $rpm_version, which still contains a hyphen." >&2
|
|
echo "rpm cannot order a version with one in it; this needs a rule rather than a retry." >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
# Always 1. The release field distinguishes several packagings of one upstream version, and there is
|
|
# only ever one packaging here — the version already carries the build height, because upstream and
|
|
# packager are the same repository.
|
|
rpm_release=1
|
|
|
|
echo "==> $name $rpm_version-$rpm_release.x86_64, from $payload"
|
|
|
|
# ◆ rpmbuild ON A MUSL HOST, WHICH IS FINE, AND WHY IT IS WORTH SAYING SO.
|
|
#
|
|
# The runner is Alpine. Nothing about that shows up in the package: rpmbuild composes an archive and
|
|
# some metadata, and the one part of it that reads the payload — elfdeps, which the spec leaves on for
|
|
# requires — parses ELF headers with libelf rather than loading anything, so it reads x86_64 glibc
|
|
# binaries on a musl host correctly. Building in a Fedora container was the alternative and would
|
|
# have meant `docker cp`-ing a 130 MB publish tree in and the package back out, because this runner is
|
|
# itself a container and a bind mount resolves on the daemon's host rather than here — see the note on
|
|
# the android job for the hour that cost.
|
|
if ! command -v rpmbuild >/dev/null 2>&1; then
|
|
SUDO=""
|
|
[ "$(id -u)" -eq 0 ] || SUDO="sudo"
|
|
|
|
echo "==> installing rpm"
|
|
if command -v apk >/dev/null 2>&1; then
|
|
$SUDO apk add --no-cache rpm
|
|
elif command -v dnf >/dev/null 2>&1; then
|
|
$SUDO dnf install -y rpm-build
|
|
elif command -v apt-get >/dev/null 2>&1; then
|
|
$SUDO apt-get update -qq
|
|
$SUDO apt-get install -y --no-install-recommends rpm
|
|
else
|
|
echo "No apk, dnf or apt-get here, so rpmbuild cannot be installed from inside the job." >&2
|
|
echo "Add rpm to the runner's image; the Linux package cannot be built without it." >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
rpmbuild --version
|
|
|
|
# ◆ THE LIST OF PRIVATE SONAMES, WORKED OUT FROM THE TREE RATHER THAN WRITTEN DOWN.
|
|
#
|
|
# build/linux/dodossh.spec turns rpm's automatic provides off for everything under /opt, because the
|
|
# libraries there are this application's private copies and have no business satisfying anybody
|
|
# else's dependency. The requires side has to be told the same thing about the same set, or a bundled
|
|
# library that links another bundled library asks for a soname nothing provides and the package
|
|
# becomes uninstallable on every host — which .NET's own libmscordbi.so, linking libmscordaccore.so,
|
|
# does on every publish.
|
|
#
|
|
# Derived here because the set belongs to the payload. A list in the spec would be Microsoft's and
|
|
# SkiaSharp's internal file names, maintained by hand, going stale on the first runtime bump — and
|
|
# going stale in the direction that produces a package nobody can install.
|
|
#
|
|
# Only the top level is scanned, which is where a .NET publish puts its native libraries. The dots
|
|
# are escaped because this becomes a regular expression; nothing else in a soname is a metacharacter.
|
|
#
|
|
# A shell glob rather than `find -printf`, which is GNU find's and not busybox's — the runner is
|
|
# Alpine, where that flag is a usage message and an empty list, which would silently disable the whole
|
|
# exclusion and put the uninstallable package back.
|
|
bundled="$(
|
|
for library in "$payload"/lib*.so*; do
|
|
[ -f "$library" ] && basename "$library"
|
|
done |
|
|
sed 's/\./\\./g' |
|
|
sort -u |
|
|
paste -sd '|' -
|
|
)"
|
|
|
|
if [ -z "$bundled" ]; then
|
|
echo "There are no native libraries at the top of $payload." >&2
|
|
echo "A self-contained .NET publish always has some, so this is a publish that went wrong" >&2
|
|
echo "rather than a package with nothing to exclude." >&2
|
|
exit 1
|
|
fi
|
|
|
|
work="$(mktemp -d)"
|
|
trap 'rm -rf "$work"' EXIT
|
|
|
|
mkdir -p "$out"
|
|
|
|
# --target rather than trusting the host's arch, because the host's arch here is whatever the runner
|
|
# happens to be and the payload's is decided by the RID the publish asked for. They agree today and
|
|
# an aarch64 runner would silently make them disagree.
|
|
#
|
|
# `dist` is emptied deliberately. It is the %{?dist} tag that puts .fc42 in a filename, and it names
|
|
# the distribution the package was *built* on — which for a self-contained tree built on Alpine would
|
|
# be both meaningless and, if rpm guessed something plausible, actively misleading.
|
|
rpmbuild -bb "$repo_root/build/linux/dodossh.spec" \
|
|
--target x86_64 \
|
|
--define "_topdir $work" \
|
|
--define "_rpmdir $out" \
|
|
--define "dist %{nil}" \
|
|
--define "dodo_name $name" \
|
|
--define "dodo_version $rpm_version" \
|
|
--define "dodo_release $rpm_release" \
|
|
--define "dodo_dir $dir" \
|
|
--define "dodo_bin $bin" \
|
|
--define "dodo_title $title" \
|
|
--define "dodo_payload $payload" \
|
|
--define "dodo_bundled $bundled" \
|
|
--define "dodo_icons $repo_root/src/DodoSSH.Client.App/Assets" \
|
|
--define "dodo_udev $repo_root/build/linux/71-dodossh-tpm.rules" \
|
|
--define "dodo_licence $repo_root/LICENSE"
|
|
|
|
# rpmbuild writes into <_rpmdir>/<arch>/, and every caller wants the file rather than the tree.
|
|
rpm_file="$out/x86_64/$name-$rpm_version-$rpm_release.x86_64.rpm"
|
|
if [ ! -s "$rpm_file" ]; then
|
|
echo "rpmbuild reported success and there is no package at $rpm_file." >&2
|
|
find "$out" >&2 || true
|
|
exit 1
|
|
fi
|
|
mv "$rpm_file" "$out/"
|
|
rmdir "$out/x86_64" 2>/dev/null || true
|
|
rpm_file="$out/$(basename "$rpm_file")"
|
|
|
|
echo "==> $(basename "$rpm_file"), $(wc -c < "$rpm_file") bytes"
|
|
|
|
# ◆ AND NOW THE PART THAT IS NOT PACKAGING: ASKING THE PACKAGE WHAT IT SAYS ABOUT ITSELF.
|
|
#
|
|
# Everything above can succeed and still produce something nobody can install, and none of the ways
|
|
# that happens are visible in an exit code. Each check below is one of them.
|
|
|
|
requires="$(rpm -qp --requires "$rpm_file")"
|
|
provides="$(rpm -qp --provides "$rpm_file")"
|
|
files="$(rpm -qp --list "$rpm_file")"
|
|
|
|
# 1. The bundled-soname trap the spec's provides exclusion sets. If some library under /opt ever
|
|
# gains a DT_NEEDED on another library under /opt, elfdeps will put that soname in the requires
|
|
# while the exclusion keeps it out of the provides, and the result is a package no host on earth
|
|
# can satisfy. Nothing about it looks wrong until an install fails, so it is checked here against
|
|
# the actual contents rather than assumed.
|
|
carried="$(printf '%s\n' "$files" | sed -n 's|^/opt/.*/\(lib[^/]*\.so[^/]*\)$|\1|p' | sort -u)"
|
|
unsatisfiable=""
|
|
for soname in $carried; do
|
|
if printf '%s\n' "$requires" | grep -qF "$soname("; then
|
|
unsatisfiable="$unsatisfiable $soname"
|
|
fi
|
|
done
|
|
if [ -n "$unsatisfiable" ]; then
|
|
echo "This package requires sonames it also carries and does not provide:$unsatisfiable" >&2
|
|
echo "That is unsatisfiable on every host, and it means the requires exclusion computed above" >&2
|
|
echo "no longer matches what rpm generated — a bundled library outside the top level, or an rpm" >&2
|
|
echo "that has stopped honouring __requires_exclude. See build/linux/dodossh.spec." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# 2. The provides exclusion still doing its job. A package advertising libcoreclr.so to the whole
|
|
# system is the thing that exclusion exists to prevent, and a future rpm that stopped honouring
|
|
# the macro would break it silently and in the direction nobody looks.
|
|
if printf '%s\n' "$provides" | grep -qE '^lib.*\.so'; then
|
|
echo "This package advertises bundled libraries to the system:" >&2
|
|
printf '%s\n' "$provides" | grep -E '^lib.*\.so' >&2
|
|
echo "__provides_exclude_from in build/linux/dodossh.spec is meant to stop that." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# 3. elfdeps having actually run. If it silently did nothing — a runner image whose rpm was built
|
|
# without libelf would do exactly that — the package would carry no glibc requirement at all and
|
|
# would install cheerfully onto a host too old to run it. libc.so.6 is the one dependency a
|
|
# self-contained .NET tree cannot fail to have.
|
|
if ! printf '%s\n' "$requires" | grep -q '^libc\.so\.6'; then
|
|
echo "This package does not require libc, which a self-contained .NET tree cannot be true of." >&2
|
|
echo "The automatic ELF scan produced nothing; rpm here is probably built without elfdeps." >&2
|
|
printf '%s\n' "$requires" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# 4. The four files a person interacts with, named individually. The tree under /opt is thousands of
|
|
# entries and its absence would be caught by size alone; these are the small ones whose absence
|
|
# would leave a package that installs, occupies 130 MB and puts nothing on the menu.
|
|
for expected in \
|
|
"/opt/$dir/DodoSSH" \
|
|
"/usr/bin/$bin" \
|
|
"/usr/share/applications/$bin.desktop" \
|
|
"/usr/share/icons/hicolor/256x256/apps/$bin.png" \
|
|
"/usr/lib/udev/rules.d/71-dodossh-tpm.rules"
|
|
do
|
|
if ! printf '%s\n' "$files" | grep -qxF "$expected"; then
|
|
echo "The package was built and $expected is not in it." >&2
|
|
printf '%s\n' "$files" | head -40 >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
# 5. Nothing world-writable, which on this package is a security property rather than a lint rule.
|
|
# The spec sets every mode explicitly and says why; this is the check that it worked, because the
|
|
# failure it guards against arrives by inheritance from a publish directory rather than by anybody
|
|
# changing this. A world-writable /opt/DodoSSH lets any local account replace the SSH client every
|
|
# other account on the machine runs.
|
|
#
|
|
# Symlinks are excluded because they are always 777 on Linux and the mode means nothing; what the
|
|
# link points at is what has permissions.
|
|
writable="$(
|
|
rpm -qp --queryformat '[%{FILEMODES:perms} %{FILENAMES}\n]' "$rpm_file" |
|
|
grep -v '^l' |
|
|
awk '$1 ~ /^.{8}w/ { print }'
|
|
)"
|
|
if [ -n "$writable" ]; then
|
|
echo "This package contains world-writable files:" >&2
|
|
printf '%s\n' "$writable" >&2
|
|
echo "Any local account could replace them. See the mode block in build/linux/dodossh.spec." >&2
|
|
exit 1
|
|
fi
|
|
|
|
echo "==> requires:"
|
|
printf '%s\n' "$requires" | sed 's/^/ /'
|
|
|
|
echo "==> $(basename "$rpm_file") is a $name $rpm_version package and says so."
|
|
|
|
# ◆ THE MANIFEST, WHICH IS WHAT AN INSTALLED CLIENT READS AND NOT WHAT A PERSON READS.
|
|
#
|
|
# The same arrangement the android job publishes, for the same reason its comment gives: a release's own
|
|
# JSON is a large document that changes shape when the forge is upgraded, and the client fetches this on a
|
|
# six-hourly timer. A small file this repository writes is a contract; a large one the forge writes is not.
|
|
#
|
|
# ◆ THE VERSION IN HERE IS THE RPM VERSION, AND THAT IS THE WHOLE POINT OF THE FILE.
|
|
#
|
|
# LinuxUpdateChannel compares with rpmvercmp, because rpm is what will accept or refuse the install — the
|
|
# same argument AndroidChannelManifest makes for versionCode. Publishing the SemVer instead would mean the
|
|
# client comparing one thing and the package manager another, and they disagree exactly where it matters:
|
|
# 0.0.1-alpha.0.143 and 0.0.1~alpha.0.143 are the same build, and only the second one sorts below 0.0.1.
|
|
#
|
|
# The SemVer goes in too and is used for nothing but the banner. A person asked which nightly they are on
|
|
# should be able to answer with the string the preferences screen prints, and that screen reads the
|
|
# assembly.
|
|
#
|
|
# ◆ AND THE DIGEST, WHICH THE OTHER TWO CHANNELS HAVE NO NEED OF.
|
|
#
|
|
# Velopack verifies what it downloads and Android refuses a package whose signature does not match the
|
|
# installed one. Nothing about an RPM fetched over HTTPS and handed to PackageKit checks that it is the
|
|
# file this release published — TLS authenticates the forge and says nothing about the bytes surviving a
|
|
# proxy, a cache or a partial write. So the manifest names the digest and the client refuses anything else.
|
|
#
|
|
# It is not a signature and must not be read as one: it is written by the same job that writes the package,
|
|
# so it proves integrity and not provenance. Provenance here is that the address is a constant pointing at
|
|
# the project's own forge — ADR 0013 rule 4 — and a signed repository is what would improve on it.
|
|
manifest="$out/linux-$channel.json"
|
|
digest="$(sha256sum "$rpm_file" | cut -d' ' -f1)"
|
|
|
|
cat > "$manifest" <<JSON
|
|
{
|
|
"rpmVersion": "$rpm_version-$rpm_release",
|
|
"version": "$version",
|
|
"rpm": "$(basename "$rpm_file")",
|
|
"sha256": "$digest"
|
|
}
|
|
JSON
|
|
|
|
echo "==> $(basename "$manifest"):"
|
|
sed 's/^/ /' "$manifest"
|
|
|
|
# The lines the calling job reads. Written to stdout as well as to GITHUB_OUTPUT when there is one, so the
|
|
# desk use above still tells you where the file went.
|
|
if [ -n "${GITHUB_OUTPUT:-}" ]; then
|
|
{
|
|
echo "rpm=$rpm_file"
|
|
echo "rpmName=$(basename "$rpm_file")"
|
|
echo "manifest=$manifest"
|
|
echo "manifestName=$(basename "$manifest")"
|
|
} >> "$GITHUB_OUTPUT"
|
|
fi
|
|
echo "$rpm_file"
|