Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 110 additions & 8 deletions .github/workflows/linux-bundles.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ name: Linux bundles (AppImage + Flatpak)
# see SECURITY.md "Release integrity"), so a CI bot can't push to it; this
# keeps the Learn site (this repo's docs/) untouched. When the token is unset, the
# OSTree publish skips cleanly (the .flatpak bundle still attaches).
# 5. (REQUIRED) AppImage signing key: environment secrets APPIMAGE_GPG_KEY (the
# ASCII-armored PRIVATE key) and APPIMAGE_GPG_KEY_ID (its full fingerprint)
# on release-publish, plus the committed public key
# packaging/appimage/keyroost-appimage-signing.asc. Unlike the Flatpak key,
# a missing key FAILS the AppImage job (probes too).

on:
push:
Expand Down Expand Up @@ -141,27 +146,124 @@ jobs:
with:
toolchain: stable

# build-appimage.sh builds `keyroost` (release), fetches linuxdeploy +
# its appimage plugin, stages the AppDir, and emits keyroost-x86_64.AppImage
# into target/appimage/. APPIMAGE_EXTRACT_AND_RUN avoids needing FUSE for the
# tools themselves inside CI.
# The AppImage is GPG-signed with a dedicated key (SECURITY.md "Release
# integrity"). Once releases are signed, an unsigned one must not
# ship, so a missing key or public key fails the run, probes included: probes exercise the same
# signing path a release takes. The committed public key is checked
# first, before the long compile.
- name: Check AppImage signing public key is committed
run: |
set -euo pipefail
pub=packaging/appimage/keyroost-appimage-signing.asc
if [ ! -s "${pub}" ]; then
echo "::error::${pub} is missing. The AppImage is signed and verified against this committed public key; see packaging/LINUX-BUNDLES.md (one-time setup, AppImage signing key)."
exit 1
fi

# The GUI binary is built in its own step, before the signing key is
# imported: `cargo build` runs dependency build scripts and proc-macros,
# and at this point no secret is in their environment and no key is in
# any keyring. (The packaging step below, which signs, does have the
# key; build-appimage.sh hands it only to the final plugin run.)
- name: Build GUI binary
run: cargo build --release -p keyroost --features keyroost/qr

# Signing key = environment secrets on release-publish (this job's
# environment), exposed ONLY to this step through env:. GNUPGHOME is a
# per-run temp dir, set per step (not via GITHUB_ENV) so no later step
# inherits it. The imported key must be exactly the committed public
# key: a wrong secret fails here rather than shipping a key change.
- name: Import AppImage signing key
env:
APPIMAGE_GPG_KEY: ${{ secrets.APPIMAGE_GPG_KEY }}
APPIMAGE_GPG_KEY_ID: ${{ secrets.APPIMAGE_GPG_KEY_ID }}
GNUPGHOME: ${{ runner.temp }}/gnupg-appimage
run: |
set -euo pipefail
if [ -z "${APPIMAGE_GPG_KEY:-}" ] || [ -z "${APPIMAGE_GPG_KEY_ID:-}" ]; then
echo "::error::APPIMAGE_GPG_KEY / APPIMAGE_GPG_KEY_ID are not set on the release-publish environment. The AppImage must be signed (an unsigned release is refused by updaters once signed ones exist); see packaging/LINUX-BUNDLES.md (one-time setup)."
exit 1
fi
norm() { tr -d ' \n' | tr 'a-f' 'A-F'; }
want="$(printf '%s' "${APPIMAGE_GPG_KEY_ID}" | norm)"
pub=packaging/appimage/keyroost-appimage-signing.asc
[ -s "${pub}" ] || { echo "::error::${pub} is missing"; exit 1; }
rm -rf "${GNUPGHOME}"
mkdir -m 700 "${GNUPGHOME}"
committed="$(gpg --batch --with-colons --show-keys "${pub}" \
| awk -F: '$1 == "fpr" {print $10}')"
if [ "$(printf '%s\n' "${committed}" | grep -c .)" != "1" ]; then
echo "::error::${pub} must hold exactly one key with no subkeys"
exit 1
fi
printf '%s\n' "${APPIMAGE_GPG_KEY}" | gpg --batch --quiet --import
secret="$(gpg --batch --with-colons --list-secret-keys \
| awk -F: '$1 == "fpr" {print $10}')"
if [ "$(printf '%s\n' "${secret}" | grep -c .)" != "1" ]; then
echo "::error::APPIMAGE_GPG_KEY must hold exactly one secret key with no subkeys"
exit 1
fi
if [ "$(printf '%s' "${secret}" | norm)" != "${want}" ]; then
echo "::error::the key in APPIMAGE_GPG_KEY does not match APPIMAGE_GPG_KEY_ID"
exit 1
fi
if [ "$(printf '%s' "${committed}" | norm)" != "${want}" ]; then
echo "::error::the signing key does not match the committed ${pub}; a different key would be refused by updaters (KEY_CHANGED). See SECURITY.md before rotating."
exit 1
fi
echo "signing key imported and matches ${pub}"

# Packaging only (KEYROOST_APPIMAGE_SKIP_CARGO): fetches the pinned
# linuxdeploy + appimage plugin, stages the AppDir, and emits
# keyroost-x86_64.AppImage (+ .zsync) into target/appimage/. LDAI_SIGN /
# LDAI_SIGN_KEY make the plugin's appimagetool sign, which happens before
# it writes the .zsync. APPIMAGE_EXTRACT_AND_RUN avoids needing FUSE for
# the tools themselves inside CI. The key ID is passed through env: like
# any secret; the key itself is only in the temp keyring.
- name: Build AppImage
env:
APPIMAGE_EXTRACT_AND_RUN: '1'
KEYROOST_APPIMAGE_SKIP_CARGO: '1'
LDAI_SIGN: '1'
LDAI_SIGN_KEY: ${{ secrets.APPIMAGE_GPG_KEY_ID }}
GNUPGHOME: ${{ runner.temp }}/gnupg-appimage
run: bash packaging/appimage/build-appimage.sh

- name: Remove AppImage signing key
if: always()
env:
GNUPGHOME: ${{ runner.temp }}/gnupg-appimage
run: |
gpgconf --kill gpg-agent 2>/dev/null || true
rm -rf "${GNUPGHOME}"

- name: Locate AppImage
id: find
run: |
set -euo pipefail
img="$(ls target/appimage/*.AppImage | head -n1)"
# Explicit name: the folder also holds the linuxdeploy tool
# AppImages, and the guard, Attest, checksum and upload must all
# use this very file.
img=target/appimage/keyroost-x86_64.AppImage
[ -f "${img}" ] || { echo "::error::${img} was not produced"; exit 1; }
echo "path=${img}" >> "$GITHUB_OUTPUT"
echo "found ${img}"
# zsync sidecar for AppImageUpdate delta updates (#53), if produced.
zsync="$(ls target/appimage/*.AppImage.zsync 2>/dev/null | head -n1 || true)"
zsync=
[ ! -f "${img}.zsync" ] || zsync="${img}.zsync"
echo "zsync=${zsync}" >> "$GITHUB_OUTPUT"
echo "zsync ${zsync:-<none>}"

# Guard: nothing is attested or uploaded unless the signature verifies
# against the COMMITTED public key alone, the update information is
# intact, and the .zsync describes the signed bytes. No secrets here.
# (CI logs may show the fingerprint as ***: its key ID is a secret, so
# GitHub masks it. The fingerprint itself is published in SECURITY.md.)
- name: Verify AppImage signature
run: |
bash packaging/appimage/verify-appimage-signature.sh --require-zsync \
"${{ steps.find.outputs.path }}"

# Give the AppImage the same provenance + checksum treatment the main
# release archives get in release.yml.
- name: Attest AppImage provenance
Expand All @@ -181,8 +283,8 @@ jobs:
with:
name: keyroost-appimage
path: |
target/appimage/*.AppImage
target/appimage/*.AppImage.zsync
${{ steps.find.outputs.path }}
${{ steps.find.outputs.zsync }}

# Attach to the Release for $TAG (release.yml's on a tag push, an
# existing one on a dispatch republish). Retry patiently (20×30s — v0.7.6
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,9 @@ chmod +x keyroost-x86_64.AppImage
./keyroost-x86_64.AppImage --appimage-extract-and-run
```

The AppImage is GPG-signed (key fingerprint `F9E0 4EED 9DC5 DC94 AB2E 6100 6C81 DC35 3794 478B`); see
[SECURITY.md → Verifying the AppImage](SECURITY.md#verifying-the-appimage).

> **Uses the host's pcsc-lite.** The AppImage always prefers the host's
> pcsc-lite client library, so the smart-card client matches the host's `pcscd`
> daemon; smart cards need `pcscd` installed (see
Expand Down
68 changes: 67 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ What keyroost does **not** defend against:

## Release integrity

Four mechanisms:
Five mechanisms:

- **Signed tags.** Release tags are signed by the maintainer with a hardware
key; tag creation is restricted to the repository admin by ruleset. The
Expand All @@ -155,6 +155,25 @@ Four mechanisms:
flatpak publish separate `.sha256` files.
- **crates.io Trusted Publishing.** Crates are published by the release
workflow over OIDC. No long-lived publishing token exists.
- **AppImage signature.** `keyroost-x86_64.AppImage` carries an embedded
GPG signature from a dedicated signing key, fingerprint
`F9E0 4EED 9DC5 DC94 AB2E 6100 6C81 DC35 3794 478B`. The public key is committed as
`packaging/appimage/keyroost-appimage-signing.asc`, so the signed release
tag covers it. AppImageUpdate-based updaters (appimageupdatetool,
AppImageLauncher) compare the key in the installed AppImage with the key
in the update and refuse an update signed by a different key, and an
unsigned installed AppImage is refused an update to a signed one. That is key continuity between updates. It does not
authenticate a first download: anyone can embed their own key in an
AppImage, so a first download is checked with the attestation and the
`.sha256` file, or against the fingerprint above (see "Verifying the
AppImage" below). The private key exists only as a GitHub environment
secret behind the release approval gate; the release workflow refuses to
build an AppImage when it is missing, or when it does not match the
committed public key. **Rotation:** the key does not expire and is not
rotated on a schedule. It is replaced only if it is lost or compromised.
A new key makes AppImageUpdate refuse that update ("key changed"), so
users download the new AppImage by hand once; the release notes say so
and SECURITY.md and the README publish the new fingerprint.

Commits on `main` are not required to be signed. Changes land through
reviewed pull requests (`packaging/REVIEWING.md` is the review checklist);
Expand All @@ -177,5 +196,52 @@ The release assets are named `keyroost-<tag>-linux-x86_64.tar.gz`,
`keyroost-<tag>-macos-universal2.tar.gz` and
`keyroost-<tag>-windows-x86_64.zip`; `SHA256SUMS` covers all three.

### Verifying the AppImage

```sh
sha256sum -c keyroost-x86_64.AppImage.sha256
gh attestation verify keyroost-x86_64.AppImage --repo framefilter/keyroost
```

The embedded signature can also be checked against the published key, with
the fingerprint `F9E0 4EED 9DC5 DC94 AB2E 6100 6C81 DC35 3794 478B`. From a checkout of the release tag,
one command checks the signature, that the embedded key is the published
one, and the update information:

```sh
bash packaging/appimage/verify-appimage-signature.sh keyroost-x86_64.AppImage
```

Or by hand with `readelf` and `gpg`. The signed data is the SHA-256 of the
file with the `.sha256_sig` and `.sig_key` sections zeroed, as a lowercase
hex string:

```sh
export GNUPGHOME="$(mktemp -d)" # a throwaway keyring, not your own
gpg --import packaging/appimage/keyroost-appimage-signing.asc
f=keyroost-x86_64.AppImage
cp "$f" zeroed
for s in .sha256_sig .sig_key; do
set -- $(readelf -SW "$f" | sed 's/\[ */[/' | awk -v s="$s" '$2 == s {print $5, $6}')
[ "$s" = .sha256_sig ] && dd if="$f" bs=1 skip=$((0x$1)) count=$((0x$2)) status=none | tr -d '\000' > sig.asc
dd if=/dev/zero of=zeroed bs=1 seek=$((0x$1)) count=$((0x$2)) conv=notrunc status=none
done
printf %s "$(sha256sum zeroed | cut -d' ' -f1)" > digest.txt
gpg --verify sig.asc digest.txt # "Good signature"; compare the fingerprint
```

In the release workflow's logs the fingerprint may appear as `***`: its key
ID is stored as a secret, so GitHub masks it. The fingerprint is public and
is the one published above and in the README.

AppImageUpdate's `validate` tool (`validate-x86_64.AppImage` from its
releases) checks a file against the key embedded in it, so "validation
successful" means something only once the fingerprint it prints matches the
one above:

```sh
./validate-x86_64.AppImage keyroost-x86_64.AppImage
```

Or skip the question entirely and build from source with
`cargo build --release --locked`.
7 changes: 7 additions & 0 deletions changelog.d/added-171-appimage-signature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
- **The AppImage is GPG-signed.** Each release's AppImage carries an
embedded signature from a dedicated signing key; its fingerprint is in
SECURITY.md and the README, which also explain how to check it.
AppImageUpdate-based updaters (appimageupdatetool, AppImageLauncher)
now refuse an update signed by a different key. Users updating an
unsigned AppImage (v0.12.x or earlier) with these tools download this
release by hand once. ([#171])
10 changes: 10 additions & 0 deletions changelog.d/fixed-170-bulk-import-dialog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
- **The Molto2 bulk import dialog closes and says why "Program all" is
gray.** Close in the bulk import dialog, and Cancel in the "Import to
slot" dialog, did nothing; both now close the dialog, and closing
either dialog by any route wipes the typed vault password or pasted URI
and drops the parsed entries (which carry seeds).
When "Program all" can't run, one line under the buttons now says why:
the file is still loading, no Molto2 is selected, the Molto2 isn't
unlocked yet (with an Authenticate button right there, and a failed
attempt is reported in that line too), or the entries
don't all fit before slot #99 (with how many do). ([#170])
Loading
Loading