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 fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool {
284 haystack.windows(needle.len()).any(|w| w == needle)
285 }
286}