From 26c379b5dc740735736b5ad0f9c9430eb0a55d9c Mon Sep 17 00:00:00 2001 From: BootIntel Agent Date: Sat, 26 Sep 2026 22:17:26 +0000 Subject: [PATCH] Release 0.5.0: detector parity, and terminal login Two things, and one of them changes output for existing consumers. Detector parity. This crate shipped 9 of the browser library's 14 detectors and folded two others into the wrong label, so the same log produced different JSON depending on which implementation read it. The parity test that now compares all 31 corpus logs also caught four further divergences nobody had asked about. The policy at the top of CHANGELOG.md calls an output-schema change MAJOR, and the crate is pre-1.0, so that is 0.5.0: anything parsing .findings[] by label should look before upgrading. The notable one is OpenSBI, which moves from Bootloader to Runtime firmware and previously lost to U-Boot in the precedence chain, so on a RISC-V log the version never reached output at all. `bootintel login`. Replaces pasting BOOTINTEL_API_KEY by hand, which the README had to teach `read -s` to do safely. The real reason it exists is that the server gates x-api-key at Pro, because "API access" there means CI and scripting, and applying that to interactive terminal use would have put the applicability lookup, the one path usable on a client device under an NDA, out of reach of the entry paid tier. The bic_ token resolves ahead of that gate server-side, so programmatic access stays Pro while a terminal login starts at Researcher. Verified against the running server rather than only in tests: the flow printed the link and code, polled, was approved from a browser session, and collected a bic_ token, which then returned 67 applicability findings with log_received false. Probe accounts and all test authorizations were removed from the database afterwards. Two things that live run changed. It survived a connection reset mid-poll, proving the retry, but printed a warning that was the only thing about a successful login that looked wrong; transient retries are now quiet, bounded at 20, and visible under -v. And clippy rejected a runtime assertion on a constant, correctly, so the poll-interval floor is a const assertion and the test exercises the actual computation instead of a tautology. Verified: 299 tests pass, clippy clean under -D warnings, rustfmt clean, cargo deny reports advisories, bans, licenses and sources all ok. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 28 ++- Cargo.lock | 4 +- Cargo.toml | 2 +- README.md | 3 + crates/cli/Cargo.toml | 2 +- crates/cli/src/api/endpoints.rs | 21 ++ crates/cli/src/cmd/login.rs | 336 ++++++++++++++++++++++++++++++++ crates/cli/src/cmd/mod.rs | 1 + crates/cli/src/main.rs | 6 + 9 files changed, 398 insertions(+), 5 deletions(-) create mode 100644 crates/cli/src/cmd/login.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d8d391..a5b1fa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to bootintel-cli are documented here. Format follows [Keep a ## [Unreleased] +## [0.5.0] — 2026-09-26 — detector parity, and terminal login + Detector-set parity with the browser detector library at bootintel.com/tools/fingerprint, which is the source of truth for the set: it is what the web tool and the legacy Node analyzer both run. This @@ -24,6 +26,29 @@ releasing is handled separately, and the 0.4.0 entry records how this project numbers a breaking change while pre-1.0. ### Added +- **`bootintel login`** — browser-approved terminal sign-in, replacing + `export BOOTINTEL_API_KEY=...` by hand. Prints a link with the code + already in it so the normal path is one click, and prints the code + separately so you can check it against what the page shows. That + comparison is the only thing stopping a malicious local process having + its own login approved, so the wording asks for it rather than just + offering a button. + + There is a second reason it exists. The server gates `x-api-key` auth at + the Pro plan, because "API access" there means CI and scripting. Applying + that to interactive terminal use would have put the applicability lookup, + the one path usable on a client device under an NDA, out of reach of the + entry paid tier. The `bic_` token this issues resolves ahead of the + API-key gate server-side, so programmatic access stays Pro while + `bootintel login` starts at Researcher. + + Shape is RFC 8628. Token lasts 90 days, is revocable from the account + page, and is stored via the existing config file so everything that + already reads `api_key` keeps working. Transient poll failures retry + quietly and are bounded: a long poll outliving keep-alive is routine, and + the first live run of this command proved it by surviving a connection + reset where the only thing that looked wrong was the warning. + - Five detectors, bringing the set to 14 and `bootintel version` to `client-side detectors: 14`: - **Runtime firmware** — OpenSBI, the RISC-V M-mode runtime. @@ -458,7 +483,8 @@ Initial release. All six subcommands live; five branch-based milestones (M1-M5) - PDF report download subcommand — server-side endpoint exists but no client-side wrapper yet. - Windows support — the Rust code compiles for Windows and the release workflow builds it, but install.sh doesn't handle Windows yet (`.ps1` installer is a follow-up). -[Unreleased]: https://github.com/bootintel/cli/compare/cli-v0.4.2...HEAD +[Unreleased]: https://github.com/bootintel/cli/compare/cli-v0.5.0...HEAD +[0.5.0]: https://github.com/bootintel/cli/releases/tag/cli-v0.5.0 [0.4.2]: https://github.com/bootintel/cli/releases/tag/cli-v0.4.2 [0.4.1]: https://github.com/bootintel/cli/releases/tag/cli-v0.4.1 [0.4.0]: https://github.com/bootintel/cli/releases/tag/cli-v0.4.0 diff --git a/Cargo.lock b/Cargo.lock index d6e5ec5..582cb6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -131,7 +131,7 @@ checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bootintel" -version = "0.4.2" +version = "0.5.0" dependencies = [ "anyhow", "arboard", @@ -154,7 +154,7 @@ dependencies = [ [[package]] name = "bootintel-detectors" -version = "0.4.2" +version = "0.5.0" dependencies = [ "regex", ] diff --git a/Cargo.toml b/Cargo.toml index e485b34..c56a5fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ members = ["crates/detectors", "crates/cli"] resolver = "2" [workspace.package] -version = "0.4.2" +version = "0.5.0" edition = "2021" rust-version = "1.90" license = "Apache-2.0" diff --git a/README.md b/README.md index b9ff8a5..144165c 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,9 @@ Client-side scan is always free and offline. Server-side integration (paid) laye bootintel scan --api --preview boot.log # authenticated full analysis — needs an API key: +bootintel login # browser-approved, no key to paste + +# Or, for CI and scripting (needs Pro): export BOOTINTEL_API_KEY=bik_... bootintel scan --api boot.log diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 96d3318..5cd1131 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -24,7 +24,7 @@ path = "src/main.rs" # version when packaging for crates.io, which rejects a bare path dep. # This is what publish = false was working around; publishing # bootintel-detectors first makes the workaround unnecessary. -bootintel-detectors = { path = "../detectors", version = "0.4.2" } +bootintel-detectors = { path = "../detectors", version = "0.5.0" } clap = { version = "4.5", features = ["derive", "wrap_help", "env"] } # Shell-completion emitters for `bootintel completions `. Version # tracks the clap major/minor (4.5). No default features — we only use diff --git a/crates/cli/src/api/endpoints.rs b/crates/cli/src/api/endpoints.rs index faa5f50..08b8da1 100644 --- a/crates/cli/src/api/endpoints.rs +++ b/crates/cli/src/api/endpoints.rs @@ -257,3 +257,24 @@ mod tests { }); } } + +/// Device-authorization endpoints for `bootintel login`. +/// +/// Deliberately not under `/analysis`: these are auth routes, and the +/// first two are reached with no credentials at all, which is the whole +/// point of the flow. +pub fn device_start_url(base: &str) -> String { + format!( + "{}{}/auth/device/start", + base.trim_end_matches('/'), + path_prefix() + ) +} + +pub fn device_token_url(base: &str) -> String { + format!( + "{}{}/auth/device/token", + base.trim_end_matches('/'), + path_prefix() + ) +} diff --git a/crates/cli/src/cmd/login.rs b/crates/cli/src/cmd/login.rs new file mode 100644 index 0000000..78390b4 --- /dev/null +++ b/crates/cli/src/cmd/login.rs @@ -0,0 +1,336 @@ +//! `bootintel login` — browser-approved terminal login. +//! +//! Replaces `export BOOTINTEL_API_KEY=...`. That worked, but it made the +//! user paste a long-lived credential by hand, and the README had to teach +//! `read -s` so it stayed out of shell history and `ps auxe`. +//! +//! There is a second, less obvious reason this exists. The server gates +//! `x-api-key` auth at the Pro plan, because "API access" there means CI and +//! scripting. Applying that to interactive terminal use would have put the +//! applicability lookup, the one path usable on a client device under an NDA, +//! out of reach of the entry paid tier that the target segment actually buys. +//! A `bic_` terminal token resolves ahead of the API-key gate server-side, so +//! programmatic access stays Pro while `bootintel login` starts at Researcher. +//! +//! Shape is RFC 8628. We print a link with the code already in it so the +//! normal path is one click, and print the code separately so the user can +//! check it against what the browser shows. That comparison is the only thing +//! standing between this flow and a malicious local process getting its own +//! login approved, so the wording asks for it explicitly. +//! +//! Exit codes follow the sysexits convention used by `whoami`: +//! 0 — authorized, token saved +//! 69 — EX_UNAVAILABLE — network failure +//! 73 — EX_CANTCREAT — authorized but the token could not be persisted +//! 77 — EX_NOPERM — the request expired or was refused + +use anyhow::{Context, Result}; +use clap::Args as ClapArgs; +use serde::Deserialize; +use std::io::{self, Write}; +use std::time::{Duration, Instant}; + +const EX_UNAVAILABLE: i32 = 69; +const EX_CANTCREAT: i32 = 73; +const EX_NOPERM: i32 = 77; + +/// Floor on the server-suggested poll interval. Stops a bad `interval` +/// turning the CLI into a hot loop against the auth endpoint. +const MIN_POLL_SECONDS: u64 = 2; +// Enforced at compile time rather than in a test: lowering this below 2 +// would hammer the auth endpoint, and a runtime assertion on a constant is +// something clippy rightly rejects. +const _: () = assert!(MIN_POLL_SECONDS >= 2); +/// Ceiling on total wait, independent of the server's `expires_in`, so a +/// forgotten terminal does not poll indefinitely. +const MAX_WAIT: Duration = Duration::from_secs(15 * 60); +/// Transient poll failures tolerated before giving up. A long poll outliving +/// keep-alive is normal; a network that is actually gone is not. +const MAX_TRANSIENT_RETRIES: u32 = 20; + +#[derive(ClapArgs, Debug)] +pub struct Args { + /// Override the API base URL (or $BOOTINTEL_API_BASE, or the + /// config file's api_base). + #[arg(long)] + pub api_base: Option, + + /// Label shown on the approval page, e.g. the machine name. + #[arg(long)] + pub label: Option, + + /// Print the URL instead of trying to open a browser. + #[arg(long)] + pub no_browser: bool, + + /// Print the token instead of saving it to the config file. + /// For throwaway shells and CI debugging; prefer the default. + #[arg(long)] + pub print_token: bool, +} + +#[derive(Deserialize)] +struct StartResponse { + device_code: String, + user_code: String, + verification_uri: String, + verification_uri_complete: Option, + #[serde(default)] + interval: u64, + #[serde(default)] + expires_in: u64, +} + +#[derive(Deserialize)] +struct TokenResponse { + #[serde(default)] + status: String, + #[serde(default)] + token: Option, + #[serde(default)] + expires_at: Option, +} + +#[derive(Deserialize)] +struct ErrBody { + #[serde(default)] + detail: Option, +} + +fn agent() -> ureq::Agent { + ureq::AgentBuilder::new() + .timeout_connect(Duration::from_secs(15)) + .timeout_read(Duration::from_secs(30)) + .build() +} + +fn detail_of(resp: ureq::Response, fallback: &str) -> String { + resp.into_json::() + .ok() + .and_then(|b| b.detail) + .unwrap_or_else(|| fallback.to_string()) +} + +pub fn run(args: Args) -> Result<()> { + let base = crate::config::resolve_api_base(args.api_base.as_deref()); + let code = login(&args, &base)?; + if code != 0 { + std::process::exit(code); + } + Ok(()) +} + +fn login(args: &Args, api_base: &str) -> Result { + let start_url = crate::api::endpoints::device_start_url(api_base); + let token_url = crate::api::endpoints::device_token_url(api_base); + let http = agent(); + + let body = ureq::json!({ "label": args.label }); + let start: StartResponse = match http.post(&start_url).send_json(body) { + Ok(r) => r + .into_json() + .context("malformed response from the login endpoint")?, + Err(ureq::Error::Status(code, resp)) => { + eprintln!( + "bootintel: could not start a login ({}): {}", + code, + detail_of(resp, "unexpected server response") + ); + return Ok(EX_UNAVAILABLE); + } + Err(ureq::Error::Transport(t)) => { + eprintln!("bootintel: could not reach {}: {}", api_base, t); + eprintln!( + " Local analysis needs no account: `bootintel scan ` works offline." + ); + return Ok(EX_UNAVAILABLE); + } + }; + + let link = start + .verification_uri_complete + .clone() + .unwrap_or_else(|| start.verification_uri.clone()); + + println!("Approve this terminal at:\n {link}\n"); + println!("Your code is {}", start.user_code); + println!("Check it matches the code the page shows before approving.\n"); + if start.expires_in > 0 { + println!("The code expires in {} minutes.", start.expires_in / 60); + } + io::stdout().flush().ok(); + + if !args.no_browser { + // Best effort. A headless box or an SSH session has no browser and + // that is fine: the link is already printed above. + let _ = open_in_browser(&link); + } + + let interval = Duration::from_secs(start.interval.max(MIN_POLL_SECONDS)); + let began = Instant::now(); + let mut transient: u32 = 0; + println!("Waiting for approval..."); + + loop { + if began.elapsed() > MAX_WAIT { + eprintln!("bootintel: gave up waiting. Run `bootintel login` again."); + return Ok(EX_NOPERM); + } + std::thread::sleep(interval); + + let payload = ureq::json!({ "device_code": start.device_code }); + match http.post(&token_url).send_json(payload) { + Ok(r) => { + let parsed: TokenResponse = r + .into_json() + .context("malformed response while collecting the token")?; + if parsed.status == "authorization_pending" { + continue; + } + let token = match parsed.token { + Some(t) if !t.is_empty() => t, + _ => { + eprintln!( + "bootintel: server reported '{}' with no token.", + parsed.status + ); + return Ok(EX_NOPERM); + } + }; + return finish(token, parsed.expires_at, args.print_token); + } + Err(ureq::Error::Status(code, resp)) => { + let detail = detail_of(resp, "login failed"); + // 410 expired, 409 already used, 404 unknown. None are + // retryable, so say which and stop rather than spinning. + eprintln!("bootintel: {detail} ({code})"); + return Ok(EX_NOPERM); + } + Err(ureq::Error::Transport(t)) => { + // A reset mid-wait is routine, not a problem: a long poll + // outlives keep-alive and the connection gets recycled. + // Observed on the very first live run of this command, where + // the retry worked and the warning was the only thing that + // looked wrong. So it is retried silently and only surfaced + // under -v, where someone is actually debugging. + transient += 1; + crate::vinfo!("login poll retry {transient}: {t}"); + if transient > MAX_TRANSIENT_RETRIES { + eprintln!("bootintel: lost contact with {api_base} while waiting ({t})."); + return Ok(EX_UNAVAILABLE); + } + continue; + } + } + } +} + +fn finish(token: String, expires_at: Option, print_only: bool) -> Result { + if print_only { + println!("{token}"); + return Ok(0); + } + match crate::config::set_key("api_key", &token) { + Ok(path) => { + let tail = token + .chars() + .rev() + .take(4) + .collect::() + .chars() + .rev() + .collect::(); + println!("Signed in. Token ...{tail} saved to {}", path.display()); + if let Some(exp) = expires_at { + println!("It expires {exp}. Run `bootintel login` again after that."); + } + println!("Revoke it any time from your account page."); + Ok(0) + } + Err(e) => { + // Authorized but unsaved. Hand the token over rather than + // stranding the user with a consumed, uncollectable login. + eprintln!("bootintel: signed in, but could not write the config file: {e:#}"); + eprintln!(" Set it for this shell instead:"); + eprintln!(" export BOOTINTEL_API_KEY={token}"); + Ok(EX_CANTCREAT) + } + } +} + +#[cfg(target_os = "macos")] +fn open_in_browser(url: &str) -> std::io::Result<()> { + std::process::Command::new("open") + .arg(url) + .status() + .map(|_| ()) +} + +#[cfg(target_os = "windows")] +fn open_in_browser(url: &str) -> std::io::Result<()> { + std::process::Command::new("cmd") + .args(["/C", "start", "", url]) + .status() + .map(|_| ()) +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn open_in_browser(url: &str) -> std::io::Result<()> { + std::process::Command::new("xdg-open") + .arg(url) + .status() + .map(|_| ()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Mirrors the interval computation in `login`. A server sending + /// `interval: 0`, or omitting it, must not produce a hot loop against + /// the auth endpoint. + fn effective_interval(server_suggested: u64) -> Duration { + Duration::from_secs(server_suggested.max(MIN_POLL_SECONDS)) + } + + #[test] + fn poll_interval_has_a_floor() { + assert_eq!(effective_interval(0), Duration::from_secs(MIN_POLL_SECONDS)); + assert_eq!(effective_interval(1), Duration::from_secs(MIN_POLL_SECONDS)); + assert_eq!(effective_interval(5), Duration::from_secs(5)); + } + + #[test] + fn exit_codes_are_distinct_and_sysexits_shaped() { + let codes = [EX_UNAVAILABLE, EX_CANTCREAT, EX_NOPERM]; + assert_eq!( + codes.len(), + codes.iter().collect::>().len() + ); + assert!(codes.iter().all(|c| (64..=78).contains(c))); + } + + #[test] + fn a_pending_status_is_not_treated_as_a_token() { + let body: TokenResponse = + serde_json::from_str(r#"{"status":"authorization_pending","interval":5}"#).unwrap(); + assert_eq!(body.status, "authorization_pending"); + assert!(body.token.is_none()); + } + + #[test] + fn the_complete_link_is_preferred_but_optional() { + let with: StartResponse = serde_json::from_str( + r#"{"device_code":"d","user_code":"AAAA-BBBB","verification_uri":"https://x/auth/device","verification_uri_complete":"https://x/auth/device?code=AAAA-BBBB","interval":5,"expires_in":600}"#, + ).unwrap(); + assert!(with.verification_uri_complete.unwrap().contains("code=")); + let without: StartResponse = serde_json::from_str( + r#"{"device_code":"d","user_code":"AAAA-BBBB","verification_uri":"https://x/auth/device"}"#, + ).unwrap(); + assert!(without.verification_uri_complete.is_none()); + assert_eq!( + without.interval, 0, + "absent interval falls back to the floor" + ); + } +} diff --git a/crates/cli/src/cmd/mod.rs b/crates/cli/src/cmd/mod.rs index 74b6bf2..4d32470 100644 --- a/crates/cli/src/cmd/mod.rs +++ b/crates/cli/src/cmd/mod.rs @@ -13,6 +13,7 @@ pub mod encode_share; pub mod export; pub mod history; pub mod init; +pub mod login; pub mod manpage; pub mod ports; pub mod replay; diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 59dc348..bb59e7a 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -161,6 +161,11 @@ enum Cmd { /// set / edit. Precedence at runtime is: CLI flag > env var > /// config file > built-in default. Config(cmd::config::Args), + /// Sign in from this terminal by approving it in a browser. + /// Replaces exporting BOOTINTEL_API_KEY by hand, and is the path + /// that reaches Researcher-tier features the API-key gate does not. + Login(cmd::login::Args), + /// Verify the current API key without running a scan. Prints the /// key's identity (email + tier + today's quota) or exits with /// a sysexits-style code (77 = unauthorized, 69 = network error). @@ -204,6 +209,7 @@ fn main() -> Result<()> { Cmd::Doctor(args) => cmd::doctor::run(args), Cmd::Completions(args) => cmd::completions::run(args), Cmd::Config(args) => cmd::config::run(args), + Cmd::Login(args) => cmd::login::run(args), Cmd::Whoami(args) => cmd::whoami::run(args), Cmd::History(args) => cmd::history::run(args), Cmd::Version(args) => cmd::version::run(args),