285 lines
9.3 KiB
Rust
285 lines
9.3 KiB
Rust
//! Auth envelope — request authentication for TCP/qrexec transports.
|
|
//!
|
|
//! Port of `auth_envelope.c`. Verifies a NIP-42-style auth envelope
|
|
//! in JSON-RPC requests. Checks signature, created_at freshness,
|
|
//! and replay protection.
|
|
|
|
use nostr_core::types::Event;
|
|
use std::collections::HashMap;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
/// NIP-42 auth event kind.
|
|
pub const AUTH_EVENT_KIND: u64 = 22242;
|
|
|
|
/// Default timestamp skew tolerance (seconds).
|
|
pub const AUTH_DEFAULT_SKEW_SECONDS: i32 = 300;
|
|
|
|
/// Auth nonce cache for replay protection.
|
|
///
|
|
/// Tracks per-pubkey monotonic timestamps + event IDs for same-second requests.
|
|
pub struct AuthNonceCache {
|
|
// pubkey_hex → (max_created_at, event_ids)
|
|
entries: HashMap<String, (i64, Vec<[u8; 32]>)>,
|
|
}
|
|
|
|
impl AuthNonceCache {
|
|
pub fn new() -> Self {
|
|
AuthNonceCache {
|
|
entries: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
/// Check and update replay protection.
|
|
///
|
|
/// Returns true if the (pubkey, created_at, event_id) tuple is acceptable.
|
|
/// Returns false if it's a replay.
|
|
pub fn check_and_update(
|
|
&mut self,
|
|
pubkey_hex: &str,
|
|
created_at: i64,
|
|
event_id: &[u8; 32],
|
|
) -> bool {
|
|
let entry = self
|
|
.entries
|
|
.entry(pubkey_hex.to_string())
|
|
.or_insert((0, Vec::new()));
|
|
|
|
if created_at > entry.0 {
|
|
// Newer timestamp: update max, clear event ID set, accept
|
|
entry.0 = created_at;
|
|
entry.1.clear();
|
|
entry.1.push(*event_id);
|
|
true
|
|
} else if created_at == entry.0 {
|
|
// Same timestamp: check event ID set for duplicates
|
|
if entry.1.contains(event_id) {
|
|
false // duplicate event ID
|
|
} else {
|
|
entry.1.push(*event_id);
|
|
true
|
|
}
|
|
} else {
|
|
// Older timestamp: reject as replay
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for AuthNonceCache {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// Auth error codes.
|
|
pub const AUTH_ERR_ENVELOPE_MALFORMED: i32 = 3001;
|
|
pub const AUTH_ERR_SIGNATURE_INVALID: i32 = 3002;
|
|
pub const AUTH_ERR_KIND_INVALID: i32 = 3003;
|
|
pub const AUTH_ERR_ENVELOPE_MISMATCH: i32 = 3004;
|
|
pub const AUTH_ERR_BODY_MISMATCH: i32 = 3005;
|
|
pub const AUTH_ERR_ENVELOPE_STALE: i32 = 3006;
|
|
pub const AUTH_ERR_REPLAY_DETECTED: i32 = 3007;
|
|
pub const AUTH_ERR_ENVELOPE_REQUIRED: i32 = 3008;
|
|
|
|
/// Verify an auth envelope in a JSON-RPC request.
|
|
///
|
|
/// On success, returns (pubkey_hex, label).
|
|
/// On failure, returns (error_code, error_message).
|
|
pub fn verify_request(
|
|
request_json: &str,
|
|
cache: &mut AuthNonceCache,
|
|
skew_seconds: i32,
|
|
) -> Result<(String, String), (i32, &'static str)> {
|
|
let root: serde_json::Value = serde_json::from_str(request_json)
|
|
.map_err(|_| (AUTH_ERR_ENVELOPE_MALFORMED, "auth_envelope_malformed"))?;
|
|
|
|
// Extract method and id from the request
|
|
let method = root
|
|
.get("method")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or((AUTH_ERR_ENVELOPE_MALFORMED, "auth_envelope_malformed"))?;
|
|
|
|
let request_id = root
|
|
.get("id")
|
|
.map(|v| {
|
|
if let Some(s) = v.as_str() {
|
|
s.to_string()
|
|
} else {
|
|
v.to_string()
|
|
}
|
|
})
|
|
.ok_or((AUTH_ERR_ENVELOPE_MALFORMED, "auth_envelope_malformed"))?;
|
|
|
|
// Extract auth envelope
|
|
let auth = root
|
|
.get("auth")
|
|
.ok_or((AUTH_ERR_ENVELOPE_REQUIRED, "auth_envelope_required"))?;
|
|
|
|
// Parse auth as a Nostr event
|
|
let auth_event: Event = serde_json::from_value(auth.clone())
|
|
.map_err(|_| (AUTH_ERR_ENVELOPE_MALFORMED, "auth_envelope_malformed"))?;
|
|
|
|
// Validate event structure
|
|
nips::nip001::validate_event_structure(&auth_event)
|
|
.map_err(|_| (AUTH_ERR_ENVELOPE_MALFORMED, "auth_envelope_malformed"))?;
|
|
|
|
// Verify event signature
|
|
nips::nip001::verify_event_signature(&auth_event)
|
|
.map_err(|_| (AUTH_ERR_SIGNATURE_INVALID, "auth_signature_invalid"))?;
|
|
|
|
// Check kind is AUTH_EVENT_KIND (22242)
|
|
if auth_event.kind.as_u64() != AUTH_EVENT_KIND {
|
|
return Err((AUTH_ERR_KIND_INVALID, "auth_kind_invalid"));
|
|
}
|
|
|
|
// Extract tags: signer_rpc, signer_method, signer_body_hash
|
|
let mut tag_rpc: Option<String> = None;
|
|
let mut tag_method: Option<String> = None;
|
|
let mut tag_body_hash: Option<String> = None;
|
|
|
|
for tag in &auth_event.tags {
|
|
if tag.kind() == "signer_rpc" {
|
|
tag_rpc = tag.get(1).map(|s| s.to_string());
|
|
} else if tag.kind() == "signer_method" {
|
|
tag_method = tag.get(1).map(|s| s.to_string());
|
|
} else if tag.kind() == "signer_body_hash" {
|
|
tag_body_hash = tag.get(1).map(|s| s.to_string());
|
|
}
|
|
}
|
|
|
|
let tag_rpc = tag_rpc.ok_or((AUTH_ERR_ENVELOPE_MALFORMED, "auth_envelope_malformed"))?;
|
|
let tag_method =
|
|
tag_method.ok_or((AUTH_ERR_ENVELOPE_MALFORMED, "auth_envelope_malformed"))?;
|
|
let tag_body_hash =
|
|
tag_body_hash.ok_or((AUTH_ERR_ENVELOPE_MALFORMED, "auth_envelope_malformed"))?;
|
|
|
|
// Verify tags match request
|
|
if tag_rpc != request_id || tag_method != method {
|
|
return Err((AUTH_ERR_ENVELOPE_MISMATCH, "auth_envelope_mismatch"));
|
|
}
|
|
|
|
// Compute body hash (SHA-256 of the params array)
|
|
let params = root.get("params").unwrap_or(&serde_json::Value::Null);
|
|
let params_compact = serde_json::to_string(params).unwrap_or_default();
|
|
let body_hash = nostr_core::crypto::sha256::sha256(params_compact.as_bytes());
|
|
let body_hash_hex = hex::encode(&body_hash);
|
|
|
|
if !tag_body_hash.eq_ignore_ascii_case(&body_hash_hex) {
|
|
return Err((AUTH_ERR_BODY_MISMATCH, "auth_body_mismatch"));
|
|
}
|
|
|
|
// Check timestamp skew
|
|
let skew = if skew_seconds <= 0 {
|
|
AUTH_DEFAULT_SKEW_SECONDS
|
|
} else {
|
|
skew_seconds
|
|
};
|
|
|
|
let now = current_timestamp();
|
|
let created = auth_event.created_at as i64;
|
|
if (now - created).abs() > skew as i64 {
|
|
return Err((AUTH_ERR_ENVELOPE_STALE, "auth_envelope_stale"));
|
|
}
|
|
|
|
// Extract pubkey
|
|
let pubkey_hex = auth_event.pubkey.to_string();
|
|
|
|
// Extract event ID for replay protection
|
|
let event_id = match &auth_event.id {
|
|
Some(id) => id.as_bytes(),
|
|
None => return Err((AUTH_ERR_ENVELOPE_MALFORMED, "auth_envelope_malformed")),
|
|
};
|
|
|
|
// Replay protection
|
|
if !cache.check_and_update(&pubkey_hex, created, event_id) {
|
|
return Err((AUTH_ERR_REPLAY_DETECTED, "auth_replay_detected"));
|
|
}
|
|
|
|
// Extract label from content
|
|
let label = auth_event.content.clone();
|
|
|
|
Ok((pubkey_hex, label))
|
|
}
|
|
|
|
/// Get current Unix timestamp in seconds.
|
|
fn current_timestamp() -> i64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_secs() as i64)
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_nonce_cache_new_timestamp() {
|
|
let mut cache = AuthNonceCache::new();
|
|
let event_id = [1u8; 32];
|
|
assert!(cache.check_and_update("pubkey1", 1000, &event_id));
|
|
}
|
|
|
|
#[test]
|
|
fn test_nonce_cache_same_timestamp_different_id() {
|
|
let mut cache = AuthNonceCache::new();
|
|
let id1 = [1u8; 32];
|
|
let id2 = [2u8; 32];
|
|
assert!(cache.check_and_update("pubkey1", 1000, &id1));
|
|
assert!(cache.check_and_update("pubkey1", 1000, &id2));
|
|
}
|
|
|
|
#[test]
|
|
fn test_nonce_cache_replay_rejected() {
|
|
let mut cache = AuthNonceCache::new();
|
|
let event_id = [1u8; 32];
|
|
assert!(cache.check_and_update("pubkey1", 1000, &event_id));
|
|
// Same timestamp + same event ID = replay
|
|
assert!(!cache.check_and_update("pubkey1", 1000, &event_id));
|
|
}
|
|
|
|
#[test]
|
|
fn test_nonce_cache_older_timestamp_rejected() {
|
|
let mut cache = AuthNonceCache::new();
|
|
let event_id = [1u8; 32];
|
|
assert!(cache.check_and_update("pubkey1", 1000, &event_id));
|
|
// Older timestamp = replay
|
|
assert!(!cache.check_and_update("pubkey1", 999, &event_id));
|
|
}
|
|
|
|
#[test]
|
|
fn test_nonce_cache_newer_timestamp_clears() {
|
|
let mut cache = AuthNonceCache::new();
|
|
let id1 = [1u8; 32];
|
|
let id2 = [2u8; 32];
|
|
assert!(cache.check_and_update("pubkey1", 1000, &id1));
|
|
// Newer timestamp clears the set, so id1 is acceptable again
|
|
assert!(cache.check_and_update("pubkey1", 1001, &id1));
|
|
assert!(cache.check_and_update("pubkey1", 1001, &id2));
|
|
}
|
|
|
|
#[test]
|
|
fn test_nonce_cache_different_pubkeys_independent() {
|
|
let mut cache = AuthNonceCache::new();
|
|
let event_id = [1u8; 32];
|
|
assert!(cache.check_and_update("pubkey1", 1000, &event_id));
|
|
// Different pubkey with same timestamp + event ID is fine
|
|
assert!(cache.check_and_update("pubkey2", 1000, &event_id));
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_request_no_auth() {
|
|
let mut cache = AuthNonceCache::new();
|
|
let request = r#"{"id":"1","method":"get_info","params":[]}"#;
|
|
let result = verify_request(request, &mut cache, 300);
|
|
assert_eq!(result, Err((AUTH_ERR_ENVELOPE_REQUIRED, "auth_envelope_required")));
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_request_malformed_json() {
|
|
let mut cache = AuthNonceCache::new();
|
|
let result = verify_request("not valid json", &mut cache, 300);
|
|
assert_eq!(result, Err((AUTH_ERR_ENVELOPE_MALFORMED, "auth_envelope_malformed")));
|
|
}
|
|
}
|