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"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
nostr-sdk = { version = "0.40", features = ["nip44"] }
|
nostr-sdk = { version = "0.40", features = ["nip44", "nip98"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
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. */
|
/** Upload one image file to nostr.build and return the public URL + MIME type. */
|
||||||
async function uploadImage(filePath: string): Promise<{ url: string; mime: string }> {
|
async function uploadImage(filePath: string): Promise<{ url: string; mime: string }> {
|
||||||
|
const uploadUrl = 'https://nostr.build/api/v2/upload/files';
|
||||||
const data = readFileSync(filePath);
|
const data = readFileSync(filePath);
|
||||||
const mime = mimeForPath(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();
|
const form = new FormData();
|
||||||
form.append('fileToUpload', new Blob([data], { type: mime }), path.basename(filePath) || 'image');
|
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',
|
method: 'POST',
|
||||||
|
headers: { authorization: authEnvelope.data.authorization },
|
||||||
body: form,
|
body: form,
|
||||||
});
|
});
|
||||||
const payload = (await response.json().catch(() => ({}))) as {
|
const payload = (await response.json().catch(() => ({}))) as {
|
||||||
|
|
|
||||||
16
src/ipc.rs
16
src/ipc.rs
|
|
@ -80,6 +80,11 @@ pub enum Request {
|
||||||
RevealSecretKey {
|
RevealSecretKey {
|
||||||
npub: String,
|
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.
|
/// 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))
|
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 {
|
Request::SettingsUpdate {
|
||||||
theme,
|
theme,
|
||||||
confirm_before_publish,
|
confirm_before_publish,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ pub mod profiles;
|
||||||
pub mod publish;
|
pub mod publish;
|
||||||
pub mod relays;
|
pub mod relays;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
pub mod uploads;
|
||||||
pub mod vault;
|
pub mod vault;
|
||||||
|
|
||||||
pub use errors::AppError;
|
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