v0.8.0: dep prune, memtest introspection fix, concurrent uploads, x-api-key
Dependency cleanup (ponytail audit): - Drop 14 unused dependency declarations across 7 crates; quick-xml and x509-parser leave the tree entirely (SAML cert/XML work is handled by bergshamra + roxmltree). Fixes: - introspect: drop the over-broad "microsoft" UTF-16 bulk-scan marker that mislabeled Secure-Boot-signed non-Windows bootables (memtest86, signed BSDs, firmware tools) as Windows — the string lives in their MS-signed EFI loader's FAT long-filename entries. INTROSPECT_REV 3 -> 4 re-probes existing local ISOs on startup so the bogus label clears on upgrade. - upload: begin_upload now reclaims an abandoned <id>.partial instead of rejecting the re-upload with "already uploading". Robust against browser refresh, tab close, and dropped connections (the chunked protocol can't resume a dead session anyway). Features: - Storage upload: multi-file + concurrent. Each dropped/selected .iso gets its own progress row and uploads independently; a single page-leave guard plus a pagehide keepalive-abort replace the old shared singletons. - Operator API key (x-api-key): a persisted key authenticates /api/* exactly like an operator session, for Postman/scripts. New core ApiKeyStore (generated on first run, regenerable), accepted in require_auth alongside the session cookie, surfaced in Settings -> Advanced with copy + regenerate and a usage reference. GET /api/api-key + POST /api/api-key/regenerate. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
27703c437a
commit
1c262a6d61
@@ -27,13 +27,11 @@ tokio = { workspace = true, features = ["sync", "rt", "macros", "time"] }
|
||||
bcrypt.workspace = true
|
||||
|
||||
# v0.5.1: pure-Rust SAML 2.0 SP. bergshamra = XML-DSig verify + exclusive
|
||||
# c14n (no OpenSSL/C). roxmltree/quick-xml parse + build SAML XML;
|
||||
# x509-parser pulls the IdP signing cert out of metadata; flate2+base64
|
||||
# encode the HTTP-Redirect binding's SAMLRequest.
|
||||
# c14n (no OpenSSL/C), plus IdP signing-cert extraction from metadata.
|
||||
# roxmltree parses the SAML/metadata XML; flate2+base64 encode the
|
||||
# HTTP-Redirect binding's SAMLRequest.
|
||||
bergshamra.workspace = true
|
||||
roxmltree.workspace = true
|
||||
quick-xml.workspace = true
|
||||
x509-parser.workspace = true
|
||||
flate2.workspace = true
|
||||
base64.workspace = true
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Operator API key — a single persisted secret that authenticates
|
||||
//! programmatic `/api/*` callers (Postman, scripts, CI) via the
|
||||
//! `x-api-key` header, as an alternative to the browser session cookie.
|
||||
//!
|
||||
//! Generated on first load and persisted to `<work_dir>/api_key.json` so
|
||||
//! it survives restarts — an operator pastes it into their client once.
|
||||
//! Regenerable from Settings → Advanced; the previous key stops working
|
||||
//! the moment a new one is minted. Grants the same access as a logged-in
|
||||
//! operator (the middleware treats a valid key exactly like a session).
|
||||
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ApiKeyFile {
|
||||
key: String,
|
||||
}
|
||||
|
||||
/// Persisted operator API key. Cheap to clone (Arc-shared); contention is
|
||||
/// nil (read on every authenticated request, written only on regenerate).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApiKeyStore {
|
||||
path: Arc<PathBuf>,
|
||||
inner: Arc<RwLock<String>>,
|
||||
}
|
||||
|
||||
impl ApiKeyStore {
|
||||
/// Load the stored key, minting + persisting a fresh one on first run
|
||||
/// (or when the file is missing / corrupt / empty).
|
||||
#[must_use]
|
||||
pub fn load_or_init(work_dir: &std::path::Path) -> Self {
|
||||
let path = work_dir.join("api_key.json");
|
||||
let key = match std::fs::read_to_string(&path) {
|
||||
Ok(text) => serde_json::from_str::<ApiKeyFile>(&text)
|
||||
.map(|f| f.key)
|
||||
.ok()
|
||||
.filter(|k| !k.is_empty())
|
||||
.unwrap_or_else(generate_key),
|
||||
Err(_) => generate_key(),
|
||||
};
|
||||
let store = Self {
|
||||
path: Arc::new(path),
|
||||
inner: Arc::new(RwLock::new(key)),
|
||||
};
|
||||
// Land a first-run (or repaired) key on disk immediately so it's
|
||||
// stable across the very next restart.
|
||||
store.persist();
|
||||
store
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn current(&self) -> String {
|
||||
self.inner.read().clone()
|
||||
}
|
||||
|
||||
/// Constant-time comparison against the stored key. An empty candidate
|
||||
/// never matches, so a blank/absent header can't authenticate.
|
||||
#[must_use]
|
||||
pub fn verify(&self, candidate: &str) -> bool {
|
||||
if candidate.is_empty() {
|
||||
return false;
|
||||
}
|
||||
ct_eq(self.inner.read().as_bytes(), candidate.as_bytes())
|
||||
}
|
||||
|
||||
/// Mint a fresh key, persist it, and return it. The previous key is
|
||||
/// invalid the instant this returns.
|
||||
#[must_use]
|
||||
pub fn regenerate(&self) -> String {
|
||||
let key = generate_key();
|
||||
self.inner.write().clone_from(&key);
|
||||
self.persist();
|
||||
tracing::info!(target: "openpxe::auth", "operator API key regenerated");
|
||||
key
|
||||
}
|
||||
|
||||
fn persist(&self) {
|
||||
let body = match serde_json::to_vec_pretty(&ApiKeyFile {
|
||||
key: self.current(),
|
||||
}) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::warn!(target: "openpxe::auth", "serialize api_key.json: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(parent) = self.path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let tmp = self.path.with_extension("json.tmp");
|
||||
if let Err(e) = std::fs::write(&tmp, body) {
|
||||
tracing::warn!(target: "openpxe::auth", "write api_key.json tmp: {e}");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
|
||||
tracing::warn!(target: "openpxe::auth", "rename api_key.json: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 128 random bits as 32 lowercase hex chars — unambiguous to copy-paste
|
||||
/// into an `x-api-key` header. UUID v4 is already our CSPRNG-backed source
|
||||
/// for session ids, so no new dependency.
|
||||
fn generate_key() -> String {
|
||||
Uuid::new_v4().simple().to_string()
|
||||
}
|
||||
|
||||
/// Length-checked constant-time byte compare — keeps key verification from
|
||||
/// leaking the matched-prefix length via timing. A 128-bit random secret
|
||||
/// isn't practically timing-attackable over a network, but the check is
|
||||
/// four lines, so we keep it.
|
||||
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let mut diff = 0u8;
|
||||
for (x, y) in a.iter().zip(b.iter()) {
|
||||
diff |= x ^ y;
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn generates_persists_and_reloads() {
|
||||
let dir = tempdir().unwrap();
|
||||
let s = ApiKeyStore::load_or_init(dir.path());
|
||||
let k = s.current();
|
||||
assert_eq!(k.len(), 32, "32 hex chars = 128 bits");
|
||||
assert!(s.verify(&k));
|
||||
assert!(!s.verify("wrong"));
|
||||
assert!(!s.verify(""), "blank header must not authenticate");
|
||||
// Reload from disk → same key (survives restart).
|
||||
let s2 = ApiKeyStore::load_or_init(dir.path());
|
||||
assert_eq!(s2.current(), k);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regenerate_invalidates_old() {
|
||||
let dir = tempdir().unwrap();
|
||||
let s = ApiKeyStore::load_or_init(dir.path());
|
||||
let old = s.current();
|
||||
let new = s.regenerate();
|
||||
assert_ne!(old, new);
|
||||
assert!(s.verify(&new));
|
||||
assert!(!s.verify(&old), "old key must stop working");
|
||||
// Persisted: a reload sees the new key.
|
||||
let s2 = ApiKeyStore::load_or_init(dir.path());
|
||||
assert_eq!(s2.current(), new);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
//! runtime settings, and the Queued Deployment queue.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod api_key;
|
||||
pub mod arch;
|
||||
pub mod auth;
|
||||
pub mod boot_log;
|
||||
@@ -23,6 +24,7 @@ pub mod settings;
|
||||
pub mod sso;
|
||||
pub mod wol;
|
||||
|
||||
pub use api_key::ApiKeyStore;
|
||||
pub use arch::{ClientArch, DriverMode, FirmwareClass};
|
||||
pub use auth::{AdminAccount, AdminPublic, AdminStore};
|
||||
pub use boot_log::{BootEvent, BootLog};
|
||||
|
||||
Reference in New Issue
Block a user