#![cfg(feature = "nip98-schnorr")]
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
use sha2::{Digest, Sha256};
use solid_pod_rs::auth::nip98::{
authorization_header, compute_event_id, verify_at, Nip98Event,
};
use solid_pod_rs::PodError;
const TIMESTAMP_TOLERANCE: u64 = 60;
fn test_signing_key() -> (k256::schnorr::SigningKey, String) {
let seed = [0x42u8; 32];
let sk = k256::schnorr::SigningKey::from_bytes(&seed)
.expect("deterministic seed produces valid Schnorr signing key");
let pubkey_hex = hex::encode(sk.verifying_key().to_bytes());
(sk, pubkey_hex)
}
fn encode_event(event: &serde_json::Value) -> String {
BASE64.encode(serde_json::to_string(event).unwrap().as_bytes())
}
fn build_event(
url: &str,
method: &str,
ts: u64,
body: Option<&[u8]>,
) -> serde_json::Value {
let (sk, pubkey) = test_signing_key();
let mut tags = vec![
vec!["u".to_string(), url.to_string()],
vec!["method".to_string(), method.to_string()],
];
if let Some(b) = body {
tags.push(vec!["payload".to_string(), hex::encode(Sha256::digest(b))]);
}
let skeleton = Nip98Event {
id: String::new(),
pubkey: pubkey.clone(),
created_at: ts,
kind: 27235,
tags: tags.clone(),
content: String::new(),
sig: String::new(),
};
let id = compute_event_id(&skeleton);
let id_bytes: Vec<u8> = hex::decode(&id).expect("id is valid hex");
let sig = {
use k256::schnorr::signature::Signer;
let signature: k256::schnorr::Signature = sk.sign(&id_bytes);
hex::encode(signature.to_bytes())
};
serde_json::json!({
"id": id,
"pubkey": pubkey,
"created_at": ts,
"kind": 27235,
"tags": tags,
"content": "",
"sig": sig,
})
}
#[test]
fn nip98_skew_window_enforced() {
let ts_event = 1_700_000_000u64;
let ev = build_event("https://a.example/r", "GET", ts_event, None);
let hdr = authorization_header(&encode_event(&ev));
let now_ahead = ts_event + TIMESTAMP_TOLERANCE + 1;
let err =
verify_at(&hdr, "https://a.example/r", "GET", None, now_ahead).unwrap_err();
assert!(matches!(err, PodError::Nip98(_)));
assert!(format!("{err}").contains("timestamp"));
let now_behind = ts_event - TIMESTAMP_TOLERANCE - 1;
let err =
verify_at(&hdr, "https://a.example/r", "GET", None, now_behind).unwrap_err();
assert!(matches!(err, PodError::Nip98(_)));
}
#[test]
fn nip98_skew_within_window_accepted() {
let ts_event = 1_700_000_000u64;
let ev = build_event("https://a.example/r", "GET", ts_event, None);
let hdr = authorization_header(&encode_event(&ev));
let now_ahead = ts_event + TIMESTAMP_TOLERANCE - 1;
let v = verify_at(&hdr, "https://a.example/r", "GET", None, now_ahead)
.expect("within window must accept");
assert_eq!(v.url, "https://a.example/r");
assert_eq!(v.method, "GET");
let now_behind = ts_event - (TIMESTAMP_TOLERANCE - 1);
verify_at(&hdr, "https://a.example/r", "GET", None, now_behind)
.expect("within window (past) must accept");
}
#[test]
fn nip98_payload_hash_required_when_present() {
let ts = 1_700_000_000u64;
let body = b"{\"hello\":\"world\"}" as &[u8];
let ev = build_event("https://a.example/r", "POST", ts, Some(body));
let hdr = authorization_header(&encode_event(&ev));
let v = verify_at(&hdr, "https://a.example/r", "POST", Some(body), ts)
.expect("matching body hash must accept");
let expected_hash = hex::encode(Sha256::digest(body));
assert_eq!(
v.payload_hash.as_deref(),
Some(expected_hash.as_str()),
"verifier echoes the hash from the payload tag"
);
let tampered = b"{\"hello\":\"evil!\"}" as &[u8];
assert_eq!(tampered.len(), body.len(), "same-length tamper stays out of length-guard");
let err =
verify_at(&hdr, "https://a.example/r", "POST", Some(tampered), ts).unwrap_err();
assert!(matches!(err, PodError::Nip98(_)));
assert!(
format!("{err}").to_lowercase().contains("payload"),
"error must identify the payload-hash mismatch: {err}"
);
}
#[test]
fn nip98_method_tag_mismatch_rejected() {
let ts = 1_700_000_000u64;
let ev = build_event("https://a.example/r", "GET", ts, None);
let hdr = authorization_header(&encode_event(&ev));
let err = verify_at(&hdr, "https://a.example/r", "POST", None, ts).unwrap_err();
assert!(matches!(err, PodError::Nip98(_)));
assert!(
format!("{err}").to_lowercase().contains("method"),
"error must identify the method mismatch: {err}"
);
}