use async_trait::async_trait;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use k256::schnorr::SigningKey;
use sha2::{Digest, Sha256};
use thiserror::Error;
use crate::event::{sign_event, verify_event, NostrEvent, UnsignedEvent};
const HTTP_AUTH_KIND: u64 = 27235;
pub const TIMESTAMP_TOLERANCE: u64 = 60;
pub const REPLAY_CACHE_TTL_SECS: u64 = 2 * TIMESTAMP_TOLERANCE;
const MAX_EVENT_SIZE: usize = 64 * 1024;
const NOSTR_PREFIX: &str = "Nostr ";
#[async_trait(?Send)]
pub trait Nip98ReplayStore {
async fn seen_or_record(&self, event_id: &str) -> Result<bool, String>;
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Nip98Token {
pub event_id: String,
pub pubkey: String,
pub url: String,
pub method: String,
pub payload_hash: Option<String>,
pub created_at: u64,
}
#[derive(Debug, Error)]
pub enum Nip98Error {
#[error("invalid secret key: {0}")]
InvalidKey(#[from] k256::schnorr::Error),
#[error("JSON serialization failed: {0}")]
Json(#[from] serde_json::Error),
#[error("base64 decode failed: {0}")]
Base64(#[from] base64::DecodeError),
#[error("missing 'Nostr ' prefix in Authorization header")]
MissingPrefix,
#[error("event exceeds maximum size ({MAX_EVENT_SIZE} bytes)")]
EventTooLarge,
#[error("wrong event kind: expected {HTTP_AUTH_KIND}, got {0}")]
WrongKind(u64),
#[error("invalid pubkey: expected 64 hex chars")]
InvalidPubkey,
#[error(
"timestamp expired: event created_at {event_ts} is more than {tolerance}s from now ({now})"
)]
TimestampExpired {
event_ts: u64,
now: u64,
tolerance: u64,
},
#[error("missing required tag: {0}")]
MissingTag(String),
#[error("URL mismatch: token={token_url}, expected={expected_url}")]
UrlMismatch {
token_url: String,
expected_url: String,
},
#[error("method mismatch: token={token_method}, expected={expected_method}")]
MethodMismatch {
token_method: String,
expected_method: String,
},
#[error("payload hash mismatch")]
PayloadMismatch,
#[error("body present but no payload tag in signed event")]
MissingPayloadTag,
#[error("event signature verification failed")]
InvalidSignature,
#[error("replay detected: event id has already been seen within the tolerance window")]
Replayed,
#[error("replay store backend error: {0}")]
ReplayBackend(String),
}
pub fn create_token(
secret_key: &[u8; 32],
url: &str,
method: &str,
body: Option<&[u8]>,
) -> Result<String, Nip98Error> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock before epoch")
.as_secs();
create_token_at(secret_key, url, method, body, now)
}
pub fn create_token_at(
secret_key: &[u8; 32],
url: &str,
method: &str,
body: Option<&[u8]>,
created_at: u64,
) -> Result<String, Nip98Error> {
let sk = SigningKey::from_bytes(secret_key)?;
let pubkey = hex::encode(sk.verifying_key().to_bytes());
let mut tags = vec![
vec!["u".to_string(), url.to_string()],
vec!["method".to_string(), method.to_string()],
];
if let Some(body_bytes) = body {
let hash = Sha256::digest(body_bytes);
tags.push(vec!["payload".to_string(), hex::encode(hash)]);
}
let unsigned = UnsignedEvent {
pubkey,
created_at,
kind: HTTP_AUTH_KIND,
tags,
content: String::new(),
};
let signed = sign_event(unsigned, &sk)
.expect("pubkey derived from same signing key — mismatch impossible");
let json = serde_json::to_string(&signed)?;
Ok(BASE64.encode(json.as_bytes()))
}
pub fn verify_nip98(
auth_header: &str,
expected_url: &str,
expected_method: &str,
max_age_secs: Option<u64>,
) -> Result<Nip98Token, Nip98Error> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock before epoch")
.as_secs();
let tolerance = max_age_secs.unwrap_or(TIMESTAMP_TOLERANCE);
verify_token_full(
auth_header,
expected_url,
expected_method,
None,
now,
tolerance,
)
}
pub fn verify_token(
auth_header: &str,
expected_url: &str,
expected_method: &str,
body: Option<&[u8]>,
) -> Result<Nip98Token, Nip98Error> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock before epoch")
.as_secs();
verify_token_full(
auth_header,
expected_url,
expected_method,
body,
now,
TIMESTAMP_TOLERANCE,
)
}
pub fn verify_token_at(
auth_header: &str,
expected_url: &str,
expected_method: &str,
body: Option<&[u8]>,
now: u64,
) -> Result<Nip98Token, Nip98Error> {
verify_token_full(
auth_header,
expected_url,
expected_method,
body,
now,
TIMESTAMP_TOLERANCE,
)
}
pub fn verify_token_full(
auth_header: &str,
expected_url: &str,
expected_method: &str,
body: Option<&[u8]>,
now: u64,
max_age_secs: u64,
) -> Result<Nip98Token, Nip98Error> {
let token = auth_header
.strip_prefix(NOSTR_PREFIX)
.ok_or(Nip98Error::MissingPrefix)?
.trim();
if token.len() > MAX_EVENT_SIZE {
return Err(Nip98Error::EventTooLarge);
}
let json_bytes = BASE64.decode(token)?;
if json_bytes.len() > MAX_EVENT_SIZE {
return Err(Nip98Error::EventTooLarge);
}
let event: NostrEvent = serde_json::from_slice(&json_bytes)?;
if event.kind != HTTP_AUTH_KIND {
return Err(Nip98Error::WrongKind(event.kind));
}
if event.pubkey.len() != 64 || hex::decode(&event.pubkey).is_err() {
return Err(Nip98Error::InvalidPubkey);
}
if now.abs_diff(event.created_at) > max_age_secs {
return Err(Nip98Error::TimestampExpired {
event_ts: event.created_at,
now,
tolerance: max_age_secs,
});
}
if !verify_event(&event) {
return Err(Nip98Error::InvalidSignature);
}
let token_url = get_tag(&event, "u").ok_or_else(|| Nip98Error::MissingTag("u".into()))?;
if token_url != expected_url {
return Err(Nip98Error::UrlMismatch {
token_url: token_url.clone(),
expected_url: expected_url.to_string(),
});
}
let token_method =
get_tag(&event, "method").ok_or_else(|| Nip98Error::MissingTag("method".into()))?;
if token_method.to_uppercase() != expected_method.to_uppercase() {
return Err(Nip98Error::MethodMismatch {
token_method,
expected_method: expected_method.to_string(),
});
}
let payload_tag = get_tag(&event, "payload");
let verified_payload_hash = if let Some(body_bytes) = body {
if !body_bytes.is_empty() {
let expected_hash = payload_tag.as_ref().ok_or(Nip98Error::MissingPayloadTag)?;
let actual_hash = hex::encode(Sha256::digest(body_bytes));
if expected_hash.to_lowercase() != actual_hash.to_lowercase() {
return Err(Nip98Error::PayloadMismatch);
}
Some(expected_hash.clone())
} else {
payload_tag
}
} else {
payload_tag
};
Ok(Nip98Token {
event_id: event.id,
pubkey: event.pubkey,
url: token_url,
method: token_method,
payload_hash: verified_payload_hash,
created_at: event.created_at,
})
}
pub async fn verify_token_at_with_replay(
auth_header: &str,
expected_url: &str,
expected_method: &str,
body: Option<&[u8]>,
now: u64,
replay_store: &dyn Nip98ReplayStore,
) -> Result<Nip98Token, Nip98Error> {
verify_nip98_with_replay(
auth_header,
expected_url,
expected_method,
body,
now,
TIMESTAMP_TOLERANCE,
replay_store,
)
.await
}
pub async fn verify_nip98_with_replay(
auth_header: &str,
expected_url: &str,
expected_method: &str,
body: Option<&[u8]>,
now: u64,
max_age_secs: u64,
replay_store: &dyn Nip98ReplayStore,
) -> Result<Nip98Token, Nip98Error> {
let token = verify_token_full(
auth_header,
expected_url,
expected_method,
body,
now,
max_age_secs,
)?;
let first_seen = replay_store
.seen_or_record(&token.event_id)
.await
.map_err(Nip98Error::ReplayBackend)?;
if !first_seen {
return Err(Nip98Error::Replayed);
}
Ok(token)
}
fn get_tag(event: &NostrEvent, name: &str) -> Option<String> {
event
.tags
.iter()
.find(|t| t.first().map(|s| s.as_str()) == Some(name))
.and_then(|t| t.get(1).cloned())
}
pub fn authorization_header(token: &str) -> String {
format!("{NOSTR_PREFIX}{token}")
}
pub fn sign_request_header(
secret_key: &[u8; 32],
url: &str,
method: &str,
body: Option<&[u8]>,
) -> Result<String, Nip98Error> {
let token = create_token(secret_key, url, method, body)?;
Ok(authorization_header(&token))
}
#[cfg(test)]
mod tests {
use super::*;
use k256::schnorr::SigningKey;
fn test_secret_key() -> [u8; 32] {
let mut key = [0u8; 32];
key[0] = 0x01;
key[31] = 0x01;
key
}
fn test_signing_key() -> SigningKey {
SigningKey::from_bytes(&test_secret_key()).unwrap()
}
#[test]
fn nip98_create_verify_roundtrip_no_body() {
let sk = test_secret_key();
let url = "https://api.example.com/upload";
let method = "GET";
let token = create_token(&sk, url, method, None).unwrap();
let header = authorization_header(&token);
let result = verify_token(&header, url, method, None).unwrap();
let expected_pubkey = hex::encode(test_signing_key().verifying_key().to_bytes());
assert_eq!(result.pubkey, expected_pubkey);
assert_eq!(result.url, url);
assert_eq!(result.method, method);
assert!(result.payload_hash.is_none());
}
#[test]
fn nip98_create_verify_roundtrip_with_body() {
let sk = test_secret_key();
let url = "https://api.example.com/data";
let method = "POST";
let body = b"hello world";
let token = create_token(&sk, url, method, Some(body)).unwrap();
let header = authorization_header(&token);
let result = verify_token(&header, url, method, Some(body)).unwrap();
assert_eq!(result.method, method);
assert!(result.payload_hash.is_some());
let expected_hash = hex::encode(Sha256::digest(body));
assert_eq!(result.payload_hash.unwrap(), expected_hash);
}
#[test]
fn nip98_reject_wrong_url() {
let sk = test_secret_key();
let token = create_token(&sk, "https://good.com/api", "GET", None).unwrap();
let header = authorization_header(&token);
let err = verify_token(&header, "https://evil.com/api", "GET", None).unwrap_err();
assert!(matches!(err, Nip98Error::UrlMismatch { .. }));
}
#[test]
fn nip98_reject_wrong_method() {
let sk = test_secret_key();
let token = create_token(&sk, "https://api.example.com/x", "GET", None).unwrap();
let header = authorization_header(&token);
let err = verify_token(&header, "https://api.example.com/x", "POST", None).unwrap_err();
assert!(matches!(err, Nip98Error::MethodMismatch { .. }));
}
#[test]
fn nip98_reject_missing_prefix() {
let err = verify_token("Bearer abc123", "https://x.com", "GET", None).unwrap_err();
assert!(matches!(err, Nip98Error::MissingPrefix));
}
#[test]
fn nip98_reject_invalid_base64() {
let err = verify_token("Nostr !!!not-base64!!!", "https://x.com", "GET", None).unwrap_err();
assert!(
matches!(err, Nip98Error::Base64(_)) || matches!(err, Nip98Error::Json(_)),
"expected Base64 or Json error, got: {err}"
);
}
#[test]
fn nip98_reject_tampered_signature() {
let sk = test_secret_key();
let url = "https://api.example.com/test";
let token_b64 = create_token(&sk, url, "GET", None).unwrap();
let json_bytes = BASE64.decode(&token_b64).unwrap();
let mut event: NostrEvent = serde_json::from_slice(&json_bytes).unwrap();
let mut sig_bytes = hex::decode(&event.sig).unwrap();
sig_bytes[0] ^= 0xFF;
event.sig = hex::encode(&sig_bytes);
let tampered_json = serde_json::to_string(&event).unwrap();
let tampered_b64 = BASE64.encode(tampered_json.as_bytes());
let header = authorization_header(&tampered_b64);
let err = verify_token(&header, url, "GET", None).unwrap_err();
assert!(matches!(err, Nip98Error::InvalidSignature));
}
#[test]
fn nip98_reject_payload_mismatch() {
let sk = test_secret_key();
let url = "https://api.example.com/upload";
let original_body = b"original content";
let tampered_body = b"tampered content";
let token = create_token(&sk, url, "POST", Some(original_body)).unwrap();
let header = authorization_header(&token);
let err = verify_token(&header, url, "POST", Some(tampered_body)).unwrap_err();
assert!(matches!(err, Nip98Error::PayloadMismatch));
}
#[test]
fn nip98_reject_body_without_payload_tag() {
let sk = test_secret_key();
let url = "https://api.example.com/upload";
let token = create_token(&sk, url, "POST", None).unwrap();
let header = authorization_header(&token);
let err = verify_token(&header, url, "POST", Some(b"sneaky body")).unwrap_err();
assert!(matches!(err, Nip98Error::MissingPayloadTag));
}
#[test]
fn nip98_reject_expired_timestamp() {
let signing_key = test_signing_key();
let pubkey = hex::encode(signing_key.verifying_key().to_bytes());
let url = "https://api.example.com/old";
let old_time = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
- 120;
let unsigned = UnsignedEvent {
pubkey,
created_at: old_time,
kind: HTTP_AUTH_KIND,
tags: vec![
vec!["u".to_string(), url.to_string()],
vec!["method".to_string(), "GET".to_string()],
],
content: String::new(),
};
let signed = sign_event(unsigned, &signing_key).expect("pubkey derived from same key");
let json = serde_json::to_string(&signed).unwrap();
let b64 = BASE64.encode(json.as_bytes());
let header = authorization_header(&b64);
let err = verify_token(&header, url, "GET", None).unwrap_err();
assert!(matches!(err, Nip98Error::TimestampExpired { .. }));
}
#[test]
fn nip98_verify_token_at_accepts_matching_timestamp() {
let sk = test_secret_key();
let url = "https://api.example.com/at";
let created_at = 1_700_000_000u64;
let token = create_token_at(&sk, url, "GET", None, created_at).unwrap();
let header = authorization_header(&token);
let result = verify_token_at(&header, url, "GET", None, created_at).unwrap();
assert_eq!(result.created_at, created_at);
}
#[test]
fn nip98_verify_token_at_accepts_within_tolerance() {
let sk = test_secret_key();
let url = "https://api.example.com/at";
let created_at = 1_700_000_000u64;
let token = create_token_at(&sk, url, "POST", Some(b"body"), created_at).unwrap();
let header = authorization_header(&token);
let result = verify_token_at(&header, url, "POST", Some(b"body"), created_at + 30).unwrap();
assert_eq!(result.created_at, created_at);
}
#[test]
fn nip98_verify_token_at_rejects_beyond_tolerance() {
let sk = test_secret_key();
let url = "https://api.example.com/at";
let created_at = 1_700_000_000u64;
let token = create_token_at(&sk, url, "GET", None, created_at).unwrap();
let header = authorization_header(&token);
let err = verify_token_at(&header, url, "GET", None, created_at + 120).unwrap_err();
assert!(matches!(err, Nip98Error::TimestampExpired { .. }));
}
#[test]
fn nip98_verify_token_at_rejects_future_beyond_tolerance() {
let sk = test_secret_key();
let url = "https://api.example.com/at";
let created_at = 1_700_000_100u64;
let token = create_token_at(&sk, url, "GET", None, created_at).unwrap();
let header = authorization_header(&token);
let err = verify_token_at(&header, url, "GET", None, created_at - 120).unwrap_err();
assert!(matches!(err, Nip98Error::TimestampExpired { .. }));
}
#[test]
fn nip98_url_trailing_slash_mismatch_rejected() {
let sk = test_secret_key();
let url_with_slash = "https://api.example.com/path/";
let url_without_slash = "https://api.example.com/path";
let token = create_token(&sk, url_with_slash, "GET", None).unwrap();
let header = authorization_header(&token);
let err = verify_token(&header, url_without_slash, "GET", None).unwrap_err();
assert!(matches!(err, Nip98Error::UrlMismatch { .. }));
}
#[test]
fn nip98_method_case_insensitive() {
let sk = test_secret_key();
let url = "https://api.example.com/test";
let token = create_token(&sk, url, "post", None).unwrap();
let header = authorization_header(&token);
let result = verify_token(&header, url, "POST", None).unwrap();
assert_eq!(result.method, "post");
}
#[test]
fn nip98_empty_body_no_payload_tag_required() {
let sk = test_secret_key();
let url = "https://api.example.com/get";
let token = create_token(&sk, url, "GET", None).unwrap();
let header = authorization_header(&token);
let result = verify_token(&header, url, "GET", None).unwrap();
assert!(result.payload_hash.is_none());
}
#[test]
fn nip98_authorization_header_format() {
let token = "abc123";
let header = authorization_header(token);
assert_eq!(header, "Nostr abc123");
}
#[test]
fn nip98_reject_wrong_kind() {
let sk = test_signing_key();
let pubkey = hex::encode(sk.verifying_key().to_bytes());
let url = "https://api.example.com/test";
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let unsigned = UnsignedEvent {
pubkey,
created_at: now,
kind: 1, tags: vec![
vec!["u".to_string(), url.to_string()],
vec!["method".to_string(), "GET".to_string()],
],
content: String::new(),
};
let signed = sign_event(unsigned, &sk).unwrap();
let json = serde_json::to_string(&signed).unwrap();
let b64 = BASE64.encode(json.as_bytes());
let header = authorization_header(&b64);
let err = verify_token(&header, url, "GET", None).unwrap_err();
assert!(matches!(err, Nip98Error::WrongKind(1)));
}
#[test]
fn nip98_reject_missing_url_tag() {
let sk = test_signing_key();
let pubkey = hex::encode(sk.verifying_key().to_bytes());
let url = "https://api.example.com/test";
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let unsigned = UnsignedEvent {
pubkey,
created_at: now,
kind: HTTP_AUTH_KIND,
tags: vec![vec!["method".to_string(), "GET".to_string()]],
content: String::new(),
};
let signed = sign_event(unsigned, &sk).unwrap();
let json = serde_json::to_string(&signed).unwrap();
let b64 = BASE64.encode(json.as_bytes());
let header = authorization_header(&b64);
let err = verify_token(&header, url, "GET", None).unwrap_err();
assert!(matches!(err, Nip98Error::MissingTag(_)));
}
#[test]
fn nip98_reject_missing_method_tag() {
let sk = test_signing_key();
let pubkey = hex::encode(sk.verifying_key().to_bytes());
let url = "https://api.example.com/test";
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let unsigned = UnsignedEvent {
pubkey,
created_at: now,
kind: HTTP_AUTH_KIND,
tags: vec![vec!["u".to_string(), url.to_string()]],
content: String::new(),
};
let signed = sign_event(unsigned, &sk).unwrap();
let json = serde_json::to_string(&signed).unwrap();
let b64 = BASE64.encode(json.as_bytes());
let header = authorization_header(&b64);
let err = verify_token(&header, url, "GET", None).unwrap_err();
assert!(matches!(err, Nip98Error::MissingTag(_)));
}
#[test]
fn nip98_empty_body_with_body_bytes_passes() {
let sk = test_secret_key();
let url = "https://api.example.com/test";
let token = create_token(&sk, url, "POST", None).unwrap();
let header = authorization_header(&token);
let result = verify_token(&header, url, "POST", Some(b"")).unwrap();
assert!(result.payload_hash.is_none());
}
#[test]
fn nip98_get_tag_returns_none_for_missing() {
let event = NostrEvent {
id: "00".repeat(32),
pubkey: "aa".repeat(32),
created_at: 0,
kind: 1,
tags: vec![vec!["e".into(), "ref1".into()]],
content: String::new(),
sig: "00".repeat(64),
};
assert!(get_tag(&event, "u").is_none());
assert_eq!(get_tag(&event, "e"), Some("ref1".to_string()));
}
#[test]
fn nip98_verify_nip98_default_tolerance() {
let sk = test_secret_key();
let url = "https://api.example.com/canonical";
let token = create_token(&sk, url, "GET", None).unwrap();
let header = authorization_header(&token);
let result = verify_nip98(&header, url, "GET", None).unwrap();
assert_eq!(result.url, url);
assert_eq!(result.method, "GET");
}
#[test]
fn nip98_verify_nip98_custom_tolerance() {
let sk = test_secret_key();
let url = "https://api.example.com/custom";
let created_at = 1_700_000_000u64;
let token = create_token_at(&sk, url, "POST", None, created_at).unwrap();
let header = authorization_header(&token);
let err = verify_token_at(&header, url, "POST", None, created_at + 90).unwrap_err();
assert!(matches!(err, Nip98Error::TimestampExpired { .. }));
let result = verify_token_full(&header, url, "POST", None, created_at + 90, 120).unwrap();
assert_eq!(result.url, url);
assert_eq!(result.created_at, created_at);
}
#[test]
fn nip98_verify_token_full_strict_tolerance() {
let sk = test_secret_key();
let url = "https://api.example.com/strict";
let created_at = 1_700_000_000u64;
let token = create_token_at(&sk, url, "GET", None, created_at).unwrap();
let header = authorization_header(&token);
let result = verify_token_full(&header, url, "GET", None, created_at + 5, 10).unwrap();
assert_eq!(result.created_at, created_at);
let err = verify_token_full(&header, url, "GET", None, created_at + 15, 10).unwrap_err();
assert!(matches!(
err,
Nip98Error::TimestampExpired { tolerance: 10, .. }
));
}
#[test]
fn nip98_timestamp_expired_error_includes_tolerance() {
let sk = test_secret_key();
let url = "https://api.example.com/err";
let created_at = 1_700_000_000u64;
let token = create_token_at(&sk, url, "GET", None, created_at).unwrap();
let header = authorization_header(&token);
let err = verify_token_full(&header, url, "GET", None, created_at + 200, 30).unwrap_err();
match err {
Nip98Error::TimestampExpired {
event_ts,
now,
tolerance,
} => {
assert_eq!(event_ts, created_at);
assert_eq!(now, created_at + 200);
assert_eq!(tolerance, 30);
}
other => panic!("expected TimestampExpired, got: {other}"),
}
}
use std::cell::RefCell;
use std::collections::HashSet;
struct InMemoryReplayStore {
seen: RefCell<HashSet<String>>,
}
impl InMemoryReplayStore {
fn new() -> Self {
Self {
seen: RefCell::new(HashSet::new()),
}
}
}
#[async_trait::async_trait(?Send)]
impl Nip98ReplayStore for InMemoryReplayStore {
async fn seen_or_record(&self, event_id: &str) -> Result<bool, String> {
let mut g = self.seen.borrow_mut();
if g.contains(event_id) {
Ok(false)
} else {
g.insert(event_id.to_string());
Ok(true)
}
}
}
fn block_on<F: std::future::Future>(f: F) -> F::Output {
use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
fn noop_clone(p: *const ()) -> RawWaker {
RawWaker::new(p, &VTAB)
}
fn noop(_: *const ()) {}
static VTAB: RawWakerVTable = RawWakerVTable::new(noop_clone, noop, noop, noop);
let waker = unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTAB)) };
let mut cx = Context::from_waker(&waker);
let mut pinned = Box::pin(f);
loop {
match pinned.as_mut().poll(&mut cx) {
Poll::Ready(v) => return v,
Poll::Pending => continue, }
}
}
#[test]
fn nip98_replay_first_use_succeeds() {
let sk = test_secret_key();
let url = "https://api.example.com/replay";
let ts = 1_700_000_000u64;
let token = create_token_at(&sk, url, "POST", Some(b"x"), ts).unwrap();
let header = authorization_header(&token);
let store = InMemoryReplayStore::new();
let result = block_on(verify_token_at_with_replay(
&header,
url,
"POST",
Some(b"x"),
ts,
&store,
));
assert!(result.is_ok(), "first use must succeed");
}
#[test]
fn nip98_replay_second_use_rejected() {
let sk = test_secret_key();
let url = "https://api.example.com/replay";
let ts = 1_700_000_000u64;
let token = create_token_at(&sk, url, "POST", Some(b"y"), ts).unwrap();
let header = authorization_header(&token);
let store = InMemoryReplayStore::new();
let _ = block_on(verify_token_at_with_replay(
&header,
url,
"POST",
Some(b"y"),
ts,
&store,
))
.unwrap();
let err = block_on(verify_token_at_with_replay(
&header,
url,
"POST",
Some(b"y"),
ts,
&store,
))
.unwrap_err();
assert!(
matches!(err, Nip98Error::Replayed),
"second use must be Replayed, got {err}"
);
}
#[test]
fn nip98_replay_different_events_independent() {
let sk = test_secret_key();
let url = "https://api.example.com/replay";
let ts = 1_700_000_000u64;
let t1 = create_token_at(&sk, url, "POST", Some(b"a"), ts).unwrap();
let t2 = create_token_at(&sk, url, "POST", Some(b"b"), ts).unwrap();
let h1 = authorization_header(&t1);
let h2 = authorization_header(&t2);
let store = InMemoryReplayStore::new();
assert!(block_on(verify_token_at_with_replay(
&h1,
url,
"POST",
Some(b"a"),
ts,
&store,
))
.is_ok());
assert!(block_on(verify_token_at_with_replay(
&h2,
url,
"POST",
Some(b"b"),
ts,
&store,
))
.is_ok());
}
#[test]
fn nip98_create_token_at_deterministic() {
let sk = test_secret_key();
let url = "https://api.example.com/test";
let ts = 1_700_000_000u64;
let t1 = create_token_at(&sk, url, "GET", None, ts).unwrap();
let t2 = create_token_at(&sk, url, "GET", None, ts).unwrap();
let b1 = BASE64.decode(&t1).unwrap();
let e1: NostrEvent = serde_json::from_slice(&b1).unwrap();
let b2 = BASE64.decode(&t2).unwrap();
let e2: NostrEvent = serde_json::from_slice(&b2).unwrap();
assert_eq!(e1.pubkey, e2.pubkey);
assert_eq!(e1.created_at, e2.created_at);
assert_eq!(e1.kind, e2.kind);
assert_eq!(e1.tags, e2.tags);
}
}
#[cfg(test)]
#[cfg(not(target_arch = "wasm32"))]
mod proptests {
use super::*;
use proptest::prelude::*;
fn test_secret_key() -> [u8; 32] {
let mut key = [0u8; 32];
key[0] = 0x01;
key[31] = 0x01;
key
}
proptest! {
#[test]
fn nip98_roundtrip_arbitrary_url(
path in "[a-z]{1,20}(/[a-z0-9]{1,10}){0,5}"
) {
let sk = test_secret_key();
let url = format!("https://api.example.com/{path}");
let ts = 1_700_000_000u64;
let token = create_token_at(&sk, &url, "GET", None, ts).unwrap();
let header = authorization_header(&token);
let result = verify_token_at(&header, &url, "GET", None, ts);
prop_assert!(result.is_ok(), "Failed for URL: {url}");
let verified = result.unwrap();
prop_assert_eq!(verified.url, url);
}
#[test]
fn nip98_roundtrip_arbitrary_method(
method in "(GET|POST|PUT|DELETE|PATCH|HEAD)"
) {
let sk = test_secret_key();
let url = "https://api.example.com/test";
let ts = 1_700_000_000u64;
let token = create_token_at(&sk, url, &method, None, ts).unwrap();
let header = authorization_header(&token);
let result = verify_token_at(&header, url, &method, None, ts);
prop_assert!(result.is_ok());
}
#[test]
fn nip98_roundtrip_arbitrary_body(
body in prop::collection::vec(any::<u8>(), 1..200)
) {
let sk = test_secret_key();
let url = "https://api.example.com/upload";
let ts = 1_700_000_000u64;
let token = create_token_at(&sk, url, "POST", Some(&body), ts).unwrap();
let header = authorization_header(&token);
let result = verify_token_at(&header, url, "POST", Some(&body), ts);
prop_assert!(result.is_ok());
}
#[test]
fn nip98_timestamp_within_tolerance_always_passes(
offset in 0u64..=60
) {
let sk = test_secret_key();
let url = "https://api.example.com/time";
let created_at = 1_700_000_000u64;
let token = create_token_at(&sk, url, "GET", None, created_at).unwrap();
let header = authorization_header(&token);
let result = verify_token_at(&header, url, "GET", None, created_at + offset);
prop_assert!(result.is_ok(), "Failed at offset {offset}s");
}
#[test]
fn nip98_timestamp_beyond_tolerance_always_fails(
offset in 61u64..=600
) {
let sk = test_secret_key();
let url = "https://api.example.com/time";
let created_at = 1_700_000_000u64;
let token = create_token_at(&sk, url, "GET", None, created_at).unwrap();
let header = authorization_header(&token);
let result = verify_token_at(&header, url, "GET", None, created_at + offset);
prop_assert!(result.is_err(), "Should fail at offset {offset}s");
}
}
}