1use std::time::{SystemTime, UNIX_EPOCH};
14
15use base64::engine::general_purpose::STANDARD as BASE64;
16use base64::Engine;
17use serde::Deserialize;
18use sha2::{Digest, Sha256};
19
20use crate::error::PodError;
21
22const HTTP_AUTH_KIND: u64 = 27235;
23const TIMESTAMP_TOLERANCE: u64 = 60;
24const MAX_EVENT_SIZE: usize = 64 * 1024;
25const NOSTR_PREFIX: &str = "Nostr ";
26
27#[derive(Debug, Clone, Deserialize)]
28pub struct Nip98Event {
29 pub id: String,
30 pub pubkey: String,
31 pub created_at: u64,
32 pub kind: u64,
33 pub tags: Vec<Vec<String>>,
34 pub content: String,
35 pub sig: String,
36}
37
38#[derive(Debug, Clone)]
39pub struct Nip98Verified {
40 pub pubkey: String,
41 pub url: String,
42 pub method: String,
43 pub payload_hash: Option<String>,
44 pub created_at: u64,
45}
46
47pub async fn verify(
52 header: &str,
53 url: &str,
54 method: &str,
55 body_hash: Option<&[u8]>,
56) -> Result<String, PodError> {
57 let now = SystemTime::now()
58 .duration_since(UNIX_EPOCH)
59 .map(|d| d.as_secs())
60 .unwrap_or(0);
61 verify_at(header, url, method, body_hash, now).map(|v| v.pubkey)
62}
63
64pub fn verify_at(
66 header: &str,
67 expected_url: &str,
68 expected_method: &str,
69 body: Option<&[u8]>,
70 now: u64,
71) -> Result<Nip98Verified, PodError> {
72 let token = header
73 .strip_prefix(NOSTR_PREFIX)
74 .ok_or_else(|| PodError::Nip98("missing 'Nostr ' prefix".into()))?
75 .trim();
76
77 if token.len() > MAX_EVENT_SIZE {
78 return Err(PodError::Nip98("token too large".into()));
79 }
80 let json_bytes = BASE64.decode(token)?;
81 if json_bytes.len() > MAX_EVENT_SIZE {
82 return Err(PodError::Nip98("decoded token too large".into()));
83 }
84 let event: Nip98Event = serde_json::from_slice(&json_bytes)?;
85
86 if event.kind != HTTP_AUTH_KIND {
87 return Err(PodError::Nip98(format!(
88 "wrong kind: expected {HTTP_AUTH_KIND}, got {}",
89 event.kind
90 )));
91 }
92 if event.pubkey.len() != 64 || hex::decode(&event.pubkey).is_err() {
93 return Err(PodError::Nip98("invalid pubkey".into()));
94 }
95 if now.abs_diff(event.created_at) > TIMESTAMP_TOLERANCE {
96 return Err(PodError::Nip98(format!(
97 "timestamp outside tolerance: event={}, now={now}",
98 event.created_at
99 )));
100 }
101
102 let token_url = get_tag(&event, "u")
103 .ok_or_else(|| PodError::Nip98("missing 'u' tag".into()))?;
104 if normalize_url(&token_url) != normalize_url(expected_url) {
105 return Err(PodError::Nip98(format!(
106 "URL mismatch: token={token_url}, expected={expected_url}"
107 )));
108 }
109
110 let token_method = get_tag(&event, "method")
111 .ok_or_else(|| PodError::Nip98("missing 'method' tag".into()))?;
112 if token_method.to_uppercase() != expected_method.to_uppercase() {
113 return Err(PodError::Nip98(format!(
114 "method mismatch: token={token_method}, expected={expected_method}"
115 )));
116 }
117
118 let payload_tag = get_tag(&event, "payload");
119 let verified_payload_hash = match body {
120 Some(b) if !b.is_empty() => {
121 let expected = payload_tag
122 .as_ref()
123 .ok_or_else(|| PodError::Nip98("body provided but no payload tag".into()))?;
124 let actual = hex::encode(Sha256::digest(b));
125 if expected.to_lowercase() != actual.to_lowercase() {
126 return Err(PodError::Nip98("payload hash mismatch".into()));
127 }
128 Some(expected.clone())
129 }
130 _ => payload_tag,
131 };
132
133 #[cfg(feature = "nip98-schnorr")]
136 {
137 verify_schnorr_signature(&event)?;
138 }
139
140 Ok(Nip98Verified {
141 pubkey: event.pubkey,
142 url: token_url,
143 method: token_method,
144 payload_hash: verified_payload_hash,
145 created_at: event.created_at,
146 })
147}
148
149pub fn compute_event_id(event: &Nip98Event) -> String {
153 let canonical = serde_json::json!([
154 0,
155 event.pubkey,
156 event.created_at,
157 event.kind,
158 event.tags,
159 event.content,
160 ]);
161 let serialized = serde_json::to_string(&canonical).unwrap_or_default();
162 hex::encode(Sha256::digest(serialized.as_bytes()))
163}
164
165#[cfg(feature = "nip98-schnorr")]
172pub fn verify_schnorr_signature(event: &Nip98Event) -> Result<(), PodError> {
173 use k256::schnorr::{signature::Verifier, Signature, VerifyingKey};
174
175 let computed_id = compute_event_id(event);
176 if computed_id.to_lowercase() != event.id.to_lowercase() {
177 return Err(PodError::Nip98(format!(
178 "event id mismatch: computed={computed_id}, claimed={}",
179 event.id
180 )));
181 }
182 let pub_bytes = hex::decode(&event.pubkey)
183 .map_err(|e| PodError::Nip98(format!("pubkey hex decode: {e}")))?;
184 let sig_bytes = hex::decode(&event.sig)
185 .map_err(|e| PodError::Nip98(format!("sig hex decode: {e}")))?;
186 if sig_bytes.len() != 64 {
187 return Err(PodError::Nip98(format!(
188 "sig wrong length: {}",
189 sig_bytes.len()
190 )));
191 }
192 let id_bytes = hex::decode(&computed_id)
193 .map_err(|e| PodError::Nip98(format!("id hex decode: {e}")))?;
194
195 let vk = VerifyingKey::from_bytes(&pub_bytes)
196 .map_err(|e| PodError::Nip98(format!("pubkey parse: {e}")))?;
197 let sig = Signature::try_from(sig_bytes.as_slice())
198 .map_err(|e| PodError::Nip98(format!("sig parse: {e}")))?;
199 vk.verify(&id_bytes, &sig)
200 .map_err(|e| PodError::Nip98(format!("schnorr verify: {e}")))?;
201 Ok(())
202}
203
204#[cfg(not(feature = "nip98-schnorr"))]
206pub fn verify_schnorr_signature(_event: &Nip98Event) -> Result<(), PodError> {
207 Err(PodError::Unsupported(
208 "nip98-schnorr feature not enabled".into(),
209 ))
210}
211
212fn get_tag(event: &Nip98Event, name: &str) -> Option<String> {
213 event
214 .tags
215 .iter()
216 .find(|t| t.first().map(|s| s.as_str()) == Some(name))
217 .and_then(|t| t.get(1).cloned())
218}
219
220fn normalize_url(u: &str) -> &str {
221 u.trim_end_matches('/')
222}
223
224pub fn authorization_header(token_b64: &str) -> String {
225 format!("{NOSTR_PREFIX}{token_b64}")
226}
227
228use crate::auth::self_signed::{
239 ProofEnvelope, SelfSignedError, SelfSignedVerifier, VerifiedSubject,
240};
241
242#[derive(Debug, Default, Clone, Copy)]
248pub struct Nip98Verifier;
249
250#[async_trait::async_trait]
251impl SelfSignedVerifier for Nip98Verifier {
252 async fn verify(
253 &self,
254 envelope: &ProofEnvelope<'_>,
255 ) -> Result<Option<VerifiedSubject>, SelfSignedError> {
256 let looks_like_header = envelope.proof.starts_with(NOSTR_PREFIX);
260 let header = if looks_like_header {
261 envelope.proof.to_string()
262 } else {
263 format!("{NOSTR_PREFIX}{}", envelope.proof)
264 };
265
266 match verify_at(&header, envelope.uri, envelope.method, None, envelope.now_unix) {
267 Ok(v) => Ok(Some(VerifiedSubject {
268 did: format!("urn:nip98:{}", v.pubkey),
269 verification_method: format!("urn:nip98:{}#key-0", v.pubkey),
270 })),
271 Err(crate::error::PodError::Nip98(msg)) if looks_like_header => {
275 if msg.contains("timestamp") {
276 Err(SelfSignedError::OutOfTimeWindow(msg))
277 } else if msg.contains("URL mismatch") || msg.contains("method mismatch") {
278 Err(SelfSignedError::ScopeMismatch(msg))
279 } else if msg.contains("schnorr") || msg.contains("id mismatch") {
280 Err(SelfSignedError::InvalidSignature(msg))
281 } else {
282 Err(SelfSignedError::Malformed(msg))
283 }
284 }
285 Err(_) if !looks_like_header => Ok(None),
289 Err(e) => Err(SelfSignedError::Malformed(e.to_string())),
290 }
291 }
292
293 fn name(&self) -> &'static str {
294 "nip98"
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 fn encode_event(event: &serde_json::Value) -> String {
303 BASE64.encode(serde_json::to_string(event).unwrap().as_bytes())
304 }
305
306 #[cfg(feature = "nip98-schnorr")]
310 fn test_signing_key() -> (k256::schnorr::SigningKey, String) {
311 let seed = [0x42u8; 32];
314 let sk = k256::schnorr::SigningKey::from_bytes(&seed)
315 .expect("seed produces valid Schnorr signing key");
316 let pubkey_hex = hex::encode(sk.verifying_key().to_bytes());
317 (sk, pubkey_hex)
318 }
319
320 #[cfg(not(feature = "nip98-schnorr"))]
321 fn test_pubkey() -> String {
322 "a".repeat(64)
323 }
324
325 #[cfg(feature = "nip98-schnorr")]
326 fn test_pubkey() -> String {
327 test_signing_key().1
328 }
329
330 fn valid_event(url: &str, method: &str, ts: u64, body: Option<&[u8]>) -> serde_json::Value {
336 let mut tags = vec![
337 vec!["u".to_string(), url.to_string()],
338 vec!["method".to_string(), method.to_string()],
339 ];
340 if let Some(b) = body {
341 tags.push(vec!["payload".to_string(), hex::encode(Sha256::digest(b))]);
342 }
343
344 let pubkey = test_pubkey();
345 let kind = 27235u64;
346 let content = String::new();
347
348 let skeleton = Nip98Event {
351 id: String::new(),
352 pubkey: pubkey.clone(),
353 created_at: ts,
354 kind,
355 tags: tags.clone(),
356 content: content.clone(),
357 sig: String::new(),
358 };
359 let id = compute_event_id(&skeleton);
360
361 let sig = {
362 #[cfg(feature = "nip98-schnorr")]
363 {
364 use k256::schnorr::signature::Signer;
371 let (sk, _) = test_signing_key();
372 let id_bytes: Vec<u8> = hex::decode(&id).expect("id is valid hex");
373 let signature: k256::schnorr::Signature =
374 sk.sign(&id_bytes);
375 hex::encode(signature.to_bytes())
376 }
377 #[cfg(not(feature = "nip98-schnorr"))]
378 {
379 "0".repeat(128)
380 }
381 };
382
383 serde_json::json!({
384 "id": id,
385 "pubkey": pubkey,
386 "created_at": ts,
387 "kind": kind,
388 "tags": tags,
389 "content": content,
390 "sig": sig,
391 })
392 }
393
394 #[test]
395 fn rejects_missing_prefix() {
396 let err = verify_at("Bearer xyz", "https://a/b", "GET", None, 0).unwrap_err();
397 assert!(matches!(err, PodError::Nip98(_)));
398 }
399
400 #[test]
401 fn accepts_well_formed_event_no_body() {
402 let ts = 1_700_000_000u64;
403 let ev = valid_event("https://api.example.com/x", "GET", ts, None);
404 let hdr = authorization_header(&encode_event(&ev));
405 let r = verify_at(&hdr, "https://api.example.com/x", "GET", None, ts).unwrap();
406 assert_eq!(r.pubkey, test_pubkey());
407 assert_eq!(r.url, "https://api.example.com/x");
408 }
409
410 #[test]
411 fn accepts_trailing_slash_variation() {
412 let ts = 1_700_000_000u64;
413 let ev = valid_event("https://api.example.com/x/", "GET", ts, None);
414 let hdr = authorization_header(&encode_event(&ev));
415 verify_at(&hdr, "https://api.example.com/x", "GET", None, ts).unwrap();
416 }
417
418 #[test]
419 fn rejects_url_mismatch() {
420 let ts = 1_700_000_000u64;
421 let ev = valid_event("https://good/x", "GET", ts, None);
422 let hdr = authorization_header(&encode_event(&ev));
423 let err = verify_at(&hdr, "https://evil/x", "GET", None, ts).unwrap_err();
424 assert!(matches!(err, PodError::Nip98(_)));
425 }
426
427 #[test]
428 fn rejects_payload_mismatch() {
429 let ts = 1_700_000_000u64;
430 let ev = valid_event("https://a/b", "POST", ts, Some(b"original"));
431 let hdr = authorization_header(&encode_event(&ev));
432 let err = verify_at(&hdr, "https://a/b", "POST", Some(b"tampered"), ts).unwrap_err();
433 assert!(matches!(err, PodError::Nip98(_)));
434 }
435
436 #[test]
437 fn rejects_body_without_payload_tag() {
438 let ts = 1_700_000_000u64;
439 let ev = valid_event("https://a/b", "POST", ts, None);
440 let hdr = authorization_header(&encode_event(&ev));
441 let err = verify_at(&hdr, "https://a/b", "POST", Some(b"sneaky"), ts).unwrap_err();
442 assert!(matches!(err, PodError::Nip98(_)));
443 }
444
445 #[test]
446 fn rejects_expired_timestamp() {
447 let ts = 1_700_000_000u64;
448 let ev = valid_event("https://a/b", "GET", ts, None);
449 let hdr = authorization_header(&encode_event(&ev));
450 let err = verify_at(&hdr, "https://a/b", "GET", None, ts + 120).unwrap_err();
451 assert!(matches!(err, PodError::Nip98(_)));
452 }
453
454 #[test]
455 fn rejects_wrong_kind() {
456 let ts = 1_700_000_000u64;
457 let mut ev = valid_event("https://a/b", "GET", ts, None);
458 ev["kind"] = serde_json::json!(1);
459 let hdr = authorization_header(&encode_event(&ev));
460 let err = verify_at(&hdr, "https://a/b", "GET", None, ts).unwrap_err();
461 assert!(matches!(err, PodError::Nip98(_)));
462 }
463
464 #[test]
465 fn compute_event_id_matches_canonical_hash() {
466 let event = Nip98Event {
467 id: String::new(),
468 pubkey: "a".repeat(64),
469 created_at: 1_700_000_000,
470 kind: 27235,
471 tags: vec![
472 vec!["u".into(), "https://api.example.com/x".into()],
473 vec!["method".into(), "GET".into()],
474 ],
475 content: String::new(),
476 sig: "0".repeat(128),
477 };
478 let id1 = compute_event_id(&event);
480 let id2 = compute_event_id(&event);
481 assert_eq!(id1, id2);
482 assert_eq!(id1.len(), 64);
483 }
484}