1use base64::Engine;
39use base64::engine::general_purpose::STANDARD as BASE64;
40use sha2::{Digest, Sha256};
41use thiserror::Error;
42
43use crate::event::{Event, EventBuilder, EventError, Kind, Tag, TagKind, Tags};
44use crate::types::{Timestamp, Url, UrlError};
45use crate::util::JsonUtil;
46use crate::util::hex::{self, HexError};
47
48pub const KIND_HTTP_AUTH: Kind = Kind::new(27_235);
50
51pub const URL_TAG: &str = "u";
53pub const METHOD_TAG: &str = "method";
55pub const PAYLOAD_TAG: &str = "payload";
57
58pub const DEFAULT_TIMESTAMP_SKEW_SECS: u64 = 60;
61
62#[derive(Debug, Clone, PartialEq, Eq, Hash)]
65#[non_exhaustive]
66pub enum HttpMethod {
67 Get,
69 Post,
71 Put,
73 Patch,
75 Delete,
77 Head,
79 Options,
81 Connect,
83 Trace,
85 Other(String),
89}
90
91impl HttpMethod {
92 #[must_use]
96 pub const fn as_str(&self) -> &str {
97 match self {
98 Self::Get => "GET",
99 Self::Post => "POST",
100 Self::Put => "PUT",
101 Self::Patch => "PATCH",
102 Self::Delete => "DELETE",
103 Self::Head => "HEAD",
104 Self::Options => "OPTIONS",
105 Self::Connect => "CONNECT",
106 Self::Trace => "TRACE",
107 Self::Other(s) => s.as_str(),
108 }
109 }
110
111 #[must_use]
114 pub fn parse(s: &str) -> Self {
115 let upper = s.trim().to_ascii_uppercase();
116 match upper.as_str() {
117 "GET" => Self::Get,
118 "POST" => Self::Post,
119 "PUT" => Self::Put,
120 "PATCH" => Self::Patch,
121 "DELETE" => Self::Delete,
122 "HEAD" => Self::Head,
123 "OPTIONS" => Self::Options,
124 "CONNECT" => Self::Connect,
125 "TRACE" => Self::Trace,
126 _ => Self::Other(upper),
127 }
128 }
129}
130
131impl std::fmt::Display for HttpMethod {
132 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133 f.write_str(self.as_str())
134 }
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct HttpAuthRequest {
140 pub url: Url,
142 pub method: HttpMethod,
144 pub payload_hash: Option<[u8; 32]>,
147}
148
149impl HttpAuthRequest {
150 #[must_use]
152 pub const fn new(url: Url, method: HttpMethod) -> Self {
153 Self {
154 url,
155 method,
156 payload_hash: None,
157 }
158 }
159
160 #[must_use]
162 pub const fn payload_hash(mut self, hash: [u8; 32]) -> Self {
163 self.payload_hash = Some(hash);
164 self
165 }
166
167 #[must_use]
171 pub fn payload(self, body: &[u8]) -> Self {
172 self.payload_hash(sha256_hash(body))
173 }
174
175 #[must_use]
177 pub fn to_tags(&self) -> Vec<Tag> {
178 let mut tags: Vec<Tag> = Vec::with_capacity(3);
179 tags.push(custom_tag(URL_TAG, [self.url.as_str().to_owned()]));
180 tags.push(custom_tag(METHOD_TAG, [self.method.as_str().to_owned()]));
181 if let Some(hash) = self.payload_hash {
182 tags.push(custom_tag(PAYLOAD_TAG, [hex::encode(hash)]));
183 }
184 tags
185 }
186
187 pub fn from_event(event: &Event) -> Result<Self, HttpAuthError> {
197 if event.kind != KIND_HTTP_AUTH {
198 return Err(HttpAuthError::WrongKind(event.kind));
199 }
200 Self::from_tags(&event.tags)
201 }
202
203 pub fn from_tags(tags: &Tags) -> Result<Self, HttpAuthError> {
209 let url_str = custom_value(tags, URL_TAG).ok_or(HttpAuthError::MissingUrl)?;
210 let url = Url::parse(url_str).map_err(HttpAuthError::InvalidUrl)?;
211 let method_str = custom_value(tags, METHOD_TAG).ok_or(HttpAuthError::MissingMethod)?;
212 let method = HttpMethod::parse(method_str);
213
214 let payload_hash = if let Some(hex_str) = custom_value(tags, PAYLOAD_TAG) {
215 Some(parse_sha256_hex(hex_str)?)
216 } else {
217 None
218 };
219 Ok(Self {
220 url,
221 method,
222 payload_hash,
223 })
224 }
225
226 pub fn validate(
251 &self,
252 signed_at: Timestamp,
253 now: Timestamp,
254 skew_secs: u64,
255 request_url: &Url,
256 request_method: &HttpMethod,
257 body: Option<&[u8]>,
258 ) -> Result<(), HttpAuthError> {
259 let signed = signed_at.as_secs();
260 let current = now.as_secs();
261 let delta = signed.abs_diff(current);
262 if delta > skew_secs {
263 return Err(HttpAuthError::ValidationTimestampSkew {
264 delta_secs: delta,
265 allowed_secs: skew_secs,
266 });
267 }
268 if self.url != *request_url {
269 return Err(HttpAuthError::ValidationUrlMismatch {
270 expected: request_url.as_str().to_owned(),
271 got: self.url.as_str().to_owned(),
272 });
273 }
274 if self.method != *request_method {
275 return Err(HttpAuthError::ValidationMethodMismatch {
276 expected: request_method.to_string(),
277 got: self.method.to_string(),
278 });
279 }
280 if let Some(body_bytes) = body
281 && let Some(expected_hash) = self.payload_hash
282 {
283 let actual_hash = sha256_hash(body_bytes);
284 if actual_hash != expected_hash {
285 return Err(HttpAuthError::ValidationPayloadMismatch);
286 }
287 }
288 Ok(())
289 }
290}
291
292fn parse_sha256_hex(input: &str) -> Result<[u8; 32], HttpAuthError> {
293 if input.len() != 64 {
294 return Err(HttpAuthError::InvalidPayloadHashLength(input.len()));
295 }
296 let mut bytes = [0_u8; 32];
297 hex::decode_to_slice(input, &mut bytes).map_err(HttpAuthError::InvalidPayloadHash)?;
298 Ok(bytes)
299}
300
301fn sha256_hash(body: &[u8]) -> [u8; 32] {
302 let mut hasher = Sha256::new();
303 hasher.update(body);
304 hasher.finalize().into()
305}
306
307fn custom_tag<I, S>(name: &str, args: I) -> Tag
308where
309 I: IntoIterator<Item = S>,
310 S: Into<String>,
311{
312 Tag::with(&TagKind::from_wire(name), args)
313}
314
315fn custom_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
316 tags.iter()
317 .find(|tag| tag.name() == name)
318 .and_then(|tag| tag.get(1))
319}
320
321#[derive(Debug, Error)]
324#[non_exhaustive]
325pub enum HttpAuthError {
326 #[error("expected kind 27235 (HTTP auth), got kind {}", .0.as_u16())]
328 WrongKind(Kind),
329 #[error("NIP-98 event must carry a `u` tag")]
331 MissingUrl,
332 #[error("NIP-98 event must carry a `method` tag")]
334 MissingMethod,
335 #[error("invalid URL: {0}")]
337 InvalidUrl(#[source] UrlError),
338 #[error("`payload` hash must be 64 hex chars, got {0}")]
340 InvalidPayloadHashLength(usize),
341 #[error("invalid `payload` hash: {0}")]
343 InvalidPayloadHash(#[source] HexError),
344 #[error("`Authorization` header must use the `Nostr` scheme")]
346 HeaderWrongScheme,
347 #[error("`Authorization` body is not valid base64: {0}")]
349 HeaderInvalidBase64(#[source] base64::DecodeError),
350 #[error("`Authorization` body is not UTF-8: {0}")]
352 HeaderInvalidUtf8(#[source] std::str::Utf8Error),
353 #[error("`Authorization` body is not a valid Nostr event: {0}")]
355 HeaderInvalidEvent(#[source] EventError),
356 #[error("`Authorization` body is not valid JSON: {0}")]
358 HeaderInvalidJson(#[source] serde_json::Error),
359 #[error("`created_at` is {delta_secs}s away from `now`; max allowed is {allowed_secs}s")]
361 ValidationTimestampSkew {
362 delta_secs: u64,
364 allowed_secs: u64,
366 },
367 #[error("`u` mismatch: expected `{expected}`, got `{got}`")]
369 ValidationUrlMismatch {
370 expected: String,
372 got: String,
374 },
375 #[error("`method` mismatch: expected `{expected}`, got `{got}`")]
377 ValidationMethodMismatch {
378 expected: String,
380 got: String,
382 },
383 #[error("`payload` SHA-256 does not match the request body")]
385 ValidationPayloadMismatch,
386}
387
388impl EventBuilder {
389 #[must_use]
391 pub fn http_auth(request: &HttpAuthRequest) -> Self {
392 let mut builder = Self::new(KIND_HTTP_AUTH, "");
393 for tag in request.to_tags() {
394 builder = builder.tag(tag);
395 }
396 builder
397 }
398}
399
400pub const AUTH_SCHEME_PREFIX: &str = "Nostr ";
402
403pub fn authorization_header(event: &Event) -> Result<String, HttpAuthError> {
411 let json = event
412 .try_to_json()
413 .map_err(HttpAuthError::HeaderInvalidJson)?;
414 Ok(format!("{AUTH_SCHEME_PREFIX}{}", BASE64.encode(json)))
415}
416
417pub fn parse_authorization_header(header: &str) -> Result<Event, HttpAuthError> {
435 let body = header
436 .strip_prefix(AUTH_SCHEME_PREFIX)
437 .ok_or(HttpAuthError::HeaderWrongScheme)?;
438 let bytes = BASE64
439 .decode(body.trim())
440 .map_err(HttpAuthError::HeaderInvalidBase64)?;
441 let json = std::str::from_utf8(&bytes).map_err(HttpAuthError::HeaderInvalidUtf8)?;
442 let event = Event::from_json(json).map_err(HttpAuthError::HeaderInvalidJson)?;
443 Ok(event)
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449 use crate::Keys;
450
451 fn keys() -> Keys {
452 Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
453 }
454
455 fn fixture_url() -> Url {
456 Url::parse("https://api.example.com/api/v1/n5sp/list").unwrap()
457 }
458
459 #[test]
460 fn round_trip_through_event_for_get_request() {
461 let req = HttpAuthRequest::new(fixture_url(), HttpMethod::Get);
462 let event = EventBuilder::http_auth(&req)
463 .sign_with_keys(&keys())
464 .unwrap();
465 assert_eq!(event.kind, KIND_HTTP_AUTH);
466 assert_eq!(event.content, "");
467 let parsed = HttpAuthRequest::from_event(&event).unwrap();
468 assert_eq!(parsed, req);
469 }
470
471 #[test]
472 fn round_trip_includes_payload_hash_for_post() {
473 let body = b"{\"hello\":\"world\"}";
474 let req = HttpAuthRequest::new(fixture_url(), HttpMethod::Post).payload(body);
475 let event = EventBuilder::http_auth(&req)
476 .sign_with_keys(&keys())
477 .unwrap();
478 let parsed = HttpAuthRequest::from_event(&event).unwrap();
479 assert_eq!(parsed, req);
480 let expected = sha256_hash(body);
481 assert_eq!(parsed.payload_hash, Some(expected));
482 }
483
484 #[test]
485 fn missing_url_is_rejected_when_parsing() {
486 let event = EventBuilder::new(KIND_HTTP_AUTH, "")
487 .tag(custom_tag(METHOD_TAG, ["GET"]))
488 .sign_with_keys(&keys())
489 .unwrap();
490 assert!(matches!(
491 HttpAuthRequest::from_event(&event),
492 Err(HttpAuthError::MissingUrl)
493 ));
494 }
495
496 #[test]
497 fn missing_method_is_rejected_when_parsing() {
498 let event = EventBuilder::new(KIND_HTTP_AUTH, "")
499 .tag(custom_tag(URL_TAG, [fixture_url().as_str()]))
500 .sign_with_keys(&keys())
501 .unwrap();
502 assert!(matches!(
503 HttpAuthRequest::from_event(&event),
504 Err(HttpAuthError::MissingMethod)
505 ));
506 }
507
508 #[test]
509 fn unknown_method_round_trips_as_other() {
510 let m = HttpMethod::parse("MOVE");
511 assert_eq!(m, HttpMethod::Other("MOVE".to_owned()));
512 assert_eq!(m.as_str(), "MOVE");
513 }
514
515 #[test]
516 fn lowercase_method_is_normalised() {
517 let m = HttpMethod::parse("get");
518 assert_eq!(m, HttpMethod::Get);
519 }
520
521 #[test]
522 fn validate_passes_for_correct_request() {
523 let req = HttpAuthRequest::new(fixture_url(), HttpMethod::Get);
524 let signed_at = Timestamp::from_secs(1_700_000_000);
525 let now = Timestamp::from_secs(1_700_000_010); req.validate(
527 signed_at,
528 now,
529 DEFAULT_TIMESTAMP_SKEW_SECS,
530 &fixture_url(),
531 &HttpMethod::Get,
532 None,
533 )
534 .unwrap();
535 }
536
537 #[test]
538 fn validate_rejects_timestamp_skew() {
539 let req = HttpAuthRequest::new(fixture_url(), HttpMethod::Get);
540 let err = req
541 .validate(
542 Timestamp::from_secs(1_700_000_000),
543 Timestamp::from_secs(1_700_000_120), DEFAULT_TIMESTAMP_SKEW_SECS,
545 &fixture_url(),
546 &HttpMethod::Get,
547 None,
548 )
549 .unwrap_err();
550 assert!(matches!(err, HttpAuthError::ValidationTimestampSkew { .. }));
551 }
552
553 #[test]
554 fn validate_rejects_url_mismatch() {
555 let req = HttpAuthRequest::new(fixture_url(), HttpMethod::Get);
556 let err = req
557 .validate(
558 Timestamp::from_secs(0),
559 Timestamp::from_secs(0),
560 DEFAULT_TIMESTAMP_SKEW_SECS,
561 &Url::parse("https://other.example/foo").unwrap(),
562 &HttpMethod::Get,
563 None,
564 )
565 .unwrap_err();
566 assert!(matches!(err, HttpAuthError::ValidationUrlMismatch { .. }));
567 }
568
569 #[test]
570 fn validate_rejects_method_mismatch() {
571 let req = HttpAuthRequest::new(fixture_url(), HttpMethod::Get);
572 let err = req
573 .validate(
574 Timestamp::from_secs(0),
575 Timestamp::from_secs(0),
576 DEFAULT_TIMESTAMP_SKEW_SECS,
577 &fixture_url(),
578 &HttpMethod::Post,
579 None,
580 )
581 .unwrap_err();
582 assert!(matches!(
583 err,
584 HttpAuthError::ValidationMethodMismatch { .. }
585 ));
586 }
587
588 #[test]
589 fn validate_rejects_payload_mismatch() {
590 let req = HttpAuthRequest::new(fixture_url(), HttpMethod::Post).payload(b"original");
591 let err = req
592 .validate(
593 Timestamp::from_secs(0),
594 Timestamp::from_secs(0),
595 DEFAULT_TIMESTAMP_SKEW_SECS,
596 &fixture_url(),
597 &HttpMethod::Post,
598 Some(b"tampered"),
599 )
600 .unwrap_err();
601 assert!(matches!(err, HttpAuthError::ValidationPayloadMismatch));
602 }
603
604 #[test]
605 fn authorization_header_round_trips() {
606 let req = HttpAuthRequest::new(fixture_url(), HttpMethod::Get);
607 let event = EventBuilder::http_auth(&req)
608 .sign_with_keys(&keys())
609 .unwrap();
610 let header = authorization_header(&event).unwrap();
611 assert!(header.starts_with("Nostr "));
612 let parsed = parse_authorization_header(&header).unwrap();
613 assert_eq!(parsed.id, event.id);
614 }
615
616 #[test]
617 fn parse_authorization_rejects_wrong_scheme() {
618 let err = parse_authorization_header("Bearer xxx").unwrap_err();
619 assert!(matches!(err, HttpAuthError::HeaderWrongScheme));
620 }
621
622 #[test]
623 fn parse_authorization_rejects_bad_base64() {
624 let err = parse_authorization_header("Nostr !!!!!").unwrap_err();
625 assert!(matches!(err, HttpAuthError::HeaderInvalidBase64(_)));
626 }
627
628 #[test]
629 fn malformed_payload_hash_surfaces_typed_error() {
630 let event = EventBuilder::new(KIND_HTTP_AUTH, "")
631 .tag(custom_tag(URL_TAG, [fixture_url().as_str()]))
632 .tag(custom_tag(METHOD_TAG, ["POST"]))
633 .tag(custom_tag(PAYLOAD_TAG, ["not-hex"]))
634 .sign_with_keys(&keys())
635 .unwrap();
636 assert!(matches!(
637 HttpAuthRequest::from_event(&event),
638 Err(HttpAuthError::InvalidPayloadHashLength(_))
639 ));
640 }
641
642 #[test]
643 fn wrong_kind_is_rejected_when_parsing() {
644 let event = EventBuilder::text_note("nope")
645 .sign_with_keys(&keys())
646 .unwrap();
647 assert!(matches!(
648 HttpAuthRequest::from_event(&event),
649 Err(HttpAuthError::WrongKind(_))
650 ));
651 }
652}