From 03f687717e6580e3cbc44d15e7a02ab8609e8022 Mon Sep 17 00:00:00 2001 From: Avi Date: Tue, 4 Aug 2026 13:37:05 -0500 Subject: [PATCH] Sign NIP-98 auth for nostr.build image uploads --- Cargo.toml | 2 +- frontend/electron/main.ts | 14 +++- src/ipc.rs | 16 +++++ src/lib.rs | 1 + src/uploads.rs | 146 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 src/uploads.rs diff --git a/Cargo.toml b/Cargo.toml index 5ed7221..0de3789 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -nostr-sdk = { version = "0.40", features = ["nip44"] } +nostr-sdk = { version = "0.40", features = ["nip44", "nip98"] } tokio = { version = "1", features = ["full"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/frontend/electron/main.ts b/frontend/electron/main.ts index 5f8df82..f4132a6 100644 --- a/frontend/electron/main.ts +++ b/frontend/electron/main.ts @@ -153,12 +153,24 @@ async function pickImage(): Promise<{ path: string; name: string; mime: string } /** Upload one image file to nostr.build and return the public URL + MIME type. */ async function uploadImage(filePath: string): Promise<{ url: string; mime: string }> { + const uploadUrl = 'https://nostr.build/api/v2/upload/files'; const data = readFileSync(filePath); const mime = mimeForPath(filePath); + + // nostr.build requires a NIP-98 auth token signed with the active profile's key. + const authEnvelope = (await backendRequest('upload_auth', { + url: uploadUrl, + http_method: 'POST', + })) as { status: string; data?: { authorization?: string }; message?: string }; + if (authEnvelope.status !== 'ok' || !authEnvelope.data?.authorization) { + throw new Error(authEnvelope.message ?? 'Could not authorize the upload.'); + } + const form = new FormData(); form.append('fileToUpload', new Blob([data], { type: mime }), path.basename(filePath) || 'image'); - const response = await fetch('https://nostr.build/api/v2/upload/files', { + const response = await fetch(uploadUrl, { method: 'POST', + headers: { authorization: authEnvelope.data.authorization }, body: form, }); const payload = (await response.json().catch(() => ({}))) as { diff --git a/src/ipc.rs b/src/ipc.rs index 9eff194..bafe9ba 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -80,6 +80,11 @@ pub enum Request { RevealSecretKey { npub: String, }, + /// Sign a NIP-98 auth event for the active profile, for uploading media. + UploadAuth { + url: String, + http_method: String, + }, } /// A reply envelope carrying either data or a safe user-facing error. @@ -282,6 +287,17 @@ async fn run(app: &mut App, request: Request) -> Result { + let authorization = crate::uploads::nip98_authorization( + &app.vault, + &url, + &http_method, + app.vault_key(), + ) + .await?; + Ok(json!({ "authorization": authorization })) + } + Request::SettingsUpdate { theme, confirm_before_publish, diff --git a/src/lib.rs b/src/lib.rs index 6124262..d3d4448 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ pub mod profiles; pub mod publish; pub mod relays; pub mod settings; +pub mod uploads; pub mod vault; pub use errors::AppError; diff --git a/src/uploads.rs b/src/uploads.rs new file mode 100644 index 0000000..fed2039 --- /dev/null +++ b/src/uploads.rs @@ -0,0 +1,146 @@ +use nostr_sdk::prelude::*; + +use crate::crypto::VaultKey; +use crate::errors::{AppError, ErrorKind}; +use crate::profiles; + +/// Sign a NIP-98 HTTP auth event for `url` with the active profile's key and +/// return the `Authorization` header value (`Nostr `). +/// +/// This is what image hosts like nostr.build require before accepting an +/// upload. Like publishing, it needs an unlocked vault when the vault is +/// password-protected. +pub async fn nip98_authorization( + vault: &crate::vault::Vault, + url: &str, + method: &str, + key: Option<&VaultKey>, +) -> Result { + let http_method = match method.to_ascii_uppercase().as_str() { + "GET" => HttpMethod::GET, + "POST" => HttpMethod::POST, + "PUT" => HttpMethod::PUT, + "PATCH" => HttpMethod::PATCH, + _ => { + return Err(AppError::simple( + ErrorKind::Config, + format!("Unsupported HTTP method for upload auth: {method}"), + )); + } + }; + + let parsed_url = Url::parse(url) + .map_err(|e| AppError::config(format!("The upload URL is not valid: {e}")))?; + + let secret_hex = profiles::resolve_active_secret_key(vault, key)?; + let secret_key = profiles::parse_secret_key(&secret_hex)?; + let keys = Keys::new(secret_key); + + let header = HttpData::new(parsed_url, http_method) + .to_authorization(&keys) + .await + .map_err(|e| AppError::sign_failed(format!("Could not sign the upload request: {e}")))?; + + Ok(header) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vault::Vault; + + fn vault_with_profile() -> Vault { + let mut vault = Vault::empty(); + crate::profiles::create_profile(&mut vault, "A".to_string(), None).unwrap(); + vault + } + + #[test] + fn missing_profile_errors() { + let vault = Vault::empty(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let err = runtime + .block_on(nip98_authorization( + &vault, + "https://nostr.build/api/v2/upload/files", + "POST", + None, + )) + .expect_err("no active profile must error"); + assert_eq!(err.kind(), ErrorKind::NoActiveProfile); + } + + #[test] + fn unsupported_method_errors() { + let vault = vault_with_profile(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let err = runtime + .block_on(nip98_authorization( + &vault, + "https://example.com/upload", + "DELETE", + None, + )) + .expect_err("unsupported method must error"); + assert_eq!(err.kind(), ErrorKind::Config); + } + + #[test] + fn locked_encrypted_vault_errors() { + let mut vault = vault_with_profile(); + vault.crypto = Some(crate::vault::VaultCrypto { + kdf: crate::vault::KdfParams { + algorithm: "argon2id".to_string(), + salt: "c2FsdA==".to_string(), + m_cost: 1, + t_cost: 1, + p_cost: 1, + }, + verifier: "dmVyaWZpZXI=".to_string(), + }); + vault.profiles[0].secret_key = "encrypted-blob".to_string(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let err = runtime + .block_on(nip98_authorization( + &vault, + "https://nostr.build/api/v2/upload/files", + "POST", + None, + )) + .expect_err("locked vault must error"); + assert_eq!(err.kind(), ErrorKind::VaultLocked); + } + + #[test] + fn signs_a_nip98_auth_header_for_the_active_profile() { + let vault = vault_with_profile(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let header = runtime + .block_on(nip98_authorization( + &vault, + "https://nostr.build/api/v2/upload/files", + "POST", + None, + )) + .expect("valid profile must sign"); + + assert!(header.starts_with("Nostr "), "expected a Nostr auth header"); + let encoded = header.trim_start_matches("Nostr ").trim(); + use base64::engine::general_purpose::STANDARD as B64; + use base64::Engine as _; + let raw = B64 + .decode(encoded) + .expect("the header payload must be base64"); + let event: Event = + serde_json::from_slice(&raw).expect("the header payload must be an event"); + + assert_eq!(event.kind, Kind::HttpAuth); + assert_eq!(event.content, ""); + let tags: Vec> = event.tags.iter().map(|t| t.as_slice().to_vec()).collect(); + assert!(tags + .iter() + .any(|t| t[0] == "u" && t[1] == "https://nostr.build/api/v2/upload/files")); + assert!(tags.iter().any(|t| t[0] == "method" && t[1] == "POST")); + assert!(event.verify().is_ok()); + } +}