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    // ---------------------------------------------------------------------------------------
284    // GOLDEN WIRE VECTORS
285    //
286    // These pin the exact bytes dig-peer puts on the wire and the exact BLS-G1 key it derives from
287    // a fixed seed. They exist so a chia-crate uplift (chia-bls / chia-protocol / chia-traits) can
288    // be PROVEN byte-compatible with peers already deployed on the previous line, rather than
289    // assumed. Blessed on the chia-0.26 line; they MUST NOT change when the line moves.
290    //
291    // A changed byte here is a compatibility break with deployed peers, not a migration detail.
292    // ---------------------------------------------------------------------------------------
293
294    /// Two NON-UNIFORM, NON-CANCELLING peer ids for the vectors.
295    ///
296    /// A uniform pair is blind here: `correlation_from` XORs `sender[i]` against
297    /// `recipient[i].rotate_left(3)`, and e.g. `0x11 ^ 0x22.rotate_left(3) == 0` collapses the whole
298    /// digest to zeros — a fixture that cannot show a derivation change. These vary per byte and
299    /// exercise the `i % 24` fold, where bytes 8..=15 receive two contributions and 16..=31 one.
300    fn vector_sender() -> PeerId {
301        let mut b = [0u8; 32];
302        for (i, x) in b.iter_mut().enumerate() {
303            *x = (i as u8).wrapping_mul(7).wrapping_add(0x13);
304        }
305        PeerId::from_bytes(b)
306    }
307
308    fn vector_recipient() -> PeerId {
309        let mut b = [0u8; 32];
310        for (i, x) in b.iter_mut().enumerate() {
311            *x = (i as u8).wrapping_mul(11).wrapping_add(0xa7);
312        }
313        PeerId::from_bytes(b)
314    }
315
316    /// The one fixed seed every vector below derives from. Never a production key path.
317    const VECTOR_SEED: [u8; 32] = [
318        0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
319        0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d,
320        0x1e, 0x1f,
321    ];
322
323    fn hex(bytes: &[u8]) -> String {
324        bytes.iter().map(|b| format!("{b:02x}")).collect()
325    }
326
327    /// **Proves:** `SecretKey::from_seed` -> compressed BLS-G1 public key is byte-identical to the
328    /// value the chia-0.26 line produced. This is the cryptographic anchor of the peer identity the
329    /// mTLS binding commits to, so a change here silently re-identifies every node.
330    /// **Catches:** a chia-bls uplift that alters EIP-2333 key derivation or G1 compression.
331    #[test]
332    fn vector_bls_g1_public_key_from_fixed_seed() {
333        let pk = public_key_bytes(&SecretKey::from_seed(&VECTOR_SEED));
334        assert_eq!(hex(&pk), "8f336467f057b373bb3c43815a10ec131119d1bf50c14fa3f9ad86c0ec074f920f936a5315a8365a37fee0afa34c32c6", "BLS-G1 derivation drifted");
335    }
336
337    /// **Proves:** the cleartext `correlation_id` routing field is derived byte-identically. Peers
338    /// match responses to requests on this value, so drift misroutes every in-flight RPC.
339    /// **Catches:** a `Bytes32` construction or byte-order change across the uplift.
340    #[test]
341    fn vector_correlation_id_for_a_fixed_directed_pair() {
342        let mut id = SealingIdentity::new(SecretKey::from_seed(&VECTOR_SEED), 7);
343        let recipient_pub = public_key_bytes(&SecretKey::from_seed(&[0x5au8; 32]));
344        let (_wire, correlation) = id
345            .seal_request(
346                vector_sender(),
347                vector_recipient(),
348                &recipient_pub,
349                b"vector-payload",
350            )
351            .expect("seal succeeds");
352        assert_eq!(
353            hex(correlation.as_ref()),
354            "0000000000000001e8982b38b82918e8b402f1613edf7f1e3999fa5b83d26191",
355            "correlation id derivation drifted"
356        );
357    }
358
359    /// **Proves:** the sealed envelope's Chia-Streamable HEADER — version, message type, flags,
360    /// correlation id, sender/recipient `Bytes32` DIDs, key epoch — serializes to the exact bytes a
361    /// deployed peer expects. The sealed body carries an ephemeral KEM share and a timestamp and is
362    /// therefore not byte-stable; the header is, and it is the part a peer parses to route.
363    /// **Catches:** a `chia-traits` Streamable encoding change (field order, integer width,
364    /// `Option` tagging) that would make a 0.36-built peer unreadable to a 0.26-built one.
365    #[test]
366    fn vector_sealed_envelope_header_bytes() {
367        let mut id = SealingIdentity::new(SecretKey::from_seed(&VECTOR_SEED), 7);
368        let recipient_pub = public_key_bytes(&SecretKey::from_seed(&[0x5au8; 32]));
369        let (wire, _correlation) = id
370            .seal_request(
371                vector_sender(),
372                vector_recipient(),
373                &recipient_pub,
374                b"vector-payload",
375            )
376            .expect("seal succeeds");
377        let envelope = DigMessageEnvelope::from_bytes(&wire).expect("envelope parses");
378        let header = envelope.header_bytes().expect("header serializes");
379        assert_eq!(hex(&header), "0100005250050000000000000001e8982b38b82918e8b402f1613edf7f1e3999fa5b83d26191131a21282f363d444b525960676e757c838a91989fa6adb4bbc2c9d0d7dee5eca7b2bdc8d3dee9f4ff0a15202b36414c57626d78838e99a4afbac5d0dbe6f1fc0000000700", "envelope header encoding drifted");
380    }
381
382    /// **Proves:** the transport `peer_id` -> `Bytes32` DID mapping is the identity mapping and its
383    /// Streamable encoding is the raw 32 bytes, unprefixed and unreordered.
384    /// **Catches:** a `chia-protocol` `Bytes32` representation change that would rewrite every DID
385    /// field on the wire.
386    #[test]
387    fn vector_peer_id_to_bytes32_streamable_encoding() {
388        let mut id = SealingIdentity::new(SecretKey::from_seed(&VECTOR_SEED), 7);
389        let recipient_pub = public_key_bytes(&SecretKey::from_seed(&[0x5au8; 32]));
390        let (wire, _c) = id
391            .seal_request(
392                vector_sender(),
393                vector_recipient(),
394                &recipient_pub,
395                b"vector-payload",
396            )
397            .expect("seal succeeds");
398        let envelope = DigMessageEnvelope::from_bytes(&wire).expect("envelope parses");
399        assert_eq!(
400            hex(&chia_traits::Streamable::to_bytes(&envelope.sender).unwrap()),
401            "131a21282f363d444b525960676e757c838a91989fa6adb4bbc2c9d0d7dee5ec"
402        );
403        assert_eq!(
404            hex(&chia_traits::Streamable::to_bytes(&envelope.recipient).unwrap()),
405            "a7b2bdc8d3dee9f4ff0a15202b36414c57626d78838e99a4afbac5d0dbe6f1fc"
406        );
407    }
408
409    fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool {
410        haystack.windows(needle.len()).any(|w| w == needle)
411    }
412}