diff --git a/crates/buzz-sdk/src/nip_oa.rs b/crates/buzz-sdk/src/nip_oa.rs index 2dff81bcf7a..f24e2584be2 100644 --- a/crates/buzz-sdk/src/nip_oa.rs +++ b/crates/buzz-sdk/src/nip_oa.rs @@ -33,7 +33,7 @@ use crate::SdkError; /// Empty string is valid. Non-empty must be `clause` or `clause&clause&...` /// where each clause is `kind=<0-65535>`, `created_at<<0-4294967295>`, or /// `created_at><0-4294967295>`. Canonical decimals only (no leading zeros). -fn validate_conditions(conditions: &str) -> Result<(), SdkError> { +pub fn validate_conditions(conditions: &str) -> Result<(), SdkError> { if conditions.is_empty() { return Ok(()); } diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 68b702431af..2b705ccc256 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1137,6 +1137,7 @@ dependencies = [ "rodio", "rubato", "rusqlite", + "rustix 1.1.4", "rustls", "security-framework 3.7.0", "serde", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index f41fa2d6e39..239bf7dd766 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -37,6 +37,7 @@ tauri-build = { version = "2", features = [] } [target.'cfg(unix)'.dependencies] libc = "0.2" ctrlc = { version = "3", features = ["termination"] } +rustix = { version = "=1.1.4", features = ["fs"] } [target.'cfg(target_os = "linux")'.dependencies] keyring = { version = "3.6.3", default-features = false, features = ["sync-secret-service", "vendored"], optional = true } diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 9cbb4444ab3..7abe44a0cfa 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -11,6 +11,7 @@ use nostr::{Keys, ToBech32}; use tauri::{AppHandle, Manager}; use tokio::sync::Mutex as AsyncMutex; +use crate::commands::OwnerAttestationPreviewStore; use crate::huddle::HuddleState; pub(crate) use crate::identity_storage::{IdentityStorage, RecoveryState, ResolvedIdentity}; use crate::managed_agents::config_bridge::SessionConfigCache; @@ -90,6 +91,9 @@ pub struct AppState { /// `keys` so readers (signing, get_identity, etc.) are not blocked during /// keyring I/O. pub identity_mutation: Mutex<()>, + /// One-use, backend-owned owner-attestation preview. Renderer-supplied + /// paths and hashes are never treated as signing authorization. + pub(crate) owner_attestation_previews: Mutex, /// Set when the boot-time Phase 2 reset attempted a wipe but verification /// failed. The sentinel is preserved so the next relaunch retries. All /// identity-dependent setup is skipped; the frontend shows a reset-failed @@ -211,6 +215,7 @@ pub fn build_app_state() -> AppState { shutdown_started: AtomicBool::new(false), managed_agent_runtime_transition: Mutex::new(()), identity_mutation: Mutex::new(()), + owner_attestation_previews: Mutex::new(OwnerAttestationPreviewStore::default()), managed_agents_store_lock: Mutex::new(()), channel_templates_store_lock: Mutex::new(()), managed_agent_processes: Mutex::new(HashMap::new()), diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 7cb2d8e3b83..39dc8fe1b1c 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -43,6 +43,7 @@ mod messages; mod notifications; mod observer_archive; mod os_idle; +mod owner_attestation; pub mod pairing; mod personas; mod prevent_sleep; @@ -104,6 +105,7 @@ pub use messages::*; pub use notifications::*; pub use observer_archive::*; pub use os_idle::*; +pub use owner_attestation::*; pub use pairing::*; pub use personas::*; pub use prevent_sleep::*; diff --git a/desktop/src-tauri/src/commands/owner_attestation.rs b/desktop/src-tauri/src/commands/owner_attestation.rs new file mode 100644 index 00000000000..a0d1229f6cf --- /dev/null +++ b/desktop/src-tauri/src/commands/owner_attestation.rs @@ -0,0 +1,997 @@ +use std::{ + ffi::{OsStr, OsString}, + fs::File, + io::{Read, Write}, + path::{Component, Path, PathBuf}, +}; + +use nostr::{ + hashes::{sha256::Hash as Sha256Hash, Hash}, + Keys, PublicKey, +}; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Manager}; +use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; + +#[cfg(unix)] +use rustix::{ + fs::{AtFlags, Mode, OFlags}, + io::Errno, +}; + +use crate::app_state::AppState; + +const REQUEST_SCHEMA: &str = "buzz.nip-oa-owner-attestation-request.v1"; +const REQUEST_FILE_NAME: &str = "OWNER_ATTESTATION_REQUEST.json"; +const TARGET_FILE_NAME: &str = "BUZZ_AUTH_TAG"; +const MAX_REQUEST_BYTES: u64 = 64 * 1024; +const MAX_CONFIRMATION_CONDITIONS_BYTES: usize = 512; +const MAX_CONFIRMATION_PATH_BYTES: usize = 1024; + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct OwnerAttestationRequest { + schema: String, + agent_pubkey: String, + conditions: String, + signing_preimage: String, + signing_hash_algorithm: String, + signature_algorithm: String, + result_tag_shape: [String; 4], + private_key_in_request: bool, + signed: bool, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OwnerAttestationPreview { + preview_id: String, + agent_pubkey: String, + owner_pubkey: String, + conditions: String, + result_path: String, +} + +#[cfg(unix)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct FileIdentity { + dev: u64, + ino: u64, + uid: u32, + gid: u32, + mode: u32, + links: u64, + len: u64, + mtime: i64, + mtime_nsec: i64, +} + +#[cfg(unix)] +impl FileIdentity { + fn from_metadata(metadata: &std::fs::Metadata) -> Self { + use std::os::unix::fs::MetadataExt; + + Self { + dev: metadata.dev(), + ino: metadata.ino(), + uid: metadata.uid(), + gid: metadata.gid(), + mode: metadata.mode() & 0o7777, + links: metadata.nlink(), + len: metadata.len(), + mtime: metadata.mtime(), + mtime_nsec: metadata.mtime_nsec(), + } + } + + fn same_directory(&self, other: &Self) -> bool { + self.dev == other.dev + && self.ino == other.ino + && self.uid == other.uid + && self.gid == other.gid + && self.mode == other.mode + } + + fn same_stable_file_identity(&self, other: &Self) -> bool { + self.dev == other.dev + && self.ino == other.ino + && self.uid == other.uid + && self.gid == other.gid + && self.mode == other.mode + && self.links == other.links + } +} + +#[cfg(unix)] +struct ValidatedRequest { + request: OwnerAttestationRequest, + request_path: PathBuf, + target_path: PathBuf, + request_sha256: String, + request_identity: FileIdentity, + parent_identity: FileIdentity, + agent_pubkey: PublicKey, +} + +#[cfg(unix)] +pub(crate) struct PreparedOwnerAttestation { + preview_id: String, + validated: ValidatedRequest, + owner_pubkey: PublicKey, +} + +#[cfg(unix)] +impl PreparedOwnerAttestation { + fn preview(&self) -> OwnerAttestationPreview { + OwnerAttestationPreview { + preview_id: self.preview_id.clone(), + agent_pubkey: self.validated.request.agent_pubkey.clone(), + owner_pubkey: self.owner_pubkey.to_hex(), + conditions: self.validated.request.conditions.clone(), + result_path: self.validated.target_path.display().to_string(), + } + } +} + +#[cfg(unix)] +#[derive(Default)] +pub(crate) struct OwnerAttestationPreviewStore { + current: Option, +} + +#[cfg(unix)] +impl OwnerAttestationPreviewStore { + fn clear(&mut self) { + self.current = None; + } + + fn replace(&mut self, prepared: PreparedOwnerAttestation) -> OwnerAttestationPreview { + let preview = prepared.preview(); + self.current = Some(prepared); + preview + } + + fn take(&mut self, preview_id: &str) -> Result { + let Some(current) = self.current.as_ref() else { + return Err("owner attestation preview is missing or already consumed; select the request again".into()); + }; + if current.preview_id != preview_id { + return Err("owner attestation preview is stale; select the request again".into()); + } + self.current + .take() + .ok_or_else(|| "owner attestation preview was already consumed".to_string()) + } +} + +#[cfg(not(unix))] +#[derive(Default)] +pub(crate) struct OwnerAttestationPreviewStore; + +#[cfg(unix)] +struct PinnedDirectory { + root: File, + directory: File, + path_components: Vec, + path: PathBuf, + identity: FileIdentity, +} + +#[cfg(unix)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TempLinkState { + NotCreated, + Preparing, + Linked, + CleanupAmbiguous, + TempUnlinked, + Committed, +} + +/// Open and validate an owner-attestation request selected by the user. +/// +/// This command is inspection-only. It does not sign, write, publish, create an +/// agent, or return any secret material. +#[tauri::command] +pub async fn select_owner_attestation_request( + app_handle: AppHandle, +) -> Result, String> { + #[cfg(not(unix))] + { + let _ = app_handle; + return Err("owner attestation requires Unix owner and mode enforcement".to_string()); + } + + #[cfg(unix)] + { + app_handle + .state::() + .owner_attestation_previews + .lock() + .map_err(|error| error.to_string())? + .clear(); + let (tx, rx) = tokio::sync::oneshot::channel(); + app_handle + .dialog() + .file() + .add_filter("Owner attestation request", &["json"]) + .pick_file(move |path| { + let _ = tx.send(path); + }); + + let selected = rx + .await + .map_err(|_| "request dialog cancelled".to_string())?; + let Some(file_path) = selected else { + return Ok(None); + }; + let request_path = file_path + .as_path() + .ok_or_else(|| "request dialog returned an invalid path".to_string())? + .to_path_buf(); + + let owner_pubkey = app_handle.state::().signing_keys()?.public_key(); + let prepared = + tokio::task::spawn_blocking(move || prepare_request(&request_path, &owner_pubkey)) + .await + .map_err(|error| format!("request inspection task failed: {error}"))??; + let preview = app_handle + .state::() + .owner_attestation_previews + .lock() + .map_err(|error| error.to_string())? + .replace(prepared); + Ok(Some(preview)) + } +} + +/// Sign one previously inspected request and atomically create its protected +/// result file. The auth tag and signature never cross the Rust IPC boundary. +#[tauri::command] +pub async fn sign_owner_attestation_request( + preview_id: String, + app_handle: AppHandle, +) -> Result<(), String> { + #[cfg(not(unix))] + { + let _ = (preview_id, app_handle); + return Err("owner attestation requires Unix owner and mode enforcement".to_string()); + } + + #[cfg(unix)] + { + tokio::task::spawn_blocking(move || { + let state = app_handle.state::(); + let prepared = state + .owner_attestation_previews + .lock() + .map_err(|error| error.to_string())? + .take(&preview_id)?; + let confirmation = confirmation_message(&prepared)?; + confirm_and_execute_prepared( + &prepared, + |_| { + app_handle + .dialog() + .message(confirmation) + .title("Sign this exact owner attestation?") + .kind(MessageDialogKind::Warning) + .buttons(MessageDialogButtons::OkCancelCustom( + "Sign once".to_string(), + "Cancel".to_string(), + )) + .blocking_show() + }, + |prepared| { + // Never hold the identity mutation lock while waiting for + // an unbounded native-dialog interaction. Re-acquire it + // only after confirmation and revalidate under the lock. + let _identity_guard = + state.identity_mutation.lock().map_err(|e| e.to_string())?; + let owner_keys = state.signing_keys()?; + sign_prepared_request(prepared, &owner_keys) + }, + ) + }) + .await + .map_err(|error| format!("owner attestation task failed: {error}"))? + } +} + +#[cfg(not(unix))] +fn inspect_request( + _request_path: &Path, + _owner_pubkey: &PublicKey, +) -> Result { + Err("owner attestation requires Unix owner and mode enforcement".to_string()) +} + +#[cfg(unix)] +fn inspect_request( + request_path: &Path, + owner_pubkey: &PublicKey, +) -> Result { + prepare_request(request_path, owner_pubkey).map(|prepared| prepared.preview()) +} + +#[cfg(unix)] +fn prepare_request( + request_path: &Path, + owner_pubkey: &PublicKey, +) -> Result { + let validated = load_and_validate_request(request_path)?; + if *owner_pubkey == validated.agent_pubkey { + return Err("owner and agent pubkeys must differ".to_string()); + } + + let prepared = PreparedOwnerAttestation { + preview_id: uuid::Uuid::new_v4().to_string(), + validated, + owner_pubkey: *owner_pubkey, + }; + // Fail inspection if the exact confirmation cannot be rendered as a + // bounded, unambiguous, printf-safe ASCII representation. + confirmation_message(&prepared)?; + Ok(prepared) +} + +#[cfg(unix)] +fn confirmation_message(prepared: &PreparedOwnerAttestation) -> Result { + let conditions = escape_confirmation_field( + &prepared.validated.request.conditions, + "conditions", + MAX_CONFIRMATION_CONDITIONS_BYTES, + )?; + let target_path = prepared + .validated + .target_path + .to_str() + .ok_or_else(|| "authorized result path must be valid UTF-8".to_string())?; + let target_path = escape_confirmation_field( + target_path, + "authorized result path", + MAX_CONFIRMATION_PATH_BYTES, + )?; + Ok(format!( + "Buzz Desktop will sign exactly this authorization and create BUZZ_AUTH_TAG once.\n\nAgent public key:\n{}\n\nDesktop owner public key:\n{}\n\nConditions (escaped ASCII):\n{}\n\nAuthorized result path (escaped ASCII):\n{}\n\nThe private key and signature stay inside Desktop. Nothing is published and no agent is created.", + prepared.validated.request.agent_pubkey, + prepared.owner_pubkey.to_hex(), + conditions, + target_path, + )) +} + +#[cfg(unix)] +fn escape_confirmation_field(value: &str, label: &str, max_bytes: usize) -> Result { + if value.len() > max_bytes { + return Err(format!( + "{label} is too long for native confirmation (maximum {max_bytes} UTF-8 bytes)" + )); + } + + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + if character.is_ascii_alphanumeric() + || matches!( + character, + '/' | '.' | '_' | '-' | ':' | '=' | '<' | '>' | '&' | ' ' + ) + { + escaped.push(character); + } else { + escaped.push_str(&format!(r"\u{{{:X}}}", character as u32)); + } + } + debug_assert!(!escaped.contains('%') && !escaped.contains('\0')); + Ok(escaped) +} + +#[cfg(unix)] +fn confirm_and_execute_prepared( + prepared: &PreparedOwnerAttestation, + confirm: C, + execute: E, +) -> Result<(), String> +where + C: FnOnce(&PreparedOwnerAttestation) -> bool, + E: FnOnce(&PreparedOwnerAttestation) -> Result<(), String>, +{ + validate_prepared_filesystem(prepared)?; + if !confirm(prepared) { + return Err( + "owner cancelled the native signing confirmation; select the request again".into(), + ); + } + execute(prepared) +} + +#[cfg(unix)] +fn validate_prepared_request( + prepared: &PreparedOwnerAttestation, + owner_keys: &Keys, +) -> Result<(), String> { + let owner_pubkey = owner_keys.public_key(); + if owner_pubkey != prepared.owner_pubkey { + return Err( + "Desktop owner identity changed after inspection; select the request again".into(), + ); + } + validate_prepared_filesystem(prepared) +} + +#[cfg(unix)] +fn validate_prepared_filesystem(prepared: &PreparedOwnerAttestation) -> Result<(), String> { + let custody = open_pinned_directory(&prepared.validated.request_path)?; + let current = load_and_validate_request_in(&custody, &prepared.validated.request_path)?; + if current.request_sha256 != prepared.validated.request_sha256 + || current.request_identity != prepared.validated.request_identity + || current.parent_identity != prepared.validated.parent_identity + || current.request != prepared.validated.request + || current.request_path != prepared.validated.request_path + || current.target_path != prepared.validated.target_path + { + return Err( + "request or custody directory no longer matches the inspected preview; select it again" + .into(), + ); + } + if prepared.owner_pubkey == current.agent_pubkey { + return Err("owner and agent pubkeys must differ".to_string()); + } + Ok(()) +} + +#[cfg(unix)] +fn sign_prepared_request( + prepared: &PreparedOwnerAttestation, + owner_keys: &Keys, +) -> Result<(), String> { + validate_prepared_request(prepared, owner_keys)?; + let custody = open_pinned_directory(&prepared.validated.request_path)?; + let first = load_and_validate_request_in(&custody, &prepared.validated.request_path)?; + if first.request_sha256 != prepared.validated.request_sha256 + || first.request_identity != prepared.validated.request_identity + || first.parent_identity != prepared.validated.parent_identity + || first.request != prepared.validated.request + { + return Err( + "request or custody directory no longer matches the inspected preview; select it again" + .into(), + ); + } + let owner_pubkey = owner_keys.public_key(); + + // Reuse the canonical NIP-OA primitive. The request conditions are passed + // verbatim; the primitive hashes the exact specified preimage and produces + // a BIP-340 Schnorr signature. + let auth_tag = buzz_sdk_pkg::nip_oa::compute_auth_tag( + owner_keys, + &first.agent_pubkey, + &first.request.conditions, + ) + .map_err(|error| format!("owner attestation validation failed: {error}"))?; + + verify_computed_tag(&auth_tag, &first, &owner_pubkey)?; + + // Re-open and re-validate immediately before the only external effect. + // Exact bytes, inode metadata, parent directory identity, owner identity, + // target absence must all remain bound to the preview. + let second = load_and_validate_request_in(&custody, &prepared.validated.request_path)?; + if second.request_sha256 != first.request_sha256 + || second.request_identity != first.request_identity + || second.parent_identity != first.parent_identity + || second.request != first.request + { + return Err( + "request or custody directory changed before commit; nothing was written".into(), + ); + } + if owner_keys.public_key() != owner_pubkey { + return Err("Desktop owner identity changed before commit; nothing was written".into()); + } + + atomic_create_secret(&custody, auth_tag.as_bytes())?; + Ok(()) +} + +#[cfg(unix)] +fn load_and_validate_request(request_path: &Path) -> Result { + let custody = open_pinned_directory(request_path)?; + load_and_validate_request_in(&custody, request_path) +} + +#[cfg(unix)] +fn open_pinned_directory(request_path: &Path) -> Result { + validate_normal_absolute_path(request_path, REQUEST_FILE_NAME)?; + let parent = request_path + .parent() + .ok_or_else(|| "request path has no parent directory".to_string())?; + + let mut path_components = Vec::new(); + for component in parent.components() { + match component { + Component::RootDir => {} + Component::Normal(value) => path_components.push(value.to_os_string()), + _ => return Err("custody path contains a non-normal component".into()), + } + } + + let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC; + let root = File::from( + rustix::fs::open("/", flags, Mode::empty()) + .map_err(|error| format!("open root directory for custody resolution: {error}"))?, + ); + let directory = open_directory_components(&root, &path_components)?; + let metadata = directory + .metadata() + .map_err(|error| format!("inspect pinned custody directory: {error}"))?; + if !metadata.is_dir() { + return Err("request directory must be a regular non-symlink directory".into()); + } + let identity = FileIdentity::from_metadata(&metadata); + if identity.mode != 0o700 { + return Err(format!( + "request directory mode must be 0700, got {:04o}", + identity.mode + )); + } + + Ok(PinnedDirectory { + root, + directory, + path_components, + path: parent.to_path_buf(), + identity, + }) +} + +#[cfg(unix)] +fn open_directory_components(root: &File, components: &[OsString]) -> Result { + let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC; + let mut current = File::from( + rustix::fs::openat(root, OsStr::new("."), flags, Mode::empty()) + .map_err(|error| format!("pin root custody directory: {error}"))?, + ); + for component in components { + current = File::from( + rustix::fs::openat(¤t, component.as_os_str(), flags, Mode::empty()).map_err( + |error| { + format!( + "open non-symlink custody path component {}: {error}", + component.to_string_lossy() + ) + }, + )?, + ); + } + Ok(current) +} + +#[cfg(unix)] +fn verify_current_path_binding(custody: &PinnedDirectory) -> Result<(), String> { + let current = open_directory_components(&custody.root, &custody.path_components)?; + let current_identity = FileIdentity::from_metadata( + ¤t + .metadata() + .map_err(|error| format!("reinspect custody path binding: {error}"))?, + ); + if !current_identity.same_directory(&custody.identity) { + return Err("custody directory path was renamed or replaced".into()); + } + Ok(()) +} + +#[cfg(unix)] +fn load_and_validate_request_in( + custody: &PinnedDirectory, + request_path: &Path, +) -> Result { + validate_normal_absolute_path(request_path, REQUEST_FILE_NAME)?; + if request_path.parent() != Some(custody.path.as_path()) { + return Err("request path diverges from the pinned custody directory".into()); + } + verify_current_path_binding(custody)?; + + let file = File::from( + rustix::fs::openat( + &custody.directory, + REQUEST_FILE_NAME, + OFlags::RDONLY | OFlags::NONBLOCK | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(|error| format!("open regular non-symlink owner attestation request: {error}"))?, + ); + let metadata = file + .metadata() + .map_err(|error| format!("inspect owner attestation request: {error}"))?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err("request must be a regular non-symlink file".into()); + } + let request_identity = FileIdentity::from_metadata(&metadata); + if request_identity.links != 1 { + return Err("request must have exactly one hard link".into()); + } + if request_identity.mode != 0o644 { + return Err(format!( + "request mode must be 0644, got {:04o}", + request_identity.mode + )); + } + if request_identity.uid != custody.identity.uid || request_identity.gid != custody.identity.gid + { + return Err("request owner and group must match its custody directory".into()); + } + if request_identity.len == 0 || request_identity.len > MAX_REQUEST_BYTES { + return Err(format!( + "request size must be between 1 and {MAX_REQUEST_BYTES} bytes" + )); + } + + let raw = read_exact_file( + file, + request_identity.len, + "owner attestation request", + false, + )?; + let request_sha256 = Sha256Hash::hash(&raw).to_string(); + let request: OwnerAttestationRequest = serde_json::from_slice(&raw) + .map_err(|error| format!("invalid owner attestation request JSON: {error}"))?; + + if request.schema != REQUEST_SCHEMA { + return Err(format!( + "unsupported owner attestation request schema: {}", + request.schema + )); + } + if request.private_key_in_request { + return Err("request must declare private_key_in_request=false".into()); + } + if request.signed { + return Err("request must be unsigned".into()); + } + if request.conditions.is_empty() { + return Err("owner attestation conditions must be non-empty".into()); + } + buzz_sdk_pkg::nip_oa::validate_conditions(&request.conditions) + .map_err(|error| format!("invalid owner attestation conditions: {error}"))?; + if request.signing_hash_algorithm != "SHA256" { + return Err("signing_hash_algorithm must be SHA256".into()); + } + if request.signature_algorithm != "BIP340_Schnorr_secp256k1" { + return Err("signature_algorithm must be BIP340_Schnorr_secp256k1".into()); + } + if request.agent_pubkey.len() != 64 || !is_lowercase_hex(&request.agent_pubkey) { + return Err("agent_pubkey must be exactly 64 lowercase hex characters".into()); + } + let agent_pubkey = PublicKey::from_hex(&request.agent_pubkey) + .map_err(|error| format!("invalid agent_pubkey: {error}"))?; + let expected_preimage = format!( + "nostr:agent-auth:{}:{}", + request.agent_pubkey, request.conditions + ); + if request.signing_preimage.as_bytes() != expected_preimage.as_bytes() { + return Err( + "signing_preimage does not byte-exactly bind agent_pubkey and conditions".into(), + ); + } + let expected_shape = [ + "auth".to_string(), + "OWNER_PUBLIC_KEY_HEX".to_string(), + request.conditions.clone(), + "OWNER_SIGNATURE_HEX".to_string(), + ]; + if request.result_tag_shape != expected_shape { + return Err("result_tag_shape does not byte-exactly bind conditions".into()); + } + + let target_path = custody.path.join(TARGET_FILE_NAME); + ensure_target_absent(custody)?; + + Ok(ValidatedRequest { + request, + request_path: request_path.to_path_buf(), + target_path, + request_sha256, + request_identity, + parent_identity: custody.identity, + agent_pubkey, + }) +} + +fn validate_normal_absolute_path(path: &Path, expected_name: &str) -> Result<(), String> { + if !path.is_absolute() { + return Err("path must be absolute".into()); + } + if path.to_str().is_none() { + return Err( + "path must be valid UTF-8 so native confirmation can display it exactly".into(), + ); + } + if path.file_name().and_then(|value| value.to_str()) != Some(expected_name) { + return Err(format!("path must end in {expected_name}")); + } + if path + .components() + .any(|component| matches!(component, Component::CurDir | Component::ParentDir)) + { + return Err("path must not contain '.' or '..' components".into()); + } + Ok(()) +} + +#[cfg(unix)] +fn ensure_target_absent(custody: &PinnedDirectory) -> Result<(), String> { + match rustix::fs::statat( + &custody.directory, + TARGET_FILE_NAME, + AtFlags::SYMLINK_NOFOLLOW, + ) { + Ok(_) => Err( + "BUZZ_AUTH_TAG target already exists or is a symlink; refusing to replace it".into(), + ), + Err(Errno::NOENT) => Ok(()), + Err(error) => Err(format!("inspect BUZZ_AUTH_TAG target: {error}")), + } +} + +fn is_lowercase_hex(value: &str) -> bool { + value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +#[cfg(unix)] +fn verify_computed_tag( + auth_tag: &str, + request: &ValidatedRequest, + owner_pubkey: &PublicKey, +) -> Result<(), String> { + let recovered = buzz_sdk_pkg::nip_oa::verify_auth_tag(auth_tag, &request.agent_pubkey) + .map_err(|error| format!("computed owner attestation did not verify: {error}"))?; + if recovered != *owner_pubkey { + return Err("computed owner attestation owner binding failed".into()); + } + let parts: Vec = serde_json::from_str(auth_tag) + .map_err(|error| format!("computed owner attestation shape failed: {error}"))?; + if parts.len() != 4 + || parts[0] != "auth" + || parts[1] != owner_pubkey.to_hex() + || parts[2].as_bytes() != request.request.conditions.as_bytes() + || parts[3].len() != 128 + || !is_lowercase_hex(&parts[3]) + { + return Err("computed owner attestation has an invalid protected tag shape".into()); + } + Ok(()) +} + +#[cfg(unix)] +trait AtomicFileOps { + fn after_temp_sync(&self, _custody: &PinnedDirectory, _temp_name: &str) -> Result<(), Errno> { + Ok(()) + } + + fn link_temp(&self, custody: &PinnedDirectory, temp_name: &str) -> Result<(), Errno>; + fn unlink_temp(&self, custody: &PinnedDirectory, temp_name: &str) -> Result<(), Errno>; + fn sync_directory(&self, custody: &PinnedDirectory) -> Result<(), Errno>; +} + +#[cfg(unix)] +struct RealAtomicFileOps; + +#[cfg(unix)] +impl AtomicFileOps for RealAtomicFileOps { + fn link_temp(&self, custody: &PinnedDirectory, temp_name: &str) -> Result<(), Errno> { + rustix::fs::linkat( + &custody.directory, + temp_name, + &custody.directory, + TARGET_FILE_NAME, + AtFlags::empty(), + ) + } + + fn unlink_temp(&self, custody: &PinnedDirectory, temp_name: &str) -> Result<(), Errno> { + rustix::fs::unlinkat(&custody.directory, temp_name, AtFlags::empty()) + } + + fn sync_directory(&self, custody: &PinnedDirectory) -> Result<(), Errno> { + rustix::fs::fsync(&custody.directory) + } +} + +#[cfg(unix)] +fn atomic_create_secret(custody: &PinnedDirectory, bytes: &[u8]) -> Result<(), String> { + atomic_create_secret_with_ops(custody, bytes, &RealAtomicFileOps) +} + +#[cfg(unix)] +fn atomic_create_secret_with_ops( + custody: &PinnedDirectory, + bytes: &[u8], + ops: &O, +) -> Result<(), String> { + verify_current_path_binding(custody)?; + ensure_target_absent(custody)?; + + let temp_name = format!(".{TARGET_FILE_NAME}.{}.tmp", uuid::Uuid::new_v4()); + let mut state = TempLinkState::NotCreated; + let operation = (|| { + let mut temp = File::from( + rustix::fs::openat( + &custody.directory, + temp_name.as_str(), + OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::RUSR | Mode::WUSR, + ) + .map_err(|error| format!("create protected temporary attestation file: {error}"))?, + ); + state = TempLinkState::Preparing; + + let temp_identity = + FileIdentity::from_metadata(&temp.metadata().map_err(|error| { + format!("inspect protected temporary attestation file: {error}") + })?); + if temp_identity.mode != 0o600 + || temp_identity.uid != custody.identity.uid + || temp_identity.gid != custody.identity.gid + || temp_identity.links != 1 + { + return Err( + "protected temporary attestation file failed owner/mode/link checks".into(), + ); + } + + temp.write_all(bytes) + .map_err(|error| format!("write protected temporary attestation file: {error}"))?; + temp.sync_all() + .map_err(|error| format!("sync protected temporary attestation file: {error}"))?; + ops.after_temp_sync(custody, &temp_name) + .map_err(|error| format!("post-sync temporary-file check failed: {error}"))?; + + let persisted_file = open_relative_file(custody, &temp_name, "temporary attestation file")?; + let persisted_identity = + FileIdentity::from_metadata(&persisted_file.metadata().map_err(|error| { + format!("reinspect protected temporary attestation file: {error}") + })?); + if !persisted_identity.same_stable_file_identity(&temp_identity) { + return Err( + "protected temporary attestation file identity changed before commit".into(), + ); + } + if persisted_identity.len != bytes.len() as u64 { + return Err( + "protected temporary attestation file length mismatched before commit".into(), + ); + } + let persisted = read_exact_file( + persisted_file, + bytes.len() as u64, + "temporary attestation file", + true, + )?; + if persisted.as_slice() != bytes { + return Err("protected temporary attestation file reread mismatch".into()); + } + + verify_current_path_binding(custody)?; + ensure_target_absent(custody)?; + ops.link_temp(custody, &temp_name).map_err(|error| { + if error == Errno::EXIST { + "BUZZ_AUTH_TAG target appeared before commit; refusing to replace it".to_string() + } else { + format!("atomically commit BUZZ_AUTH_TAG without replacement: {error}") + } + })?; + state = TempLinkState::Linked; + + if let Err(error) = ops.unlink_temp(custody, &temp_name) { + state = TempLinkState::CleanupAmbiguous; + return Err(format!( + "BUZZ_AUTH_TAG was linked but temporary-link cleanup failed: {error}; STOP and do not retry" + )); + } + state = TempLinkState::TempUnlinked; + drop(temp); + + ops.sync_directory(custody).map_err(|error| { + format!( + "BUZZ_AUTH_TAG was committed but custody-directory sync failed: {error}; STOP and do not retry" + ) + })?; + verify_current_path_binding(custody).map_err(|error| { + format!( + "BUZZ_AUTH_TAG was committed but custody path verification failed: {error}; STOP and do not retry" + ) + })?; + + let target = open_relative_file(custody, TARGET_FILE_NAME, "BUZZ_AUTH_TAG")?; + let target_metadata = target.metadata().map_err(|error| { + format!( + "BUZZ_AUTH_TAG was committed but metadata verification failed: {error}; STOP and do not retry" + ) + })?; + let target_identity = FileIdentity::from_metadata(&target_metadata); + if !target_metadata.is_file() + || target_metadata.file_type().is_symlink() + || target_identity.mode != 0o600 + || target_identity.uid != custody.identity.uid + || target_identity.gid != custody.identity.gid + || target_identity.links != 1 + { + return Err( + "BUZZ_AUTH_TAG was committed but failed regular-file/owner/mode/link verification; STOP and do not retry" + .into(), + ); + } + let target_bytes = read_exact_file(target, bytes.len() as u64, "BUZZ_AUTH_TAG", true)?; + if target_bytes.as_slice() != bytes { + return Err( + "BUZZ_AUTH_TAG was committed but reread mismatched; STOP and do not retry".into(), + ); + } + state = TempLinkState::Committed; + Ok(()) + })(); + + if state == TempLinkState::Preparing { + if let Err(operation_error) = &operation { + if let Err(cleanup_error) = ops.unlink_temp(custody, &temp_name) { + return Err(format!( + "{operation_error}; pre-commit temporary-file cleanup failed: {cleanup_error}; STOP and do not retry" + )); + } + state = TempLinkState::NotCreated; + } + } + + debug_assert!(matches!( + state, + TempLinkState::NotCreated + | TempLinkState::CleanupAmbiguous + | TempLinkState::TempUnlinked + | TempLinkState::Committed + )); + operation +} + +#[cfg(unix)] +fn open_relative_file(custody: &PinnedDirectory, name: &str, label: &str) -> Result { + rustix::fs::openat( + &custody.directory, + name, + OFlags::RDONLY | OFlags::NONBLOCK | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) + .map(File::from) + .map_err(|error| format!("open protected {label}: {error}; STOP and do not retry")) +} + +#[cfg(unix)] +fn read_exact_file( + mut file: File, + expected_len: u64, + label: &str, + stop_on_error: bool, +) -> Result, String> { + let stop = if stop_on_error { + "; STOP and do not retry" + } else { + "" + }; + let expected_len = usize::try_from(expected_len) + .map_err(|_| format!("{label} length does not fit memory limits{stop}"))?; + let mut bytes = vec![0; expected_len]; + file.read_exact(&mut bytes) + .map_err(|error| format!("read {label} at its inspected length: {error}{stop}"))?; + let mut extra = [0_u8; 1]; + match file.read(&mut extra) { + Ok(0) => Ok(bytes), + Ok(_) => Err(format!("{label} grew while it was read{stop}")), + Err(error) => Err(format!("check {label} length after read: {error}{stop}")), + } +} + +#[cfg(all(test, unix))] +#[path = "owner_attestation_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/commands/owner_attestation_tests.rs b/desktop/src-tauri/src/commands/owner_attestation_tests.rs new file mode 100644 index 00000000000..ca81833806f --- /dev/null +++ b/desktop/src-tauri/src/commands/owner_attestation_tests.rs @@ -0,0 +1,828 @@ +use super::*; +use std::{ + cell::Cell, + os::unix::fs::{MetadataExt, PermissionsExt}, +}; + +struct Fixture { + _dir: tempfile::TempDir, + request_path: PathBuf, + target_path: PathBuf, + owner_keys: Keys, + agent_keys: Keys, + conditions: String, +} + +impl Fixture { + fn new() -> Self { + let test_root = std::env::current_dir().expect("test working directory"); + let dir = tempfile::Builder::new() + .prefix("owner-attestation-test-") + .tempdir_in(test_root) + .expect("temp dir"); + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)) + .expect("restrict temp dir"); + let request_path = dir.path().join(REQUEST_FILE_NAME); + let target_path = dir.path().join(TARGET_FILE_NAME); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let conditions = "kind=9&created_at>1&created_at<4294967295".to_string(); + let fixture = Self { + _dir: dir, + request_path, + target_path, + owner_keys, + agent_keys, + conditions, + }; + fixture.write_request(None); + fixture + } + + fn request(&self) -> OwnerAttestationRequest { + let agent_pubkey = self.agent_keys.public_key().to_hex(); + OwnerAttestationRequest { + schema: REQUEST_SCHEMA.to_string(), + agent_pubkey: agent_pubkey.clone(), + conditions: self.conditions.clone(), + signing_preimage: format!("nostr:agent-auth:{agent_pubkey}:{}", self.conditions), + signing_hash_algorithm: "SHA256".to_string(), + signature_algorithm: "BIP340_Schnorr_secp256k1".to_string(), + result_tag_shape: [ + "auth".to_string(), + "OWNER_PUBLIC_KEY_HEX".to_string(), + self.conditions.clone(), + "OWNER_SIGNATURE_HEX".to_string(), + ], + private_key_in_request: false, + signed: false, + } + } + + fn write_request(&self, request: Option) { + let request = request.unwrap_or_else(|| self.request()); + let bytes = serde_json::to_vec_pretty(&request).expect("request JSON"); + std::fs::write(&self.request_path, bytes).expect("write request"); + std::fs::set_permissions(&self.request_path, std::fs::Permissions::from_mode(0o644)) + .expect("set request mode"); + } + + fn prepared(&self) -> PreparedOwnerAttestation { + prepare_request(&self.request_path, &self.owner_keys.public_key()).expect("prepare request") + } + + fn sign(&self, prepared: &PreparedOwnerAttestation) -> Result<(), String> { + sign_prepared_request(prepared, &self.owner_keys) + } +} + +#[test] +fn nonempty_conditions_sign_and_verify_with_atomic_owner_only_custody() { + let fixture = Fixture::new(); + let request_before = std::fs::read(&fixture.request_path).expect("request bytes"); + let request_meta_before = std::fs::metadata(&fixture.request_path).expect("request metadata"); + let prepared = fixture.prepared(); + let preview = prepared.preview(); + + fixture.sign(&prepared).expect("sign request"); + assert_ne!( + fixture.owner_keys.public_key(), + fixture.agent_keys.public_key() + ); + assert_eq!(preview.conditions, fixture.conditions); + assert_eq!( + std::fs::read(&fixture.request_path).unwrap(), + request_before + ); + let request_meta_after = std::fs::metadata(&fixture.request_path).unwrap(); + assert_eq!(request_meta_after.ino(), request_meta_before.ino()); + assert_eq!(request_meta_after.mtime(), request_meta_before.mtime()); + assert_eq!( + request_meta_after.mtime_nsec(), + request_meta_before.mtime_nsec() + ); + + let tag_json = std::fs::read_to_string(&fixture.target_path).expect("protected tag"); + let recovered = + buzz_sdk_pkg::nip_oa::verify_auth_tag(&tag_json, &fixture.agent_keys.public_key()) + .expect("BIP340 verify"); + assert_eq!(recovered, fixture.owner_keys.public_key()); + let parts: Vec = serde_json::from_str(&tag_json).expect("tag JSON"); + assert_eq!(parts.len(), 4); + assert_eq!(parts[0], "auth"); + assert_eq!(parts[2].as_bytes(), fixture.conditions.as_bytes()); + assert_eq!(parts[3].len(), 128); + + let target_meta = std::fs::symlink_metadata(&fixture.target_path).unwrap(); + let parent_meta = std::fs::metadata(fixture.target_path.parent().unwrap()).unwrap(); + assert!(target_meta.is_file()); + assert!(!target_meta.file_type().is_symlink()); + assert_eq!(target_meta.permissions().mode() & 0o7777, 0o600); + assert_eq!(target_meta.uid(), parent_meta.uid()); + assert_eq!(target_meta.gid(), parent_meta.gid()); + assert_eq!(target_meta.nlink(), 1); + let entries = std::fs::read_dir(fixture.target_path.parent().unwrap()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect::>(); + assert_eq!(entries.len(), 2, "no temporary or unrelated side effects"); +} + +#[test] +fn rejects_malformed_conditions_before_preview() { + for conditions in [ + "kind=01", + "kind=1%", + "kind=1\n", + "kind=1\0", + "kind=1\u{202e}", + ] { + let mut fixture = Fixture::new(); + fixture.conditions = conditions.to_string(); + fixture.write_request(None); + + let error = inspect_request(&fixture.request_path, &fixture.owner_keys.public_key()) + .expect_err("non-canonical conditions must fail inspection"); + + assert!(error.contains("condition"), "unexpected error: {error}"); + assert!(!fixture.target_path.exists()); + } + + let mut fixture = Fixture::new(); + fixture.conditions = std::iter::repeat_n("kind=1", 80) + .collect::>() + .join("&"); + fixture.write_request(None); + let error = inspect_request(&fixture.request_path, &fixture.owner_keys.public_key()) + .expect_err("oversized valid conditions must fail before preview"); + assert!(error.contains("too long"), "unexpected error: {error}"); +} + +#[test] +fn confirmation_fields_are_bounded_and_printf_safe() { + assert_eq!( + escape_confirmation_field("kind=1&created_at<100", "conditions", 128).unwrap(), + "kind=1&created_at<100" + ); + assert_eq!( + escape_confirmation_field("/tmp/100%/line\npath", "path", 128).unwrap(), + r"/tmp/100\u{25}/line\u{A}\u{1B}path" + ); + let error = escape_confirmation_field(&"a".repeat(129), "path", 128) + .expect_err("oversized confirmation field must fail"); + assert!(error.contains("too long"), "unexpected error: {error}"); +} + +#[test] +fn cancellation_prevents_the_execution_step() { + let fixture = Fixture::new(); + let prepared = fixture.prepared(); + let executed = Cell::new(false); + + let error = confirm_and_execute_prepared( + &prepared, + |_| false, + |_| { + executed.set(true); + Ok(()) + }, + ) + .expect_err("cancellation must abort"); + + assert!(error.contains("cancelled"), "unexpected error: {error}"); + assert!(!executed.get(), "execution must not run after cancellation"); + assert!(!fixture.target_path.exists()); +} + +#[test] +fn existing_target_is_rejected_without_modification() { + let fixture = Fixture::new(); + let prepared = fixture.prepared(); + std::fs::write(&fixture.target_path, b"preserve-me").unwrap(); + std::fs::set_permissions(&fixture.target_path, std::fs::Permissions::from_mode(0o600)).unwrap(); + + let error = fixture + .sign(&prepared) + .expect_err("existing target must fail"); + + assert!(error.contains("already exists")); + assert_eq!(std::fs::read(&fixture.target_path).unwrap(), b"preserve-me"); +} + +#[test] +fn symlink_target_is_rejected_without_following_it() { + use std::os::unix::fs::symlink; + + let fixture = Fixture::new(); + let outside = fixture.target_path.parent().unwrap().join("outside"); + std::fs::write(&outside, b"outside-preserved").unwrap(); + symlink(&outside, &fixture.target_path).unwrap(); + + let error = inspect_request(&fixture.request_path, &fixture.owner_keys.public_key()) + .expect_err("symlink target must fail"); + + assert!(error.contains("already exists or is a symlink")); + assert_eq!(std::fs::read(&outside).unwrap(), b"outside-preserved"); + assert!(std::fs::symlink_metadata(&fixture.target_path) + .unwrap() + .file_type() + .is_symlink()); +} + +#[test] +fn request_symlink_mode_and_link_count_are_rejected_without_output() { + use std::os::unix::fs::symlink; + + let symlink_fixture = Fixture::new(); + let real_request = symlink_fixture + .request_path + .parent() + .unwrap() + .join("request-real.json"); + std::fs::rename(&symlink_fixture.request_path, &real_request).unwrap(); + symlink(&real_request, &symlink_fixture.request_path).unwrap(); + assert!(inspect_request( + &symlink_fixture.request_path, + &symlink_fixture.owner_keys.public_key() + ) + .unwrap_err() + .contains("symlink")); + assert!(!symlink_fixture.target_path.exists()); + + let mode_fixture = Fixture::new(); + std::fs::set_permissions( + &mode_fixture.request_path, + std::fs::Permissions::from_mode(0o600), + ) + .unwrap(); + assert!(inspect_request( + &mode_fixture.request_path, + &mode_fixture.owner_keys.public_key() + ) + .unwrap_err() + .contains("0644")); + assert!(!mode_fixture.target_path.exists()); + + let link_fixture = Fixture::new(); + let second_link = link_fixture + .request_path + .parent() + .unwrap() + .join("request-second-link.json"); + std::fs::hard_link(&link_fixture.request_path, &second_link).unwrap(); + assert!(inspect_request( + &link_fixture.request_path, + &link_fixture.owner_keys.public_key() + ) + .unwrap_err() + .contains("exactly one hard link")); + assert!(!link_fixture.target_path.exists()); +} + +#[test] +fn invalid_conditions_and_owner_fail_without_output() { + let fixture = Fixture::new(); + let mut request = fixture.request(); + request.conditions.clear(); + request.signing_preimage = format!("nostr:agent-auth:{}:", request.agent_pubkey); + request.result_tag_shape[2].clear(); + fixture.write_request(Some(request)); + assert!( + inspect_request(&fixture.request_path, &fixture.owner_keys.public_key()) + .unwrap_err() + .contains("non-empty") + ); + assert!(!fixture.target_path.exists()); + + let mut request = fixture.request(); + request.agent_pubkey = fixture.owner_keys.public_key().to_hex(); + request.signing_preimage = format!( + "nostr:agent-auth:{}:{}", + request.agent_pubkey, request.conditions + ); + fixture.write_request(Some(request)); + assert!( + inspect_request(&fixture.request_path, &fixture.owner_keys.public_key()) + .unwrap_err() + .contains("must differ") + ); + assert!(!fixture.target_path.exists()); +} + +#[test] +fn stale_preview_fails_without_output() { + let fixture = Fixture::new(); + let prepared = fixture.prepared(); + let mut request = fixture.request(); + request.result_tag_shape[1] = "OWNER_PUBLIC_KEY_HEX_CHANGED".to_string(); + fixture.write_request(Some(request)); + let error = fixture + .sign(&prepared) + .expect_err("stale preview must fail"); + assert!(error.contains("changed after inspection") || error.contains("result_tag_shape")); + assert!(!fixture.target_path.exists()); +} + +#[test] +fn preview_binds_request_and_parent_identity_across_ipc_round_trip() { + let fixture = Fixture::new(); + let prepared = fixture.prepared(); + let request_bytes = std::fs::read(&fixture.request_path).unwrap(); + let original = fixture.request_path.parent().unwrap().to_path_buf(); + let moved = original.with_extension("moved-after-preview"); + + std::fs::rename(&original, &moved).unwrap(); + std::fs::create_dir(&original).unwrap(); + std::fs::set_permissions(&original, std::fs::Permissions::from_mode(0o700)).unwrap(); + std::fs::write(original.join(REQUEST_FILE_NAME), request_bytes).unwrap(); + std::fs::set_permissions( + original.join(REQUEST_FILE_NAME), + std::fs::Permissions::from_mode(0o644), + ) + .unwrap(); + + let error = fixture + .sign(&prepared) + .expect_err("byte-identical replacement must be rejected"); + assert!(error.contains("preview") || error.contains("renamed or replaced")); + assert!(!original.join(TARGET_FILE_NAME).exists()); + assert!(!moved.join(TARGET_FILE_NAME).exists()); + + std::fs::remove_dir_all(&original).unwrap(); + std::fs::rename(&moved, &original).unwrap(); +} + +#[test] +fn preview_store_consumes_the_exact_preview_once() { + let fixture = Fixture::new(); + let prepared = fixture.prepared(); + let preview = prepared.preview(); + let mut store = OwnerAttestationPreviewStore::default(); + store.replace(prepared); + + assert!(store.take("wrong-preview-id").is_err()); + let consumed = store.take(&preview.preview_id).expect("exact preview id"); + assert_eq!(consumed.preview_id, preview.preview_id); + assert!(store.take(&preview.preview_id).is_err()); +} + +#[test] +fn non_utf8_custody_path_is_rejected_before_preview() { + use std::os::unix::ffi::OsStringExt; + + let fixture = Fixture::new(); + let original = fixture.request_path.parent().unwrap().to_path_buf(); + let mut invalid_name = format!("cust-{}", uuid::Uuid::new_v4()).into_bytes(); + invalid_name.push(0x80); + let moved = original + .parent() + .unwrap() + .join(std::ffi::OsString::from_vec(invalid_name)); + std::fs::rename(&original, &moved).unwrap(); + let moved_request = moved.join(REQUEST_FILE_NAME); + + let error = inspect_request(&moved_request, &fixture.owner_keys.public_key()) + .expect_err("native confirmation must be able to display the exact path"); + assert!(error.contains("valid UTF-8")); + + std::fs::rename(&moved, &original).unwrap(); +} + +#[test] +fn fifo_named_like_request_is_rejected_without_blocking() { + use std::process::Command; + use std::sync::mpsc; + use std::time::Duration; + + let fixture = Fixture::new(); + std::fs::remove_file(&fixture.request_path).unwrap(); + let status = Command::new("mkfifo") + .arg(&fixture.request_path) + .status() + .expect("mkfifo command"); + assert!(status.success()); + std::fs::set_permissions( + &fixture.request_path, + std::fs::Permissions::from_mode(0o644), + ) + .unwrap(); + + let request_path = fixture.request_path.clone(); + let owner_pubkey = fixture.owner_keys.public_key(); + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + let _ = sender.send(inspect_request(&request_path, &owner_pubkey)); + }); + let error = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("FIFO inspection must return instead of blocking") + .expect_err("FIFO must be rejected"); + assert!(error.contains("regular non-symlink file")); +} + +#[test] +fn bounded_read_rejects_growth_beyond_inspected_length() { + let fixture = Fixture::new(); + let path = fixture.request_path.parent().unwrap().join("growing-file"); + std::fs::write(&path, b"four").unwrap(); + let file = File::open(path).unwrap(); + + let error = read_exact_file(file, 3, "growing file", false) + .expect_err("one extra byte must be detected without reading to EOF"); + assert!(error.contains("grew while it was read")); +} + +#[test] +fn existing_request_shape_is_accepted_and_derives_sibling_target() { + let fixture = Fixture::new(); + let existing = serde_json::json!({ + "schema": REQUEST_SCHEMA, + "agent_pubkey": fixture.agent_keys.public_key().to_hex(), + "conditions": fixture.conditions.clone(), + "signing_preimage": format!("nostr:agent-auth:{}:{}", fixture.agent_keys.public_key().to_hex(), fixture.conditions), + "signing_hash_algorithm": "SHA256", + "signature_algorithm": "BIP340_Schnorr_secp256k1", + "result_tag_shape": ["auth", "OWNER_PUBLIC_KEY_HEX", fixture.conditions, "OWNER_SIGNATURE_HEX"], + "private_key_in_request": false, + "signed": false + }); + std::fs::write( + &fixture.request_path, + serde_json::to_vec_pretty(&existing).unwrap(), + ) + .unwrap(); + std::fs::set_permissions( + &fixture.request_path, + std::fs::Permissions::from_mode(0o644), + ) + .unwrap(); + + let preview = inspect_request(&fixture.request_path, &fixture.owner_keys.public_key()) + .expect("existing request must be accepted"); + assert_eq!( + preview.result_path, + fixture.target_path.display().to_string() + ); + assert!(!fixture.target_path.exists()); +} + +struct CountingSuccessOps { + link_calls: Cell, + unlink_calls: Cell, + sync_calls: Cell, +} + +impl AtomicFileOps for CountingSuccessOps { + fn link_temp(&self, custody: &PinnedDirectory, temp_name: &str) -> Result<(), Errno> { + self.link_calls.set(self.link_calls.get() + 1); + RealAtomicFileOps.link_temp(custody, temp_name) + } + + fn unlink_temp(&self, custody: &PinnedDirectory, temp_name: &str) -> Result<(), Errno> { + self.unlink_calls.set(self.unlink_calls.get() + 1); + RealAtomicFileOps.unlink_temp(custody, temp_name) + } + + fn sync_directory(&self, custody: &PinnedDirectory) -> Result<(), Errno> { + self.sync_calls.set(self.sync_calls.get() + 1); + RealAtomicFileOps.sync_directory(custody) + } +} + +struct StableIdentityMutationOps { + link_calls: Cell, + unlink_calls: Cell, +} + +impl AtomicFileOps for StableIdentityMutationOps { + fn after_temp_sync(&self, custody: &PinnedDirectory, temp_name: &str) -> Result<(), Errno> { + std::fs::set_permissions( + custody.path.join(temp_name), + std::fs::Permissions::from_mode(0o400), + ) + .expect("mutate stable temp mode"); + Ok(()) + } + + fn link_temp(&self, custody: &PinnedDirectory, temp_name: &str) -> Result<(), Errno> { + self.link_calls.set(self.link_calls.get() + 1); + RealAtomicFileOps.link_temp(custody, temp_name) + } + + fn unlink_temp(&self, custody: &PinnedDirectory, temp_name: &str) -> Result<(), Errno> { + self.unlink_calls.set(self.unlink_calls.get() + 1); + RealAtomicFileOps.unlink_temp(custody, temp_name) + } + + fn sync_directory(&self, custody: &PinnedDirectory) -> Result<(), Errno> { + RealAtomicFileOps.sync_directory(custody) + } +} + +struct UnlinkFailureOps { + unlink_calls: Cell, + sync_calls: Cell, +} + +impl AtomicFileOps for UnlinkFailureOps { + fn link_temp(&self, custody: &PinnedDirectory, temp_name: &str) -> Result<(), Errno> { + RealAtomicFileOps.link_temp(custody, temp_name) + } + + fn unlink_temp(&self, _custody: &PinnedDirectory, _temp_name: &str) -> Result<(), Errno> { + self.unlink_calls.set(self.unlink_calls.get() + 1); + Err(Errno::IO) + } + + fn sync_directory(&self, _custody: &PinnedDirectory) -> Result<(), Errno> { + self.sync_calls.set(self.sync_calls.get() + 1); + Ok(()) + } +} + +struct SyncFailureOps { + unlink_calls: Cell, + sync_calls: Cell, +} + +struct TargetAppearsDuringLinkOps { + link_calls: Cell, + unlink_calls: Cell, +} + +impl AtomicFileOps for TargetAppearsDuringLinkOps { + fn link_temp(&self, custody: &PinnedDirectory, temp_name: &str) -> Result<(), Errno> { + self.link_calls.set(self.link_calls.get() + 1); + let mut target = File::from(rustix::fs::openat( + &custody.directory, + TARGET_FILE_NAME, + OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW, + Mode::RUSR | Mode::WUSR, + )?); + target.write_all(b"preserve-race-winner").unwrap(); + target.sync_all().unwrap(); + RealAtomicFileOps.link_temp(custody, temp_name) + } + + fn unlink_temp(&self, custody: &PinnedDirectory, temp_name: &str) -> Result<(), Errno> { + self.unlink_calls.set(self.unlink_calls.get() + 1); + RealAtomicFileOps.unlink_temp(custody, temp_name) + } + + fn sync_directory(&self, custody: &PinnedDirectory) -> Result<(), Errno> { + RealAtomicFileOps.sync_directory(custody) + } +} + +impl AtomicFileOps for SyncFailureOps { + fn link_temp(&self, custody: &PinnedDirectory, temp_name: &str) -> Result<(), Errno> { + RealAtomicFileOps.link_temp(custody, temp_name) + } + + fn unlink_temp(&self, custody: &PinnedDirectory, temp_name: &str) -> Result<(), Errno> { + self.unlink_calls.set(self.unlink_calls.get() + 1); + RealAtomicFileOps.unlink_temp(custody, temp_name) + } + + fn sync_directory(&self, _custody: &PinnedDirectory) -> Result<(), Errno> { + self.sync_calls.set(self.sync_calls.get() + 1); + Err(Errno::IO) + } +} + +struct RenameDuringLinkOps { + original: PathBuf, + moved: PathBuf, + link_calls: Cell, +} + +impl AtomicFileOps for RenameDuringLinkOps { + fn link_temp(&self, custody: &PinnedDirectory, temp_name: &str) -> Result<(), Errno> { + self.link_calls.set(self.link_calls.get() + 1); + std::fs::rename(&self.original, &self.moved).expect("rename custody during link"); + std::fs::create_dir(&self.original).expect("create replacement custody during link"); + std::fs::set_permissions(&self.original, std::fs::Permissions::from_mode(0o700)) + .expect("restrict replacement custody during link"); + RealAtomicFileOps.link_temp(custody, temp_name) + } + + fn unlink_temp(&self, custody: &PinnedDirectory, temp_name: &str) -> Result<(), Errno> { + RealAtomicFileOps.unlink_temp(custody, temp_name) + } + + fn sync_directory(&self, custody: &PinnedDirectory) -> Result<(), Errno> { + RealAtomicFileOps.sync_directory(custody) + } +} + +fn restore_replaced_custody(original: &Path, moved: &Path) { + std::fs::remove_dir(original).expect("remove replacement custody directory"); + std::fs::rename(moved, original).expect("restore original custody directory"); +} + +#[test] +fn valid_post_write_identity_and_length_reach_linkat_success() { + let fixture = Fixture::new(); + let custody = open_pinned_directory(&fixture.request_path).expect("pin custody directory"); + let ops = CountingSuccessOps { + link_calls: Cell::new(0), + unlink_calls: Cell::new(0), + sync_calls: Cell::new(0), + }; + + atomic_create_secret_with_ops(&custody, b"test-auth-tag", &ops) + .expect("valid write must reach linkat"); + + assert_eq!(ops.link_calls.get(), 1); + assert_eq!(ops.unlink_calls.get(), 1); + assert_eq!(ops.sync_calls.get(), 1); + assert_eq!( + std::fs::read(&fixture.target_path).unwrap(), + b"test-auth-tag" + ); +} + +#[test] +fn stable_temp_identity_mutation_fails_before_linkat() { + let fixture = Fixture::new(); + let custody = open_pinned_directory(&fixture.request_path).expect("pin custody directory"); + let ops = StableIdentityMutationOps { + link_calls: Cell::new(0), + unlink_calls: Cell::new(0), + }; + + let error = atomic_create_secret_with_ops(&custody, b"test-auth-tag", &ops) + .expect_err("stable identity mutation must fail"); + + assert!(error.contains("identity changed before commit")); + assert_eq!(ops.link_calls.get(), 0); + assert_eq!(ops.unlink_calls.get(), 1, "pre-commit cleanup runs once"); + assert!(!fixture.target_path.exists()); + assert_eq!( + std::fs::read_dir(fixture.request_path.parent().unwrap()) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| entry + .file_name() + .to_string_lossy() + .starts_with(".BUZZ_AUTH_TAG.")) + .count(), + 0 + ); +} + +#[test] +fn directory_replacement_before_commit_is_rejected_by_pinned_identity() { + let fixture = Fixture::new(); + let custody = open_pinned_directory(&fixture.request_path).expect("pin custody directory"); + let original = fixture.request_path.parent().unwrap().to_path_buf(); + let moved = original.with_extension("moved-before-commit"); + std::fs::rename(&original, &moved).unwrap(); + std::fs::create_dir(&original).unwrap(); + std::fs::set_permissions(&original, std::fs::Permissions::from_mode(0o700)).unwrap(); + + let error = atomic_create_secret(&custody, b"test-auth-tag") + .expect_err("replaced custody directory must fail"); + + assert!(error.contains("renamed or replaced")); + assert!(!moved.join(TARGET_FILE_NAME).exists()); + assert!(!original.join(TARGET_FILE_NAME).exists()); + restore_replaced_custody(&original, &moved); +} + +#[test] +fn directory_rename_during_link_is_detected_after_descriptor_relative_commit() { + let fixture = Fixture::new(); + let custody = open_pinned_directory(&fixture.request_path).expect("pin custody directory"); + let original = fixture.request_path.parent().unwrap().to_path_buf(); + let moved = original.with_extension("moved-during-link"); + let ops = RenameDuringLinkOps { + original: original.clone(), + moved: moved.clone(), + link_calls: Cell::new(0), + }; + + let error = atomic_create_secret_with_ops(&custody, b"test-auth-tag", &ops) + .expect_err("rename during commit must be detected"); + + assert_eq!(ops.link_calls.get(), 1); + assert!(error.contains("custody path verification failed")); + assert!(error.contains("STOP and do not retry")); + assert_eq!( + std::fs::read(moved.join(TARGET_FILE_NAME)).unwrap(), + b"test-auth-tag" + ); + assert!(!original.join(TARGET_FILE_NAME).exists()); + restore_replaced_custody(&original, &moved); +} + +#[test] +fn target_appearing_at_link_commit_wins_without_replacement() { + let fixture = Fixture::new(); + let custody = open_pinned_directory(&fixture.request_path).expect("pin custody directory"); + let ops = TargetAppearsDuringLinkOps { + link_calls: Cell::new(0), + unlink_calls: Cell::new(0), + }; + + let error = atomic_create_secret_with_ops(&custody, b"test-auth-tag", &ops) + .expect_err("concurrent target must win"); + + assert!(error.contains("target appeared before commit")); + assert_eq!(ops.link_calls.get(), 1); + assert_eq!( + ops.unlink_calls.get(), + 1, + "pre-commit temp cleanup runs once" + ); + assert_eq!( + std::fs::read(&fixture.target_path).unwrap(), + b"preserve-race-winner" + ); + assert_eq!( + std::fs::read_dir(fixture.request_path.parent().unwrap()) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| entry + .file_name() + .to_string_lossy() + .starts_with(".BUZZ_AUTH_TAG.")) + .count(), + 0 + ); +} + +#[test] +fn unlink_fault_is_visible_and_never_retried_by_cleanup() { + let fixture = Fixture::new(); + let custody = open_pinned_directory(&fixture.request_path).expect("pin custody directory"); + let ops = UnlinkFailureOps { + unlink_calls: Cell::new(0), + sync_calls: Cell::new(0), + }; + + let error = atomic_create_secret_with_ops(&custody, b"test-auth-tag", &ops) + .expect_err("unlink fault must stop"); + + assert!(error.contains("temporary-link cleanup failed")); + assert!(error.contains("STOP and do not retry")); + assert_eq!(ops.unlink_calls.get(), 1, "cleanup must not retry in Drop"); + assert_eq!( + ops.sync_calls.get(), + 0, + "no operation follows ambiguous cleanup" + ); + assert_eq!( + std::fs::read(&fixture.target_path).unwrap(), + b"test-auth-tag" + ); + assert_eq!( + std::fs::metadata(&fixture.target_path).unwrap().nlink(), + 2, + "failed cleanup leaves the two-link state visible" + ); + let temp_files = std::fs::read_dir(fixture.request_path.parent().unwrap()) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with(".BUZZ_AUTH_TAG.") + }) + .collect::>(); + assert_eq!(temp_files.len(), 1, "ambiguous temp link is left untouched"); +} + +#[test] +fn directory_fsync_fault_is_visible_after_single_cleanup() { + let fixture = Fixture::new(); + let custody = open_pinned_directory(&fixture.request_path).expect("pin custody directory"); + let ops = SyncFailureOps { + unlink_calls: Cell::new(0), + sync_calls: Cell::new(0), + }; + + let error = atomic_create_secret_with_ops(&custody, b"test-auth-tag", &ops) + .expect_err("directory fsync fault must stop"); + + assert!(error.contains("custody-directory sync failed")); + assert!(error.contains("STOP and do not retry")); + assert_eq!(ops.unlink_calls.get(), 1); + assert_eq!(ops.sync_calls.get(), 1); + assert_eq!( + std::fs::read(&fixture.target_path).unwrap(), + b"test-auth-tag" + ); + assert_eq!( + std::fs::read_dir(fixture.request_path.parent().unwrap()) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| entry + .file_name() + .to_string_lossy() + .starts_with(".BUZZ_AUTH_TAG.")) + .count(), + 0 + ); +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 2dde312d779..50d5417900f 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -555,6 +555,8 @@ pub fn run() { save_ncryptsec_copy, import_identity, persist_current_identity, + select_owner_attestation_request, + sign_owner_attestation_request, get_profile, update_profile, update_profile_at_relay, diff --git a/desktop/src/features/settings/ui/OwnerAttestationSettingsCard.test.mjs b/desktop/src/features/settings/ui/OwnerAttestationSettingsCard.test.mjs new file mode 100644 index 00000000000..6cd0c300028 --- /dev/null +++ b/desktop/src/features/settings/ui/OwnerAttestationSettingsCard.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const component = readFileSync( + new URL("./OwnerAttestationSettingsCard.tsx", import.meta.url), + "utf8", +); +const backend = readFileSync( + new URL( + "../../../../src-tauri/src/commands/owner_attestation.rs", + import.meta.url, + ), + "utf8", +); + +test("owner attestation signing crosses IPC only with a one-use preview id", () => { + assert.match(component, /previewId:\s*string/); + assert.doesNotMatch(component, /requestPath:\s*string/); + assert.doesNotMatch(component, /requestSha256:\s*string/); + + const invocation = component.match( + /invokeTauri\("sign_owner_attestation_request",\s*\{([\s\S]*?)\}\);/, + ); + assert.ok(invocation, "signing invocation is present"); + assert.match(invocation[1], /previewId:\s*preview\.previewId/); + assert.doesNotMatch(invocation[1], /requestPath|requestSha256|ownerPubkey/); +}); + +test("the native command consumes the preview and owns final confirmation", () => { + const signature = backend.match( + /pub async fn sign_owner_attestation_request\(([\s\S]*?)\) -> Result<\(\), String>/, + ); + assert.ok(signature, "native signing command is present"); + assert.match(signature[1], /preview_id:\s*String/); + assert.doesNotMatch( + signature[1], + /request_path|expected_request_sha256|expected_owner_pubkey/, + ); + assert.match( + backend, + /owner_attestation_previews[\s\S]*?\.take\(&preview_id\)\?/, + ); + assert.match(backend, /\.blocking_show\(\)/); + assert.match(backend, /MessageDialogButtons::OkCancelCustom/); +}); + +test("a signing attempt clears the consumed one-use preview", () => { + const signHandler = component.match( + /async function signRequest\(\) \{([\s\S]*?)\n {2}\}/, + )?.[1]; + assert.ok(signHandler, "signOnce handler should exist"); + + const clearIndex = signHandler.indexOf("setPreview(null)"); + const invokeIndex = signHandler.indexOf( + 'invokeTauri("sign_owner_attestation_request"', + ); + assert.ok(clearIndex >= 0, "signing must clear the one-use preview"); + assert.ok(invokeIndex >= 0, "signing command should still be invoked"); + assert.ok( + clearIndex < invokeIndex, + "preview must clear before the backend consumes its authorization", + ); +}); diff --git a/desktop/src/features/settings/ui/OwnerAttestationSettingsCard.tsx b/desktop/src/features/settings/ui/OwnerAttestationSettingsCard.tsx new file mode 100644 index 00000000000..80e5aa4205b --- /dev/null +++ b/desktop/src/features/settings/ui/OwnerAttestationSettingsCard.tsx @@ -0,0 +1,197 @@ +import { useState } from "react"; +import { FileKey2, Loader2 } from "lucide-react"; + +import { invokeTauri } from "@/shared/api/tauri"; +import { Button } from "@/shared/ui/button"; +import { + SettingsOptionGroup, + SettingsOptionGroupList, + SettingsOptionRow, +} from "./SettingsOptionGroup"; +import { SettingsSectionHeader } from "./SettingsSectionHeader"; + +type OwnerAttestationPreview = { + previewId: string; + agentPubkey: string; + ownerPubkey: string; + conditions: string; + resultPath: string; +}; + +function publicValue(value: string) { + return ( + + {value} + + ); +} + +export function OwnerAttestationSettingsCard() { + const [preview, setPreview] = useState(null); + const [completedPath, setCompletedPath] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function chooseRequest() { + setBusy(true); + setError(null); + setCompletedPath(null); + setPreview(null); + try { + const selected = await invokeTauri( + "select_owner_attestation_request", + ); + if (selected) setPreview(selected); + } catch (cause) { + setPreview(null); + setError( + cause instanceof Error + ? cause.message + : "The owner attestation request could not be inspected.", + ); + } finally { + setBusy(false); + } + } + + async function signRequest() { + if (!preview) return; + setBusy(true); + setError(null); + // The backend consumes this authorization exactly once, including on + // cancellation or validation failure. Never leave a stale retry surface. + setPreview(null); + try { + await invokeTauri("sign_owner_attestation_request", { + previewId: preview.previewId, + }); + setCompletedPath(preview.resultPath); + setPreview(null); + } catch (cause) { + setError( + cause instanceof Error + ? cause.message + : "The owner attestation was not written.", + ); + } finally { + setBusy(false); + } + } + + return ( +
+ + + + + +
+

OWNER_ATTESTATION_REQUEST.json

+

+ Selection reads public request data only. It does not sign or + write anything. +

+
+ +
+
+ + {preview ? ( + + +
+
+

+ Agent public key +

+ {publicValue(preview.agentPubkey)} +
+
+

+ Desktop owner public key +

+ {publicValue(preview.ownerPubkey)} +
+
+

+ Conditions +

+ {publicValue(preview.conditions)} +
+
+

+ Authorized result path +

+ {publicValue(preview.resultPath)} +
+
+
+ +

+ The owner private key stays inside Desktop. Nothing is published + and no agent is created. +

+ +
+
+ ) : null} + + {completedPath ? ( + + +
+

Protected tag written once

+

+ The signature and tag value were not returned to the UI. +

+ {publicValue(completedPath)} +
+
+
+ ) : null} +
+ + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 6b00fc8f74c..bd5f316a2e5 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -14,6 +14,7 @@ import { MonitorCog, Moon, ShieldAlert, + ShieldCheck, Smartphone, Smile, Sun, @@ -69,6 +70,7 @@ import { MobilePairingCard } from "./MobilePairingCard"; import { ModerationQueueCard } from "./ModerationQueueCard"; import { NotificationSettingsCard } from "./NotificationSettingsCard"; import { AgentsSettingsPanel } from "./AgentsSettingsPanel"; +import { OwnerAttestationSettingsCard } from "./OwnerAttestationSettingsCard"; import { HostedCommunitiesSettingsCard } from "./HostedCommunitiesSettingsCard"; import { SettingsOptionGroup, @@ -87,6 +89,7 @@ export type SettingsSection = | "voice" | "experimental" | "agents" + | "owner-attestation" | "channel-templates" | "compute" | "appearance" @@ -107,6 +110,7 @@ const SETTINGS_SECTION_VALUES: readonly SettingsSection[] = [ "voice", "experimental", "agents", + "owner-attestation", "channel-templates", "compute", "appearance", @@ -182,6 +186,11 @@ export const settingsSections: SettingsSectionDescriptor[] = [ icon: Bot, featureGate: "managed-agents", }, + { + value: "owner-attestation", + label: "Owner attestation", + icon: ShieldCheck, + }, { value: "channel-templates", label: "Channel templates", @@ -834,6 +843,8 @@ export function renderSettingsSection( return ; case "agents": return ; + case "owner-attestation": + return ; case "channel-templates": return ; case "compute": diff --git a/desktop/src/features/settings/ui/SettingsView.tsx b/desktop/src/features/settings/ui/SettingsView.tsx index 2f9b2c36a1a..6d48902a1be 100644 --- a/desktop/src/features/settings/ui/SettingsView.tsx +++ b/desktop/src/features/settings/ui/SettingsView.tsx @@ -71,7 +71,14 @@ const settingsNavGroups: Array<{ }, { label: "App", - sections: ["agents", "compute", "experimental", "mobile", "updates"], + sections: [ + "agents", + "owner-attestation", + "compute", + "experimental", + "mobile", + "updates", + ], }, ];