Files
signer/src/auth_envelope.rs
T

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")));
}
}