mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
229 lines
7.8 KiB
Rust
229 lines
7.8 KiB
Rust
//! Deployment-operator revocation notification ingress.
|
|
//!
|
|
//! This surface validates and logs a normalized notification. It deliberately
|
|
//! does not revoke identities, disconnect sessions, persist state, publish to
|
|
//! Redis, or claim that enforcement occurred.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use axum::{
|
|
body::Bytes,
|
|
extract::State,
|
|
http::{header, HeaderMap, StatusCode},
|
|
response::Json,
|
|
};
|
|
use chrono::{DateTime, SecondsFormat, Utc};
|
|
use nostr::{secp256k1::XOnlyPublicKey, PublicKey};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
use uuid::Uuid;
|
|
|
|
use crate::state::AppState;
|
|
|
|
use super::{api_error, operator::authorize_operator_request};
|
|
|
|
const PATH: &str = "/operator/revocation-notifications";
|
|
const NOTIFICATION_VERSION: u32 = 1;
|
|
const NOTIFICATION_TYPE: &str = "identity.revoked";
|
|
const MAX_OCCURRED_AT_BYTES: usize = 64;
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct RevocationNotificationRequest {
|
|
version: u32,
|
|
id: String,
|
|
#[serde(rename = "type")]
|
|
notification_type: String,
|
|
target_pubkey: XOnlyPublicKey,
|
|
occurred_at: String,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ValidatedRevocationNotification {
|
|
id: String,
|
|
target_pubkey: PublicKey,
|
|
occurred_at: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub(crate) struct RevocationNotificationResponse {
|
|
accepted: bool,
|
|
status: &'static str,
|
|
revocation_applied: bool,
|
|
id: String,
|
|
}
|
|
|
|
fn validate_notification(
|
|
request: RevocationNotificationRequest,
|
|
) -> Result<ValidatedRevocationNotification, &'static str> {
|
|
if request.version != NOTIFICATION_VERSION {
|
|
return Err("version must be 1");
|
|
}
|
|
if request.notification_type != NOTIFICATION_TYPE {
|
|
return Err("type must be identity.revoked");
|
|
}
|
|
|
|
let id = Uuid::parse_str(&request.id).map_err(|_| "id must be a canonical UUID")?;
|
|
if id.hyphenated().to_string() != request.id {
|
|
return Err("id must be a canonical UUID");
|
|
}
|
|
|
|
let target_pubkey = PublicKey::from(request.target_pubkey);
|
|
let occurred_at = validate_occurred_at(&request.occurred_at)?;
|
|
|
|
Ok(ValidatedRevocationNotification {
|
|
id: request.id,
|
|
target_pubkey,
|
|
occurred_at,
|
|
})
|
|
}
|
|
|
|
fn validate_occurred_at(value: &str) -> Result<DateTime<Utc>, &'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");
|
|
}
|
|
DateTime::parse_from_rfc3339(value)
|
|
.map(|timestamp| timestamp.with_timezone(&Utc))
|
|
.map_err(|_| "occurred_at must be an RFC3339 UTC timestamp ending in Z")
|
|
}
|
|
|
|
/// Validate and log an operator-authenticated revocation notification.
|
|
///
|
|
/// A successful response acknowledges only that the bounded notification was
|
|
/// authenticated, validated, and emitted to structured logs. It performs no
|
|
/// durable write, deduplication, retry scheduling, session closure, or access
|
|
/// revocation.
|
|
pub(crate) async fn receive_revocation_notification(
|
|
State(state): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
body: Bytes,
|
|
) -> Result<Json<RevocationNotificationResponse>, (StatusCode, Json<Value>)> {
|
|
let signer =
|
|
authorize_operator_request(&state, &headers, "POST", PATH, None, Some(&body)).await?;
|
|
|
|
if headers
|
|
.get(header::CONTENT_TYPE)
|
|
.and_then(|value| value.to_str().ok())
|
|
!= Some("application/json")
|
|
{
|
|
return Err(api_error(
|
|
StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
|
"content-type must be application/json",
|
|
));
|
|
}
|
|
|
|
let request: RevocationNotificationRequest = serde_json::from_slice(&body).map_err(|_| {
|
|
api_error(
|
|
StatusCode::BAD_REQUEST,
|
|
"invalid revocation notification JSON",
|
|
)
|
|
})?;
|
|
let notification = validate_notification(request)
|
|
.map_err(|message| api_error(StatusCode::BAD_REQUEST, message))?;
|
|
let occurred_at = notification
|
|
.occurred_at
|
|
.to_rfc3339_opts(SecondsFormat::AutoSi, true);
|
|
|
|
tracing::info!(
|
|
notification_id = %notification.id,
|
|
signer = %signer.to_hex(),
|
|
target_pubkey = %notification.target_pubkey.to_hex(),
|
|
occurred_at = %occurred_at,
|
|
status = "logged_stub",
|
|
revocation_applied = false,
|
|
"operator revocation notification logged; no revocation action applied"
|
|
);
|
|
|
|
Ok(Json(RevocationNotificationResponse {
|
|
accepted: true,
|
|
status: "logged_stub",
|
|
revocation_applied: false,
|
|
id: notification.id,
|
|
}))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{validate_notification, RevocationNotificationRequest};
|
|
|
|
const VALID_PUBKEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
|
|
|
|
fn valid_request() -> RevocationNotificationRequest {
|
|
RevocationNotificationRequest {
|
|
version: 1,
|
|
id: "550e8400-e29b-41d4-a716-446655440000".to_string(),
|
|
notification_type: "identity.revoked".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");
|
|
assert_eq!(notification.id, "550e8400-e29b-41d4-a716-446655440000");
|
|
assert_eq!(notification.target_pubkey.to_hex(), VALID_PUBKEY);
|
|
}
|
|
|
|
#[test]
|
|
fn timestamp_allows_fractional_seconds_but_requires_z() {
|
|
let mut fractional = valid_request();
|
|
fractional.occurred_at = "2026-09-21T12:34:56.123Z".to_string();
|
|
assert!(validate_notification(fractional).is_ok());
|
|
|
|
let mut offset = valid_request();
|
|
offset.occurred_at = "2026-09-21T12:34:56+00:00".to_string();
|
|
assert_eq!(
|
|
validate_notification(offset).expect_err("offset form must fail"),
|
|
"occurred_at must be an RFC3339 UTC timestamp ending in Z"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn semantic_contract_fields_are_strict() {
|
|
let mut wrong_version = valid_request();
|
|
wrong_version.version = 2;
|
|
assert!(validate_notification(wrong_version).is_err());
|
|
|
|
let mut wrong_type = valid_request();
|
|
wrong_type.notification_type = "identity.disabled".to_string();
|
|
assert!(validate_notification(wrong_type).is_err());
|
|
|
|
let mut noncanonical_id = valid_request();
|
|
noncanonical_id.id = "550E8400-E29B-41D4-A716-446655440000".to_string();
|
|
assert!(validate_notification(noncanonical_id).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn target_pubkey_deserialization_uses_sdk_hex_type() {
|
|
let uppercase = VALID_PUBKEY.to_uppercase();
|
|
let request =
|
|
serde_json::from_str::<RevocationNotificationRequest>(&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 invalid_point = "0".repeat(64);
|
|
for invalid in ["not-hex", invalid_point.as_str()] {
|
|
assert!(
|
|
serde_json::from_str::<RevocationNotificationRequest>(&request_json(invalid))
|
|
.is_err()
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_json_fields_are_rejected() {
|
|
let body = format!(
|
|
r#"{{"version":1,"id":"550e8400-e29b-41d4-a716-446655440000","type":"identity.revoked","target_pubkey":"{VALID_PUBKEY}","occurred_at":"2026-09-21T12:34:56Z","command":"disconnect"}}"#
|
|
);
|
|
assert!(serde_json::from_str::<RevocationNotificationRequest>(&body).is_err());
|
|
}
|
|
}
|