Skip to main content

dig_peer/
seal.rs

1//! The §5.4 end-to-end seal applied to **directed** RPC — layered ON TOP of mTLS.
2//!
3//! mTLS authenticates and encrypts the pipe, but any intermediary that terminates TLS (a relay, a
4//! hole-punch forwarder) sees the plaintext of what it forwards. For a **directed** RPC — a request
5//! carrying content specific to the recipient peer — that is not enough (CLAUDE.md §5.4 / #1075). So
6//! dig-peer seals the request payload to the peer's verified BLS-G1 identity via [`dig_message`], so
7//! only the receiving key can open it; the relay forwards ciphertext it cannot read.
8//!
9//! ## Identity model (node-to-node)
10//!
11//! A DIG node's sealing identity is its BLS-G1 machine identity — the same key that signed its mTLS
12//! cert's #1204 binding (so a peer's `peer_bls_pub`, captured at the handshake, is exactly the key to
13//! seal to). dig-message's DID fields carry routing identity; for node-to-node RPC we use each peer's
14//! `peer_id` (`SHA-256(SPKI DER)`, 32 bytes) as the `Bytes32` identity id, and the receiver resolves
15//! the sender's BLS key from the binding it captured for that same connection. No DID registry is
16//! required: both ends captured each other's cert-bound BLS key at the mTLS handshake.
17//!
18//! ## Fail-closed
19//!
20//! If the peer presented no verified BLS-G1 key, or no local sealing identity is configured, a
21//! directed call is REFUSED (never downgraded to an unsealed send). A response that fails
22//! authenticated-open, signature, replay, or correlation checks is discarded, never surfaced.
23
24use chia_protocol::Bytes32;
25use chia_traits::Streamable as _;
26use dig_message::{
27    envelope::InteractionShape, open_message, seal_message, DigMessageEnvelope, ReplayGuard,
28    SealParams,
29};
30use dig_tls::bls::{public_key_bytes, SecretKey};
31use dig_tls::PeerId;
32
33use crate::error::{DigPeerError, Result};
34
35/// The dig-message type id dig-peer stamps on a sealed RPC envelope. A dedicated id keeps peer-RPC
36/// traffic distinguishable from other directed message types (chat/email) in the shared registry.
37///
38/// Additive-only (SPEC §5.1 of dig-message): once assigned, never renumbered.
39pub const RPC_MESSAGE_TYPE: u32 = 0x0000_5250; // "RP"
40
41/// The local identity dig-peer uses to seal directed RPCs and open sealed responses.
42///
43/// It holds this node's BLS-G1 identity secret key (the machine identity that signed its mTLS cert
44/// binding) and the key epoch. The DID fields for the seal are derived from the connection's
45/// `peer_id`s, so this type is just the secret material plus the receive-side replay guard.
46pub struct SealingIdentity {
47    /// This node's BLS-G1 identity secret key (the ONE key that signs G2 and does the static G1 DH).
48    secret_key: SecretKey,
49    /// The key epoch, for rotation disambiguation.
50    epoch: u32,
51    /// The anti-replay guard for opening sealed responses on this connection.
52    replay_guard: ReplayGuard,
53    /// The strictly-monotonic per-connection send counter (§5.6 anti-replay).
54    counter: u64,
55}
56
57impl std::fmt::Debug for SealingIdentity {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        f.debug_struct("SealingIdentity")
60            .field("epoch", &self.epoch)
61            .field("counter", &self.counter)
62            .field("secret_key", &"<redacted BLS sk>")
63            .finish()
64    }
65}
66
67impl SealingIdentity {
68    /// Create a sealing identity from this node's BLS-G1 identity secret key and key epoch.
69    ///
70    /// The secret key MUST be the same identity key that signed this node's mTLS cert binding, so
71    /// that the peer resolves this node's sender key from the binding it captured at the handshake.
72    #[must_use]
73    pub fn new(secret_key: SecretKey, epoch: u32) -> Self {
74        Self {
75            secret_key,
76            epoch,
77            replay_guard: ReplayGuard::default(),
78            counter: 0,
79        }
80    }
81
82    /// This identity's BLS-G1 public key (48-byte compressed) — the value a peer must capture from
83    /// this node's cert binding to open messages this identity seals.
84    #[must_use]
85    pub fn public_key(&self) -> [u8; 48] {
86        public_key_bytes(&self.secret_key)
87    }
88
89    /// Seal `payload` as a directed request to `recipient` (their BLS-G1 key), authored by `sender`.
90    ///
91    /// Returns the byte-serialized sealed [`DigMessageEnvelope`] to write on the wire, plus the
92    /// `correlation_id` the caller matches the response against. Advances the send counter.
93    ///
94    /// # Errors
95    /// [`DigPeerError::Seal`] if the recipient key fails the subgroup check or the AEAD/compression
96    /// step fails.
97    pub fn seal_request(
98        &mut self,
99        sender: PeerId,
100        recipient: PeerId,
101        recipient_pub: &[u8; 48],
102        payload: &[u8],
103    ) -> Result<(Vec<u8>, Bytes32)> {
104        self.counter = self.counter.wrapping_add(1);
105        let correlation_id = correlation_from(sender, recipient, self.counter);
106        let now_ms = now_ms();
107        let params = SealParams {
108            sender_sk: &self.secret_key,
109            sender: peer_id_to_bytes32(sender),
110            sender_epoch: self.epoch,
111            recipient: peer_id_to_bytes32(recipient),
112            recipient_pub,
113            message_type: RPC_MESSAGE_TYPE,
114            shape: InteractionShape::Request,
115            correlation_id,
116            stream: None,
117            counter: self.counter,
118            timestamp_ms: now_ms,
119            expires_at: 0,
120            payload,
121        };
122        let envelope = seal_message(&params).map_err(|e| DigPeerError::Seal(e.to_string()))?;
123        let bytes = envelope
124            .to_bytes()
125            .map_err(|e| DigPeerError::Seal(e.to_string()))?;
126        Ok((bytes, correlation_id))
127    }
128
129    /// Open a sealed response, verifying it was authored by `sender` (their captured BLS-G1 key),
130    /// sealed to this identity, and correlates with `expected_correlation`.
131    ///
132    /// # Errors
133    /// [`DigPeerError::Seal`] if authenticated-open / signature / replay / freshness verification
134    /// fails; [`DigPeerError::Misdelivered`] if the response's `correlation_id` does not match.
135    pub fn open_response(
136        &mut self,
137        sender_pub: &[u8; 48],
138        expected_correlation: Bytes32,
139        bytes: &[u8],
140    ) -> Result<Vec<u8>> {
141        let envelope =
142            DigMessageEnvelope::from_bytes(bytes).map_err(|e| DigPeerError::Seal(e.to_string()))?;
143        let resolver = |_did: Bytes32, _epoch: u32| -> Option<[u8; 48]> { Some(*sender_pub) };
144        let opened = open_message(
145            &self.secret_key,
146            &envelope,
147            resolver,
148            &mut self.replay_guard,
149            now_ms(),
150        )
151        .map_err(|e| DigPeerError::Seal(e.to_string()))?;
152        if opened.correlation_id != expected_correlation {
153            return Err(DigPeerError::Misdelivered);
154        }
155        Ok(opened.payload)
156    }
157}
158
159/// Convert a transport `peer_id` (`SHA-256(SPKI DER)`, 32 bytes) into the dig-message `Bytes32`
160/// identity id used for the seal's DID fields.
161fn peer_id_to_bytes32(peer_id: PeerId) -> Bytes32 {
162    Bytes32::new(*peer_id.as_bytes())
163}
164
165/// Derive a deterministic-but-unique correlation id for a request from the directed pair and the
166/// send counter. It need not be secret (it is a cleartext routing/multiplex field) — only unique per
167/// in-flight request on this connection so the matching response is unambiguous.
168fn correlation_from(sender: PeerId, recipient: PeerId, counter: u64) -> Bytes32 {
169    let mut bytes = [0u8; 32];
170    bytes[..8].copy_from_slice(&counter.to_be_bytes());
171    // Fold both peer_ids in so a correlation id is unique to this directed pair, not just the counter.
172    for (i, b) in sender.as_bytes().iter().enumerate() {
173        bytes[8 + (i % 24)] ^= *b;
174    }
175    for (i, b) in recipient.as_bytes().iter().enumerate() {
176        bytes[8 + (i % 24)] ^= b.rotate_left(3);
177    }
178    Bytes32::new(bytes)
179}
180
181/// The receiver's wall clock in Unix milliseconds — the freshness/expiry basis dig-message enforces.
182fn now_ms() -> u64 {
183    use std::time::{SystemTime, UNIX_EPOCH};
184    SystemTime::now()
185        .duration_since(UNIX_EPOCH)
186        .map(|d| d.as_millis() as u64)
187        .unwrap_or(0)
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    /// A deterministic BLS secret key from a label — test-only, never a production key path.
195    fn sk(label: &str) -> SecretKey {
196        let mut seed = [0u8; 32];
197        let bytes = label.as_bytes();
198        seed[..bytes.len().min(32)].copy_from_slice(&bytes[..bytes.len().min(32)]);
199        SecretKey::from_seed(&seed)
200    }
201
202    fn pid(byte: u8) -> PeerId {
203        PeerId::from_bytes([byte; 32])
204    }
205
206    /// **Proves:** a sealed directed request is genuine ciphertext — the plaintext method name never
207    /// appears in the on-wire bytes — and the intended recipient recovers it exactly.
208    /// **Catches:** a regression that sends a directed payload in the clear (defeating §5.4).
209    #[test]
210    fn sealed_request_is_ciphertext_and_round_trips_to_the_intended_recipient() {
211        let sender_sk = sk("seal/sender");
212        let recipient_sk = sk("seal/recipient");
213        let recipient_pub = public_key_bytes(&recipient_sk);
214        let sender_pub = public_key_bytes(&sender_sk);
215
216        let mut sender = SealingIdentity::new(sender_sk, 0);
217        let plaintext = br#"{"jsonrpc":"2.0","id":1,"method":"dig.getPeers"}"#;
218        let (wire, correlation) = sender
219            .seal_request(pid(0xAA), pid(0xBB), &recipient_pub, plaintext)
220            .expect("seal succeeds");
221
222        // The sensitive method name is NOT present in the on-wire bytes.
223        assert!(
224            !contains_subslice(&wire, b"dig.getPeers"),
225            "the plaintext method name leaked into the sealed on-wire bytes"
226        );
227
228        // The recipient opens it and recovers the exact plaintext (as if it were the response path).
229        let mut recipient = SealingIdentity::new(recipient_sk, 0);
230        let recovered = recipient
231            .open_response(&sender_pub, correlation, &wire)
232            .expect("recipient opens the sealed message");
233        assert_eq!(recovered, plaintext);
234    }
235
236    /// **Proves:** a directed message sealed to peer X cannot be opened by a different peer Y — the
237    /// seal binds confidentiality to the recipient's key, not merely to the mTLS pipe.
238    /// **Catches:** a mis-targeted seal (wrong recipient key) that would let the wrong node read it.
239    #[test]
240    fn message_sealed_to_one_peer_cannot_be_opened_by_another() {
241        let sender_sk = sk("wrong/sender");
242        let sender_pub = public_key_bytes(&sender_sk);
243        let intended_pub = public_key_bytes(&sk("wrong/intended"));
244        let wrong_sk = sk("wrong/eavesdropper");
245
246        let mut sender = SealingIdentity::new(sender_sk, 0);
247        let (wire, correlation) = sender
248            .seal_request(pid(1), pid(2), &intended_pub, b"secret-directed-payload")
249            .expect("seal succeeds");
250
251        let mut wrong = SealingIdentity::new(wrong_sk, 0);
252        let opened = wrong.open_response(&sender_pub, correlation, &wire);
253        assert!(
254            matches!(opened, Err(DigPeerError::Seal(_))),
255            "a peer the message was NOT sealed to must fail to open it, got {opened:?}"
256        );
257    }
258
259    /// **Proves:** a response whose correlation id does not match the request is rejected as a
260    /// misdelivery rather than surfaced to the caller.
261    /// **Catches:** a client that accepts a response correlated to a different in-flight request.
262    #[test]
263    fn mismatched_correlation_is_rejected_as_misdelivery() {
264        let sender_sk = sk("corr/sender");
265        let sender_pub = public_key_bytes(&sender_sk);
266        let recipient_sk = sk("corr/recipient");
267        let recipient_pub = public_key_bytes(&recipient_sk);
268
269        let mut sender = SealingIdentity::new(sender_sk, 0);
270        let (wire, _correlation) = sender
271            .seal_request(pid(3), pid(4), &recipient_pub, b"payload")
272            .expect("seal succeeds");
273
274        let mut recipient = SealingIdentity::new(recipient_sk, 0);
275        let wrong_correlation = Bytes32::new([0x77; 32]);
276        let opened = recipient.open_response(&sender_pub, wrong_correlation, &wire);
277        assert!(
278            matches!(opened, Err(DigPeerError::Misdelivered)),
279            "a mismatched correlation must be a Misdelivery, got {opened:?}"
280        );
281    }
282
283    fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool {
284        haystack.windows(needle.len()).any(|w| w == needle)
285    }
286}