Skip to content

Security: framefilter/keyroost

SECURITY.md

Security Policy

Reporting a vulnerability

Use GitHub's private vulnerability reporting ("Report a vulnerability" under the Security tab) so the report stays out of public issues until a fix ships. If that's unavailable, open an issue saying only that you have a sensitive report and a maintainer will arrange a private channel — please don't put details in the issue itself.

You can expect an acknowledgement within a week. There is no bounty program; credit in the changelog is offered.

Please give a reasonable window before public disclosure — the aim is a fix and an advisory within 90 days of the report, sooner for anything actively exploitable. If GitHub is not a workable channel for you, say so in a content-free issue and a maintainer will arrange an alternative.

Out of scope as vulnerabilities: keyroost displaying a secret you asked it to display on your own terminal or screen; a compromised host reading the process's memory; and physical or firmware attacks on the token itself (see the threat model below).

Supported versions

Only the latest release receives security fixes. The tool talks to local hardware and has no server component, so updating is the whole story.

Threat model

What keyroost defends against:

  • Malicious or malformed input files. Import parsers (otpauth URIs, Aegis/2FAS exports, encrypted vaults) are bounds-checked, reject attacker-controlled resource demands (e.g. hostile scrypt parameters), and authenticate ciphertexts before use.
  • Malicious or buggy devices. Everything read from USB/NFC — APDU responses, TLV/BER structures, CBOR, CTAP-HID frames — is length-checked and bounded; a fuzzing device gets an error, not a hang or a panic.
  • Accidental secret disclosure by the tool itself. keyroost persists no secrets of its own accord — the only files it writes unprompted are the friendly-name registry (keys.json, which holds salted fingerprints, not serials, and its salt file keys.salt) and the GUI's settings.json (while an older keys.json is converted, a short-lived owner-only backup of it, keys.json.v1-backup, holds its old serials and is removed once the conversion succeeds), all created owner-only (0600) and none holding key material. Secret output reaches disk only when you name a destination for it (e.g. openpgp decrypt --out), and those writes go through an owner-only temp file that is fsynced and atomically renamed, refusing to follow a symlink or to overwrite a file owned by another user. --debug traces redact secret-bearing command bodies on every applet — Molto2 secure writes, OATH PUT/SET CODE/VALIDATE, OpenPGP key import and decipher, PIV GENERAL AUTHENTICATE, the Token2 OTP seed writes and code reads, and CTAP CBOR exchanges, in both directions where a response is as revealing as the request. Secret-typed memory is zeroized on drop where Rust allows — PINs, CTAP session secrets and per-credential largeBlob keys, RSA key components, imported TOTP seeds, and the decrypted-vault / QR-payload buffers they pass through (buffer reallocations and library-internal copies remain out of reach). PINs, passwords, keys, seeds and otpauth:// URIs are accepted via an environment variable, stdin or a hidden terminal prompt only — never argv.

What keyroost does not defend against:

  • A compromised host. Code running as your user can read process memory and everything you can read. No host-side tool can fix this.
  • Physical attacks on the token itself, or weaknesses in a device's own firmware/protocol. Notably, the Molto2's wire protocol (4-byte SM4-CBC-MAC truncation, SM4-ECB seed encryption keyed from the customer key) is fixed by the device; rotating the customer key away from the public factory default is the strongest available mitigation, and the CLI warns when you haven't.
  • Other software with access to the same device. Anything the OS allows to open the token can talk to it; unplug keys you're not using.

Invariants you can rely on

  • No network access, by design. No crate in this workspace opens a network socket, resolves a hostname, or speaks HTTP; there is no telemetry, no update check, no "cloud". A release that broke this would be a security bug — report it as one. Two things look adjacent and are not: the GUI's "Learn" link hands a documentation URL to your browser (keyroost makes no connection itself), and the Wayland screenshot path talks to xdg-desktop-portal over the session bus, which is local IPC.
  • unsafe only in two scoped FFI shims. unsafe_code = "forbid" is the workspace-wide default; the sole exceptions are the Windows-only keyroost-winwebauthn (HID enumeration / shell launch) and keyroost-screengrab (GDI screen capture) crates, which set unsafe_code = "allow" to confine their thin windows-sys FFI. Both are inert on non-Windows targets. No unsafe appears in any cross-platform crate.
  • Vendored protocol code, scoped dependencies. SM4, SHA-1/256/512, the OATH HMAC, base32/hex, and the APDU/TLV/CBOR and otpauth:// parsers are implemented in-tree with no external crate; keyroost-proto and keyroost-resolve carry no external dependencies at all, and the OATH, OpenPGP, PIV and Token2 single-profile byte layers carry nothing but zeroize. That does not mean no external hash code is in the tree: where the CTAP and Token2-OTP paths need those primitives inside a larger construction (client-PIN, ECDH, largeBlob), they use RustCrypto's sha2 and hmac rather than a hand-rolled equivalent. What is pulled in is deliberate, confined to the crate that needs it, and annotated in the Cargo.toml that declares it: the device and OS boundary (pcsc; hidapi off Linux, where sysfs and hidraw are used directly; windows-sys on Windows), the interface boundary (clap and clap_complete/clap_mangen; rpassword for the hidden terminal prompt; eframe/egui with arboard, rfd, pollster, png and Linux's x11rb/ashpd; serde/serde_json and base64), and cryptography that would be irresponsible to hand-roll under forbid(unsafe_code) — RustCrypto (sha2, hmac, aes, des, cbc, cipher, p256, aes-gcm), getrandom, rand_core, zeroize, scrypt (Aegis vaults), rsa/rand (host RSA keygen, confined to keyroost-rsakey), p384/p521/ed25519-dalek/x25519-dalek (host-side verification of the PIV slot self-test, confined to keyroost-pivtest), and miniz_oxide (CTAP large-blob deflate, and reading and writing gzip-compressed PIV certificates; decompression is size-capped). QR and image decoding (rqrr, png, jpeg-decoder) is confined to keyroost-qr. The README's "Workspace layout" table lists them per crate. No new dependency lands without that justification.
  • One build script, Windows-only. The workspace has exactly one build.rs (crates/keyroost/build.rs). It embeds the application icon and the VS_VERSION_INFO resource into keyroost.exe so that CI, cargo install and vendor-signed builds all produce the same bytes plus a signature. It is #[cfg(windows)] — an empty main() on every other target — and its sole build-dependency (winresource) is declared under [target.'cfg(windows)'.build-dependencies], so a Linux or macOS build neither fetches nor runs it. No build script in this workspace generates code, downloads anything, or reads outside its own package.
  • Continuously fuzzed and dependency-audited. The parsers named in the threat model are covered by eighteen cargo-fuzz targets (fuzz/fuzz_targets/), all run on a weekly schedule; a RUSTSEC scan (cargo audit) runs weekly and on every change to a manifest or the lockfile, and Dependabot proposes monthly updates for the cargo, GitHub-Actions and Nix flake dependency sets. The fuzz harness is its own workspace so its nightly-only toolchain requirement never enters the shipped dependency tree.
  • Reviewable releases. Release binaries are built by CI from tagged commits using SHA-pinned actions, without shared build caches (a tag build would otherwise fall back to default-branch caches), with SHA256SUMS and a build-provenance attestation published alongside (gh attestation verify <file> --repo framefilter/keyroost). The crates.io fanout authenticates through Trusted Publishing (OIDC), so no long-lived registry token exists to be stolen.

Release integrity

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 maintainer reviews the complete diff since the previous release before signing, so the tag signature covers every change in the release, including merged contributions.
  • Build provenance. Release assets carry GitHub build-provenance attestations identifying the workflow run and commit that produced them. Verification command below.
  • Checksums. SHA256SUMS covers the platform archives. The AppImage and 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); only the maintainer can merge, and workflow runs for outside contributors start only after maintainer approval. Per-commit signature enforcement was removed because it prevented merging external pull requests, and it does not address the relevant attack class: a compromised or malicious committer produces validly signed commits, as in the xz-utils backdoor. The mechanisms above verify that a release matches the public source, which is the property that attack violates.

Verifying a download

sha256sum -c SHA256SUMS --ignore-missing
gh attestation verify keyroost-*-linux-x86_64.tar.gz --repo framefilter/keyroost

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

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:

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:

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:

./validate-x86_64.AppImage keyroost-x86_64.AppImage

Or skip the question entirely and build from source with cargo build --release --locked.

There aren't any published security advisories