Skip to content

Commit bffc05a

Browse files
framefilterclaude
andcommitted
review fixes: keyring load resilience, publish order, pinned wingetcreate, QR partial decode
Post-review corrections to the security-hardening branch before merging to main: - keyring: sanitize names on load instead of rejecting the file. One out-of-policy entry in a hand-edited keys.json made the whole registry unloadable, and callers that fall back to an empty registry on error (GUI rename) would then save and destroy every other entry. - publish: crates.io order published keyroost-resolve before its dependency keyroost-transport, which fails the first tag run; same ordering corrected in the TODO runbook. - publish: pin wingetcreate.exe to a versioned release and verify its SHA-256 before running it with the winget token — matches the SHA pinning applied to every action in the same file. - qr: one malformed otpauth payload no longer discards accounts decoded from the other QR codes in the same image; it becomes a skip, and the underlying error is surfaced only when nothing in the image parses. - keyroostctl: `import --qr` now prints the "QR i of n" notice for multi-code Google Authenticator exports (a clean 1-account import of QR 1 read as "migration complete"), and the 0-account error no longer suggests import-file, which would equally find nothing. - SECURITY.md: scope the zeroize-on-drop claim to what is implemented; imported seed buffers are tracked in TODO-hardening.md instead. - udev: correct the ID_SECURITY_TOKEN attribution (fido_id builtin, not hwdb) and document that the generic rule needs systemd-udevd. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 0e6243d commit bffc05a

7 files changed

Lines changed: 123 additions & 35 deletions

File tree

.github/workflows/publish.yml

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ jobs:
7575
version="${TAG#v}"
7676
for crate in keyroost-proto keyroost-hid keyroost-keyring keyroost-rsakey \
7777
keyroost-ctap keyroost-oath keyroost-openpgp keyroost-piv \
78-
keyroost-import keyroost-resolve keyroost-qr keyroost-transport \
78+
keyroost-import keyroost-transport keyroost-resolve keyroost-qr \
7979
keyroostctl keyroost; do
8080
if curl -fsSL "https://crates.io/api/v1/crates/${crate}/${version}" >/dev/null 2>&1; then
8181
echo "${crate} ${version} already on crates.io — skipping"
@@ -219,7 +219,17 @@ jobs:
219219
run: |
220220
$version = $Env:TAG.TrimStart('v')
221221
$url = "https://github.com/$Env:GITHUB_REPOSITORY/releases/download/$Env:TAG/keyroost-$Env:TAG-windows-x86_64.zip"
222-
curl.exe -fsSL -o wingetcreate.exe https://aka.ms/wingetcreate/latest
222+
# Pinned + hash-checked for the same reason the actions above are
223+
# SHA-pinned: this executable runs with the winget PAT in env.
224+
# Hash is from wingetcreate.exe.txt on the same release.
225+
$wcVersion = "v1.12.8.0"
226+
$wcSha256 = "8bd738851b524885410112678e3771b341c5c716de60fbbecb88ab0a363ed85d"
227+
curl.exe -fsSL -o wingetcreate.exe "https://github.com/microsoft/winget-create/releases/download/$wcVersion/wingetcreate.exe"
228+
$actual = (Get-FileHash wingetcreate.exe -Algorithm SHA256).Hash.ToLower()
229+
if ($actual -ne $wcSha256) {
230+
Write-Error "wingetcreate.exe hash mismatch: got $actual, expected $wcSha256"
231+
exit 1
232+
}
223233
.\wingetcreate.exe update Framefilter.Keyroost `
224234
--version $version `
225235
--urls $url `

SECURITY.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,9 @@ What keyroost defends against:
2929
and bounded; a fuzzing device gets an error, not a hang or a panic.
3030
- **Accidental secret disclosure by the tool itself.** Secrets are never
3131
written to disk; `--debug` traces redact secret-bearing command bodies;
32-
secret-typed memory (PINs, seeds, RSA components, session tokens) is
33-
zeroized on drop where Rust allows; secrets are accepted via env/stdin
32+
PINs, CTAP session secrets, and RSA key components are zeroized on drop
33+
(imported TOTP seeds passing through vault/QR import buffers are not yet
34+
— tracked in TODO-hardening.md); secrets are accepted via env/stdin
3435
rather than argv.
3536

3637
What keyroost does **not** defend against:

TODO-hardening.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ done and committed on this branch.
2222

2323
- [x] Zeroize `PinUvAuthToken` and the CTAP shared secrets on drop
2424
- [x] Zeroize CLI-side secret strings (`read_secret` / `gather_secret` returns)
25+
- [ ] Zeroize imported TOTP seeds: `BulkEntry.secret`, the decrypted Aegis
26+
vault plaintext, and GA migration entry buffers currently drop without
27+
wiping (SECURITY.md is scoped to match until this lands)
2528

2629
## Phase 4 — documentation
2730

@@ -57,8 +60,9 @@ index propagation:
5760

5861
1. `keyroost-proto`, `keyroost-hid`, `keyroost-keyring`, `keyroost-rsakey`
5962
2. `keyroost-ctap`, `keyroost-oath`, `keyroost-openpgp`, `keyroost-piv`,
60-
`keyroost-import`, `keyroost-resolve`
61-
3. `keyroost-qr`, `keyroost-transport`
63+
`keyroost-import`
64+
3. `keyroost-transport` (needs proto/oath/openpgp/piv), then
65+
`keyroost-resolve` (needs transport) and `keyroost-qr` (needs import)
6266
4. `keyroostctl`, `keyroost`
6367

6468
Afterwards `cargo install keyroostctl` / `cargo install keyroost` work for
@@ -78,6 +82,17 @@ anyone with the Linux build prerequisites from the README.
7882
- [ ] **Branch/tag protection** — repo settings, must be done in the GitHub
7983
UI by an admin: protect `main` (require PR + green CI), protect `v*`
8084
tags (maintainers only; tag push is release authority).
85+
- [ ] **GUI: move slow imports off the frame loop** — QR image decode and
86+
Aegis scrypt decryption run synchronously inside `update()`, freezing
87+
the window (seconds for a stock vault, minutes at the scrypt caps);
88+
route through the existing job mechanism, mindful that the worker
89+
thread serializes device I/O.
90+
- [ ] **Wayland clipboard clear** — the conditional clear reads via
91+
arboard's X11 backend; on pure-Wayland sessions without XWayland
92+
clipboard sync it fails open and never clears. No complete fix known
93+
(wl-data-control is wlroots-only); document or detect.
94+
- [ ] **CI cache for fuzz/audit jobs** — both `cargo install` cargo-fuzz /
95+
cargo-audit from source every run; cache the binaries.
8196
- [x] **Clipboard conditional clear** — done via arboard (already in the
8297
tree through eframe): clears only when the clipboard still holds the
8398
copied code; fails open if unreadable.

crates/keyroost-keyring/src/lib.rs

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -230,13 +230,15 @@ impl Keyring {
230230
Ok(s) => {
231231
let mut ring: Keyring =
232232
serde_json::from_str(&s).map_err(|e| KeyringError::Parse(e.to_string()))?;
233-
// `add` validates names before they ever reach disk, so
234-
// re-validate on the way back in: a hand-edited file could
235-
// otherwise inject ANSI escape sequences that get echoed to
236-
// the terminal in error messages and listings. Free-text
237-
// fields are sanitized rather than rejected.
233+
// `add` validates names before they ever reach disk; on the
234+
// way back in every field — including the name — is sanitized
235+
// rather than rejected. Rejecting would make one hand-edited
236+
// entry render the whole registry unloadable, and callers
237+
// that fall back to an empty registry on error would then
238+
// overwrite keys.json and destroy every other entry on the
239+
// next save.
238240
for entry in &mut ring.keys {
239-
validate_name(&entry.name)?;
241+
strip_control_chars(&mut entry.name);
240242
strip_control_chars(&mut entry.serial);
241243
for field in [&mut entry.vendor, &mut entry.aaguid, &mut entry.note]
242244
.into_iter()
@@ -475,18 +477,22 @@ mod tests {
475477
}
476478

477479
#[test]
478-
fn load_rejects_invalid_names_and_strips_control_chars() {
480+
fn load_sanitizes_invalid_names_and_strips_control_chars() {
479481
let dir = std::env::temp_dir().join(format!("keyroost-test-{}", std::process::id()));
480482
std::fs::create_dir_all(&dir).unwrap();
481483
let path = dir.join("keys.json");
482484

483-
// A name with an ANSI escape must fail validation on load.
485+
// A name with an ANSI escape is sanitized, not fatal: one bad
486+
// hand-edited entry must never make the whole registry unloadable
487+
// (an unwrap_or_default + save would wipe every other entry).
484488
std::fs::write(
485489
&path,
486-
"{\"keys\":[{\"name\":\"evil\\u001b[31m\",\"serial\":\"S1\"}]}",
490+
"{\"keys\":[{\"name\":\"evil\\u001b[31m\",\"serial\":\"S1\"},{\"name\":\"good\",\"serial\":\"S2\"}]}",
487491
)
488492
.unwrap();
489-
assert!(Keyring::load_from(&path).is_err());
493+
let k = Keyring::load_from(&path).unwrap();
494+
assert_eq!(k.keys[0].name, "evil[31m");
495+
assert_eq!(k.keys[1].name, "good");
490496

491497
// Control chars in free-text fields are stripped, not fatal.
492498
std::fs::write(

crates/keyroost-qr/src/lib.rs

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -111,30 +111,65 @@ pub fn entries_from_image(bytes: &[u8]) -> Result<QrImport, QrError> {
111111
batch: None,
112112
};
113113
let mut any_otpauth = false;
114+
// A malformed payload must not abort the whole image: a screenshot can
115+
// hold several QR codes and one damaged code shouldn't discard the
116+
// accounts decoded from the others. Failures only become the result
117+
// when no payload in the image yielded anything.
118+
let mut payload_err: Option<String> = None;
119+
let mut any_parsed = false;
114120
for text in &texts {
115121
if migration::is_migration_uri(text) {
116122
any_otpauth = true;
117-
let m = migration::parse(text).map_err(|e| QrError::Payload(e.to_string()))?;
118-
import.entries.extend(m.entries);
119-
import.skipped.extend(m.skipped);
120-
import.batch = import.batch.or(m.batch);
123+
match migration::parse(text) {
124+
Ok(m) => {
125+
any_parsed = true;
126+
import.entries.extend(m.entries);
127+
import.skipped.extend(m.skipped);
128+
import.batch = import.batch.or(m.batch);
129+
}
130+
Err(e) => {
131+
payload_err.get_or_insert(e.to_string());
132+
import.skipped.push(migration::Skipped {
133+
label: "damaged QR code".into(),
134+
reason: "payload could not be parsed",
135+
});
136+
}
137+
}
121138
} else if text.trim_start().starts_with("otpauth://") {
122139
any_otpauth = true;
123-
let parsed: OtpAuth = keyroost_import::parse_otpauth(text.trim())
124-
.map_err(|e| QrError::Payload(e.to_string()))?;
125-
import.entries.push(BulkEntry {
126-
issuer: parsed.issuer,
127-
account: parsed.account,
128-
secret: parsed.secret,
129-
algorithm: parsed.algorithm,
130-
digits: parsed.digits,
131-
time_step: parsed.time_step,
132-
});
140+
match keyroost_import::parse_otpauth(text.trim()) {
141+
Ok(parsed) => {
142+
let parsed: OtpAuth = parsed;
143+
any_parsed = true;
144+
import.entries.push(BulkEntry {
145+
issuer: parsed.issuer,
146+
account: parsed.account,
147+
secret: parsed.secret,
148+
algorithm: parsed.algorithm,
149+
digits: parsed.digits,
150+
time_step: parsed.time_step,
151+
});
152+
}
153+
Err(e) => {
154+
payload_err.get_or_insert(e.to_string());
155+
import.skipped.push(migration::Skipped {
156+
label: "damaged QR code".into(),
157+
reason: "payload could not be parsed",
158+
});
159+
}
160+
}
133161
}
134162
}
135163
if !any_otpauth {
136164
return Err(QrError::NotOtpauth);
137165
}
166+
if !any_parsed {
167+
if let Some(e) = payload_err {
168+
// Every otpauth payload in the image was unreadable — surface
169+
// the first underlying error instead of an empty success.
170+
return Err(QrError::Payload(e));
171+
}
172+
}
138173
Ok(import)
139174
}
140175

crates/keyroostctl/src/main.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1351,7 +1351,22 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
13511351
for s in &import.skipped {
13521352
eprintln!("skipped {:?}: {}", s.label, s.reason);
13531353
}
1354+
// A GA export can span several QR images; a clean single-slot
1355+
// import of QR 1 must not read as "migration complete".
1356+
if let Some((i, n)) = import.batch {
1357+
eprintln!(
1358+
"note: this is QR {} of {} in the export — import the other images too",
1359+
i + 1,
1360+
n
1361+
);
1362+
}
13541363
match import.entries.len() {
1364+
0 => {
1365+
return Err(
1366+
"QR decoded, but no account could be imported (see skips above)"
1367+
.into(),
1368+
)
1369+
}
13551370
1 => import.entries.into_iter().next().unwrap(),
13561371
n => {
13571372
return Err(format!(

udev/70-keyroost-fido.rules

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,17 @@
1212
# instead of relying on group membership.
1313

1414
# Generic FIDO/U2F devices matched by HID usage page (0xF1D0) — works for any
15-
# vendor as long as their report descriptor follows the spec. systemd's hwdb
16-
# sets ID_SECURITY_TOKEN=1 from the usage page; matching on it (rather than
17-
# bInterfaceClass=="03", which is *every* HID device) keeps keyboard/mouse
18-
# hidraw nodes from becoming user-accessible — raw read access to a keyboard
19-
# node is a keylogger.
15+
# vendor as long as their report descriptor follows the spec. systemd-udevd's
16+
# fido_id builtin (60-fido-id.rules, systemd v244+) sets ID_SECURITY_TOKEN=1
17+
# from the usage page; matching on it (rather than bInterfaceClass=="03",
18+
# which is *every* HID device) keeps keyboard/mouse hidraw nodes from
19+
# becoming user-accessible — raw read access to a keyboard node is a
20+
# keylogger.
21+
#
22+
# Caveat: on non-systemd udev (eudev — Alpine, Devuan, some Gentoo) fido_id
23+
# never runs, so this generic rule matches nothing and only the explicit
24+
# vendor rules below apply. Keys from other vendors on such systems need
25+
# their own vendor-ID rule added here.
2026
KERNEL=="hidraw*", SUBSYSTEM=="hidraw", \
2127
ENV{ID_SECURITY_TOKEN}=="1", \
2228
TAG+="uaccess", GROUP="plugdev", MODE="0660"

0 commit comments

Comments
 (0)