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).
Only the latest release receives security fixes. The tool talks to local hardware and has no server component, so updating is the whole story.
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 filekeys.salt) and the GUI'ssettings.json(while an olderkeys.jsonis 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.--debugtraces redact secret-bearing command bodies on every applet — Molto2 secure writes, OATHPUT/SET CODE/VALIDATE, OpenPGP key import and decipher, PIVGENERAL 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 andotpauth://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.
- 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-portalover the session bus, which is local IPC. unsafeonly in two scoped FFI shims.unsafe_code = "forbid"is the workspace-wide default; the sole exceptions are the Windows-onlykeyroost-winwebauthn(HID enumeration / shell launch) andkeyroost-screengrab(GDI screen capture) crates, which setunsafe_code = "allow"to confine their thinwindows-sysFFI. Both are inert on non-Windows targets. Nounsafeappears 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-protoandkeyroost-resolvecarry no external dependencies at all, and the OATH, OpenPGP, PIV and Token2 single-profile byte layers carry nothing butzeroize. 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'ssha2andhmacrather than a hand-rolled equivalent. What is pulled in is deliberate, confined to the crate that needs it, and annotated in theCargo.tomlthat declares it: the device and OS boundary (pcsc;hidapioff Linux, where sysfs andhidraware used directly;windows-syson Windows), the interface boundary (clapandclap_complete/clap_mangen;rpasswordfor the hidden terminal prompt;eframe/eguiwitharboard,rfd,pollster,pngand Linux'sx11rb/ashpd;serde/serde_jsonandbase64), and cryptography that would be irresponsible to hand-roll underforbid(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 tokeyroost-rsakey),p384/p521/ed25519-dalek/x25519-dalek(host-side verification of the PIV slot self-test, confined tokeyroost-pivtest), andminiz_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 tokeyroost-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 theVS_VERSION_INFOresource intokeyroost.exeso that CI,cargo installand vendor-signed builds all produce the same bytes plus a signature. It is#[cfg(windows)]— an emptymain()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-fuzztargets (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
SHA256SUMSand 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.
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.
SHA256SUMScovers the platform archives. The AppImage and flatpak publish separate.sha256files. - crates.io Trusted Publishing. Crates are published by the release workflow over OIDC. No long-lived publishing token exists.
- AppImage signature.
keyroost-x86_64.AppImagecarries an embedded GPG signature from a dedicated signing key, fingerprintF9E0 4EED 9DC5 DC94 AB2E 6100 6C81 DC35 3794 478B. The public key is committed aspackaging/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.sha256file, 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.
sha256sum -c SHA256SUMS --ignore-missing
gh attestation verify keyroost-*-linux-x86_64.tar.gz --repo framefilter/keyroostThe 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.
sha256sum -c keyroost-x86_64.AppImage.sha256
gh attestation verify keyroost-x86_64.AppImage --repo framefilter/keyroostThe 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.AppImageOr 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 fingerprintIn 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.AppImageOr skip the question entirely and build from source with
cargo build --release --locked.