From 496f83067a264d53fce17377df899c3283e83405 Mon Sep 17 00:00:00 2001 From: Christian Nennemann Date: Wed, 4 Mar 2026 20:54:55 +0100 Subject: [PATCH] feat(delivery): add server-signed delivery proof on enqueue The server now produces a 96-byte Ed25519-signed delivery proof for every enqueued message: SHA-256(seq || recipient_key || timestamp_ms) followed by the server's Ed25519 signature. Clients can verify the proof using verify_delivery_proof() in quicproquo-core to get cryptographic evidence the server accepted their message. --- .../src/v2_handlers/delivery.rs | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/quicproquo-server/src/v2_handlers/delivery.rs b/crates/quicproquo-server/src/v2_handlers/delivery.rs index fc266d6..30fc81c 100644 --- a/crates/quicproquo-server/src/v2_handlers/delivery.rs +++ b/crates/quicproquo-server/src/v2_handlers/delivery.rs @@ -7,6 +7,7 @@ use prost::Message; use quicproquo_proto::qpq::v1; use quicproquo_rpc::error::RpcStatus; use quicproquo_rpc::method::{HandlerResult, RequestContext}; +use sha2::{Digest, Sha256}; use tokio::sync::Notify; use crate::domain::delivery::DeliveryService; @@ -15,6 +16,29 @@ use crate::hooks::{HookAction, MessageEvent}; use super::{require_auth, ServerState}; +/// Build a 96-byte delivery proof: `SHA-256(seq || recipient_key || timestamp_ms) || Ed25519(hash)`. +/// +/// The sender stores this as cryptographic evidence that the server enqueued the message. +fn build_delivery_proof( + signing_key: &quicproquo_core::IdentityKeypair, + seq: u64, + recipient_key: &[u8], + timestamp_ms: u64, +) -> Vec { + let mut hasher = Sha256::new(); + hasher.update(seq.to_le_bytes()); + hasher.update(recipient_key); + hasher.update(timestamp_ms.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + + let sig = signing_key.sign_raw(&hash); + + let mut proof = vec![0u8; 96]; + proof[..32].copy_from_slice(&hash); + proof[32..].copy_from_slice(&sig); + proof +} + pub async fn handle_enqueue(state: Arc, ctx: RequestContext) -> HandlerResult { let identity_key = match require_auth(&state, &ctx) { Ok(ik) => ik, @@ -71,7 +95,7 @@ pub async fn handle_enqueue(state: Arc, ctx: RequestContext) -> Han // Fire hook. let action = state.hooks.on_message_enqueue(&MessageEvent { sender_identity: Some(identity_key), - recipient_key: req.recipient_key, + recipient_key: req.recipient_key.clone(), channel_id: req.channel_id, payload_len: req.payload.len(), seq: resp.seq, @@ -80,9 +104,18 @@ pub async fn handle_enqueue(state: Arc, ctx: RequestContext) -> Han return HandlerResult::err(RpcStatus::Forbidden, &reason); } + // Build server-signed delivery proof. + let timestamp_ms = crate::auth::current_timestamp(); + let delivery_proof = build_delivery_proof( + &state.signing_key, + resp.seq, + &req.recipient_key, + timestamp_ms, + ); + let proto = v1::EnqueueResponse { seq: resp.seq, - delivery_proof: resp.delivery_proof, + delivery_proof, duplicate: false, }; HandlerResult::ok(Bytes::from(proto.encode_to_vec()))