Sign NIP-98 auth for nostr.build image uploads

This commit is contained in:
Avi 2026-08-04 13:37:05 -05:00
commit 03f687717e
5 changed files with 177 additions and 2 deletions

View file

@ -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"

View file

@ -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 {

View file

@ -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<serde_json::Value, AppEr
Ok(json!(revealed))
}
Request::UploadAuth { url, http_method } => {
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,

View file

@ -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;

146
src/uploads.rs Normal file
View file

@ -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 <base64>`).
///
/// 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<String, AppError> {
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<Vec<String>> = 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());
}
}