Skip to main content

dig_message/
transcript.rs

1//! The signed transcript (SPEC §5.1 / §5.1a) — the domain-separated byte string the sender BLS-signs
2//! and the recipient verifies, binding EVERY security-relevant field so nothing is malleable.
3//!
4//! The signed message is `SIG_DOMAIN || transcript` where `SIG_DOMAIN = "DIGNET-MSG:dig-message/v1"`
5//! (SPEC §5.1a) — a fixed ASCII augmentation that keeps a dig-message signature un-confusable with a
6//! Chia spend signature (AGG_SIG_ME / AGG_SIG_UNSAFE). The transcript itself is a fixed-width,
7//! big-endian, byte-deterministic serialization so the Rust and wasm/JS targets agree byte-for-byte.
8//!
9//! Field order + widths (NORMATIVE, SPEC §5.1):
10//! `version:u8 ‖ message_type:u32 ‖ flags:u8 ‖ correlation_id:32 ‖ sender:32 ‖ recipient:32 ‖
11//! sender_epoch:u32 ‖ counter:u64 ‖ timestamp_ms:u64 ‖ expires_at:u64 ‖ stream_frame:u8 ‖
12//! stream_seq:u64 ‖ kem_enc:48 ‖ compression:u8 ‖ uncompressed_len:u32 ‖ compressed_payload_hash:32`
13//! (all integers big-endian; `stream_frame`/`stream_seq` are 0 for a non-stream message;
14//! `compressed_payload_hash` is the SHA-256 of the on-wire compressed payload bytes).
15
16use chia_protocol::Bytes32;
17use sha2::{Digest, Sha256};
18
19use crate::constants::SIG_DOMAIN;
20use crate::envelope::StreamHeader;
21use dig_identity::bls::SecretKey;
22use dig_identity::{sign_message, verify_signature};
23
24/// The exact byte length of a transcript (the sum of the fixed-width fields above).
25const TRANSCRIPT_LEN: usize = 1 + 4 + 1 + 32 + 32 + 32 + 4 + 8 + 8 + 8 + 1 + 8 + 48 + 1 + 4 + 32;
26
27/// Every field the sender signature binds (SPEC §5.1). Assembled by the seal (send) and reconstructed
28/// from the opened envelope + inner message (receive), so both sides compute the identical transcript.
29#[derive(Debug, Clone)]
30pub struct TranscriptFields<'a> {
31    pub version: u8,
32    pub message_type: u32,
33    pub flags: u8,
34    pub correlation_id: Bytes32,
35    pub sender: Bytes32,
36    pub recipient: Bytes32,
37    pub sender_epoch: u32,
38    pub counter: u64,
39    pub timestamp_ms: u64,
40    pub expires_at: u64,
41    /// The stream frame kind + seq (SPEC §3); `None` for a non-stream message (encoded as 0/0).
42    pub stream: Option<StreamHeader>,
43    /// The ephemeral G1 encapsulation (SPEC §5.1) — binds the seal to this KEM so it cannot be reused.
44    pub kem_enc: &'a [u8; 48],
45    pub compression: u8,
46    pub uncompressed_len: u32,
47    /// The on-wire COMPRESSED payload bytes; the transcript binds their SHA-256, not the bytes.
48    pub compressed_payload: &'a [u8],
49}
50
51impl TranscriptFields<'_> {
52    /// The domain-separated bytes to sign/verify: `SIG_DOMAIN || transcript` (SPEC §5.1 / §5.1a).
53    #[must_use]
54    pub fn signing_bytes(&self) -> Vec<u8> {
55        let mut out = Vec::with_capacity(SIG_DOMAIN.len() + TRANSCRIPT_LEN);
56        out.extend_from_slice(SIG_DOMAIN);
57        out.extend_from_slice(&self.version.to_be_bytes());
58        out.extend_from_slice(&self.message_type.to_be_bytes());
59        out.extend_from_slice(&self.flags.to_be_bytes());
60        out.extend_from_slice(self.correlation_id.as_ref());
61        out.extend_from_slice(self.sender.as_ref());
62        out.extend_from_slice(self.recipient.as_ref());
63        out.extend_from_slice(&self.sender_epoch.to_be_bytes());
64        out.extend_from_slice(&self.counter.to_be_bytes());
65        out.extend_from_slice(&self.timestamp_ms.to_be_bytes());
66        out.extend_from_slice(&self.expires_at.to_be_bytes());
67        let (frame, seq) = self.stream.map_or((0u8, 0u64), |s| (s.frame, s.seq));
68        out.extend_from_slice(&frame.to_be_bytes());
69        out.extend_from_slice(&seq.to_be_bytes());
70        out.extend_from_slice(self.kem_enc);
71        out.extend_from_slice(&self.compression.to_be_bytes());
72        out.extend_from_slice(&self.uncompressed_len.to_be_bytes());
73        let hash: [u8; 32] = Sha256::digest(self.compressed_payload).into();
74        out.extend_from_slice(&hash);
75        out
76    }
77
78    /// BLS-G2 sign the transcript with the sender identity key (AugScheme), returning the 96-byte
79    /// signature (SPEC §5.1). Signs ONLY through the dig-identity helper — never a wallet spend path.
80    #[must_use]
81    pub fn sign(&self, sender_sk: &SecretKey) -> [u8; 96] {
82        sign_message(sender_sk, &self.signing_bytes())
83    }
84
85    /// Verify a 96-byte BLS-G2 signature against the resolved sender G1 key (SPEC §5.1). Fail-closed:
86    /// any malformed key/sig or non-verifying signature returns `false`.
87    #[must_use]
88    pub fn verify(&self, sender_pub: &[u8; 48], sig: &[u8; 96]) -> bool {
89        verify_signature(sender_pub, &self.signing_bytes(), sig)
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use dig_identity::{derive_identity_sk, master_secret_key_from_seed, public_key_bytes};
97
98    fn sk(label: &str) -> SecretKey {
99        let seed: [u8; 32] = Sha256::digest(label.as_bytes()).into();
100        derive_identity_sk(&master_secret_key_from_seed(&seed))
101    }
102
103    fn fields<'a>(kem_enc: &'a [u8; 48], payload: &'a [u8]) -> TranscriptFields<'a> {
104        TranscriptFields {
105            version: 1,
106            message_type: 0x0000_0201,
107            flags: 0b0000_0100,
108            correlation_id: Bytes32::new([1u8; 32]),
109            sender: Bytes32::new([2u8; 32]),
110            recipient: Bytes32::new([3u8; 32]),
111            sender_epoch: 7,
112            counter: 42,
113            timestamp_ms: 1_700_000_000_000,
114            expires_at: 0,
115            stream: None,
116            kem_enc,
117            compression: 0,
118            uncompressed_len: payload.len() as u32,
119            compressed_payload: payload,
120        }
121    }
122
123    #[test]
124    fn signing_bytes_are_fixed_width_and_domain_prefixed() {
125        let kem = [4u8; 48];
126        let f = fields(&kem, b"hello");
127        let bytes = f.signing_bytes();
128        assert_eq!(bytes.len(), SIG_DOMAIN.len() + TRANSCRIPT_LEN);
129        assert!(bytes.starts_with(SIG_DOMAIN));
130    }
131
132    #[test]
133    fn signing_bytes_are_deterministic() {
134        let kem = [4u8; 48];
135        assert_eq!(
136            fields(&kem, b"abc").signing_bytes(),
137            fields(&kem, b"abc").signing_bytes()
138        );
139    }
140
141    #[test]
142    fn sign_then_verify_round_trips() {
143        let sk = sk("transcript/sign");
144        let pk = public_key_bytes(&sk);
145        let kem = [9u8; 48];
146        let f = fields(&kem, b"payload-bytes");
147        let sig = f.sign(&sk);
148        assert!(f.verify(&pk, &sig));
149    }
150
151    #[test]
152    fn any_field_change_breaks_the_signature() {
153        let sk = sk("transcript/tamper");
154        let pk = public_key_bytes(&sk);
155        let kem = [9u8; 48];
156        let f = fields(&kem, b"payload-bytes");
157        let sig = f.sign(&sk);
158
159        let mut tampered = fields(&kem, b"payload-bytes");
160        tampered.counter = 43;
161        assert!(
162            !tampered.verify(&pk, &sig),
163            "changing counter must break the sig"
164        );
165
166        let mut tampered_exp = fields(&kem, b"payload-bytes");
167        tampered_exp.expires_at = 999;
168        assert!(
169            !tampered_exp.verify(&pk, &sig),
170            "changing expires_at must break the sig"
171        );
172
173        // The untampered fields still verify (control).
174        assert!(f.verify(&pk, &sig));
175    }
176
177    #[test]
178    fn wrong_key_does_not_verify() {
179        let signer = sk("transcript/wrong-a");
180        let kem = [9u8; 48];
181        let f = fields(&kem, b"x");
182        let sig = f.sign(&signer);
183        let other = public_key_bytes(&sk("transcript/wrong-b"));
184        assert!(!f.verify(&other, &sig));
185    }
186
187    #[test]
188    fn payload_hash_is_bound_not_the_bytes() {
189        // A different compressed payload yields a different transcript (the hash changes).
190        let kem = [9u8; 48];
191        assert_ne!(
192            fields(&kem, b"one").signing_bytes(),
193            fields(&kem, b"two").signing_bytes()
194        );
195    }
196
197    #[test]
198    fn stream_fields_are_bound() {
199        let kem = [9u8; 48];
200        let mut a = fields(&kem, b"x");
201        a.stream = Some(StreamHeader {
202            frame: 2,
203            seq: 5,
204            window: 0,
205        });
206        let mut b = fields(&kem, b"x");
207        b.stream = Some(StreamHeader {
208            frame: 2,
209            seq: 6,
210            window: 0,
211        });
212        assert_ne!(a.signing_bytes(), b.signing_bytes(), "stream_seq is bound");
213    }
214}