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(¶ms).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}