Skip to main content

solid_pod_rs/auth/
nip98.rs

1//! NIP-98 HTTP authentication: structural verification.
2//!
3//! Reference: <https://github.com/nostr-protocol/nips/blob/master/98.md>
4//!
5//! Wire format: `Authorization: Nostr <base64(json(event))>` where
6//! the event is a kind-27235 Nostr event with tags `u` (URL),
7//! `method`, and optional `payload` (SHA-256 of request body).
8//!
9//! This Phase 1 implementation performs all structural checks.
10//! Cryptographic signature verification (Schnorr over secp256k1) is
11//! the Phase 2 deliverable.
12
13use 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
47/// Verify a NIP-98 `Authorization` header against expected URL,
48/// method, and optional body.
49///
50/// Returns the signer pubkey on success.
51pub 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
64/// `verify` with an explicit timestamp (for deterministic testing).
65pub 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    // Schnorr signature verification is available under the
134    // `nip98-schnorr` feature. Structural checks always run.
135    #[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
149/// Canonical serialisation of a Nostr event per NIP-01 §"Serialization".
150/// Returns `sha256(json([0, pubkey, created_at, kind, tags, content]))`
151/// as lowercase hex.
152pub 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/// Schnorr signature verification (feature-gated).
166///
167/// This validates:
168/// 1. `event.id` matches the canonical NIP-01 hash.
169/// 2. `event.sig` is a valid BIP-340 Schnorr signature by `event.pubkey`
170///    over the event id bytes.
171#[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/// No-op stub when the `nip98-schnorr` feature is not enabled.
205#[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
228// ---------------------------------------------------------------------------
229// Sprint 11 row 152: SelfSignedVerifier adapter.
230//
231// Wraps `verify_at` in the CID verifier contract so NIP-98 is one of
232// the proof formats a `CidVerifier` can dispatch. The wire format is
233// the `Nostr <base64>` header exactly as produced by clients today —
234// the adapter strips the `Nostr ` prefix if the caller supplied the
235// raw header, or accepts the bare token otherwise.
236// ---------------------------------------------------------------------------
237
238use crate::auth::self_signed::{
239    ProofEnvelope, SelfSignedError, SelfSignedVerifier, VerifiedSubject,
240};
241
242/// [`SelfSignedVerifier`] adapter for NIP-98.
243///
244/// Accepts either `Nostr <b64>` (raw header) or `<b64>` (already-stripped
245/// token). On success the returned subject is `urn:nip98:<pubkey>` with
246/// a `verification_method` of `urn:nip98:<pubkey>#key-0`.
247#[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        // Decide whether this looks like NIP-98. We accept either the
257        // full `Nostr <b64>` header OR a bare token that begins with a
258        // base64-ish byte — so the CID dispatcher can hand us either.
259        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            // Header-shaped proof (`Nostr …`) that fails to parse — the
272            // client clearly intended a NIP-98 event, so surface the
273            // failure verbatim so the dispatcher stops.
274            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            // Bare (non-header) input — treat any structural failure as
286            // "not NIP-98, try the next verifier". Only surface errors
287            // for the well-formed-event-but-bad-signature case.
288            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    /// Deterministic test keypair. Under `nip98-schnorr` this returns a
307    /// real BIP-340 signing key whose x-only pubkey is returned as hex.
308    /// Without the feature, returns the legacy `"a".repeat(64)` placeholder.
309    #[cfg(feature = "nip98-schnorr")]
310    fn test_signing_key() -> (k256::schnorr::SigningKey, String) {
311        // Deterministic 32-byte seed — not all seeds produce a valid
312        // Schnorr key, but this one does (secp256k1 is ~99.9% acceptance).
313        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    /// Build a canonically-hashed, properly-signed (when feature on) event.
331    ///
332    /// - `id` is always computed from the NIP-01 canonical serialisation.
333    /// - `sig` is a real BIP-340 Schnorr signature when `nip98-schnorr` is
334    ///   enabled, otherwise a 128-hex-zero placeholder (not verified).
335    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        // Build a Nip98Event purely to reuse `compute_event_id` —
349        // that's the canonical NIP-01 hash and the single source of truth.
350        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                // Match the verifier: `verify_schnorr_signature` calls
365                // `VerifyingKey::verify(id_bytes, sig)`, and k256's
366                // `Verifier` impl hashes the input via
367                // `Sha256::new_with_prefix`. The paired `Signer::try_sign`
368                // does the same, so signing over `id_bytes` with that
369                // trait produces a matching signature.
370                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        // Stable canonical hash — recomputing produces the same value.
479        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}