diff --git a/crates/buzz-relay/src/api/operator_revocations.rs b/crates/buzz-relay/src/api/operator_revocations.rs index 72939be5a0..e029f3e68a 100644 --- a/crates/buzz-relay/src/api/operator_revocations.rs +++ b/crates/buzz-relay/src/api/operator_revocations.rs @@ -13,7 +13,7 @@ use axum::{ response::Json, }; use chrono::{DateTime, SecondsFormat, Utc}; -use nostr::PublicKey; +use nostr::{secp256k1::XOnlyPublicKey, PublicKey}; use serde::{Deserialize, Serialize}; use serde_json::Value; use uuid::Uuid; @@ -34,7 +34,7 @@ struct RevocationNotificationRequest { id: String, #[serde(rename = "type")] notification_type: String, - target_pubkey: String, + target_pubkey: XOnlyPublicKey, occurred_at: String, } @@ -68,7 +68,7 @@ fn validate_notification( return Err("id must be a canonical UUID"); } - let target_pubkey = parse_target_pubkey(&request.target_pubkey)?; + let target_pubkey = PublicKey::from(request.target_pubkey); let occurred_at = validate_occurred_at(&request.occurred_at)?; Ok(ValidatedRevocationNotification { @@ -78,18 +78,6 @@ fn validate_notification( }) } -fn parse_target_pubkey(value: &str) -> Result { - let pubkey = PublicKey::from_hex(value) - .map_err(|_| "target_pubkey must be 64 lowercase hex characters")?; - if pubkey.to_hex() != value { - return Err("target_pubkey must be 64 lowercase hex characters"); - } - pubkey - .xonly() - .map_err(|_| "target_pubkey is not a valid x-only secp256k1 public key")?; - Ok(pubkey) -} - fn validate_occurred_at(value: &str) -> Result, &'static str> { if value.len() > MAX_OCCURRED_AT_BYTES || !value.ends_with('Z') { return Err("occurred_at must be an RFC3339 UTC timestamp ending in Z"); @@ -165,11 +153,17 @@ mod tests { version: 1, id: "550e8400-e29b-41d4-a716-446655440000".to_string(), notification_type: "identity.revoked".to_string(), - target_pubkey: VALID_PUBKEY.to_string(), + target_pubkey: VALID_PUBKEY.parse().expect("valid x-only public key"), occurred_at: "2026-09-21T12:34:56Z".to_string(), } } + fn request_json(target_pubkey: &str) -> String { + format!( + r#"{{"version":1,"id":"550e8400-e29b-41d4-a716-446655440000","type":"identity.revoked","target_pubkey":"{target_pubkey}","occurred_at":"2026-09-21T12:34:56Z"}}"# + ) + } + #[test] fn exact_contract_is_valid() { let notification = validate_notification(valid_request()).expect("valid notification"); @@ -204,14 +198,24 @@ mod tests { let mut noncanonical_id = valid_request(); noncanonical_id.id = "550E8400-E29B-41D4-A716-446655440000".to_string(); assert!(validate_notification(noncanonical_id).is_err()); + } - let mut uppercase_key = valid_request(); - uppercase_key.target_pubkey = VALID_PUBKEY.to_uppercase(); - assert!(validate_notification(uppercase_key).is_err()); + #[test] + fn target_pubkey_deserialization_uses_sdk_hex_type() { + let uppercase = VALID_PUBKEY.to_uppercase(); + let request = + serde_json::from_str::(&request_json(&uppercase)) + .expect("uppercase hex public key"); + let notification = validate_notification(request).expect("valid notification"); + assert_eq!(notification.target_pubkey.to_hex(), VALID_PUBKEY); - let mut invalid_point = valid_request(); - invalid_point.target_pubkey = "0".repeat(64); - assert!(validate_notification(invalid_point).is_err()); + let invalid_point = "0".repeat(64); + for invalid in ["not-hex", invalid_point.as_str()] { + assert!( + serde_json::from_str::(&request_json(invalid)) + .is_err() + ); + } } #[test] diff --git a/docs/operator-revocation-notifications.md b/docs/operator-revocation-notifications.md index 98ef8bba5e..887386e855 100644 --- a/docs/operator-revocation-notifications.md +++ b/docs/operator-revocation-notifications.md @@ -14,7 +14,7 @@ Authorization: Nostr "version": 1, "id": "550e8400-e29b-41d4-a716-446655440000", "type": "identity.revoked", - "target_pubkey": "<64 lowercase hex characters>", + "target_pubkey": "<64 hex characters>", "occurred_at": "2026-09-21T12:34:56Z" } ``` @@ -28,11 +28,13 @@ accepts at most 4 KiB. The JSON contract is strict: unknown fields are rejected; `version` and `type` have the fixed values above; `id` is a canonical hyphenated UUID; -`target_pubkey` is a valid lowercase x-only secp256k1 public key; and -`occurred_at` is an RFC3339 UTC timestamp ending in `Z`. Fractional seconds are -accepted. An equivalent `+00:00` suffix is not accepted because the wire -contract requires `Z`. The occurrence time is informational and has no recency -requirement; NIP-98 independently supplies authentication freshness. +`target_pubkey` is a valid 64-character hexadecimal x-only secp256k1 public +key; and `occurred_at` is an RFC3339 UTC timestamp ending in `Z`. Public-key +hex is accepted in either letter case and normalized to lowercase in logs. +Fractional seconds are accepted. An equivalent `+00:00` suffix is not accepted +because the wire contract requires `Z`. The occurrence time is informational +and has no recency requirement; NIP-98 independently supplies authentication +freshness. Success returns: