Sign NIP-98 auth for nostr.build image uploads
This commit is contained in:
parent
8b701db929
commit
03f687717e
5 changed files with 177 additions and 2 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
16
src/ipc.rs
16
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<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,
|
||||
|
|
|
|||
|
|
@ -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
146
src/uploads.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue