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.
This commit is contained in:
2026-03-04 20:54:55 +01:00
parent 1768f85258
commit 496f83067a

View File

@@ -7,6 +7,7 @@ use prost::Message;
use quicproquo_proto::qpq::v1; use quicproquo_proto::qpq::v1;
use quicproquo_rpc::error::RpcStatus; use quicproquo_rpc::error::RpcStatus;
use quicproquo_rpc::method::{HandlerResult, RequestContext}; use quicproquo_rpc::method::{HandlerResult, RequestContext};
use sha2::{Digest, Sha256};
use tokio::sync::Notify; use tokio::sync::Notify;
use crate::domain::delivery::DeliveryService; use crate::domain::delivery::DeliveryService;
@@ -15,6 +16,29 @@ use crate::hooks::{HookAction, MessageEvent};
use super::{require_auth, ServerState}; 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<u8> {
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<ServerState>, ctx: RequestContext) -> HandlerResult { pub async fn handle_enqueue(state: Arc<ServerState>, ctx: RequestContext) -> HandlerResult {
let identity_key = match require_auth(&state, &ctx) { let identity_key = match require_auth(&state, &ctx) {
Ok(ik) => ik, Ok(ik) => ik,
@@ -71,7 +95,7 @@ pub async fn handle_enqueue(state: Arc<ServerState>, ctx: RequestContext) -> Han
// Fire hook. // Fire hook.
let action = state.hooks.on_message_enqueue(&MessageEvent { let action = state.hooks.on_message_enqueue(&MessageEvent {
sender_identity: Some(identity_key), sender_identity: Some(identity_key),
recipient_key: req.recipient_key, recipient_key: req.recipient_key.clone(),
channel_id: req.channel_id, channel_id: req.channel_id,
payload_len: req.payload.len(), payload_len: req.payload.len(),
seq: resp.seq, seq: resp.seq,
@@ -80,9 +104,18 @@ pub async fn handle_enqueue(state: Arc<ServerState>, ctx: RequestContext) -> Han
return HandlerResult::err(RpcStatus::Forbidden, &reason); 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 { let proto = v1::EnqueueResponse {
seq: resp.seq, seq: resp.seq,
delivery_proof: resp.delivery_proof, delivery_proof,
duplicate: false, duplicate: false,
}; };
HandlerResult::ok(Bytes::from(proto.encode_to_vec())) HandlerResult::ok(Bytes::from(proto.encode_to_vec()))