Skip to main content

tf_core_no_std/
packet.rs

1#![allow(clippy::items_after_test_module)]
2//! Packet-mode (TF-0011) sign/verify, embedded edition.
3//!
4//! Design constraints:
5//! * `#![no_std]`, no_alloc by default.
6//! * Strings carried inline via `heapless::String<N>` so the type has a
7//!   fully-stack-allocated representation. Capacities are sized for the
8//!   identifiers actually used by TrustForge (TF-0001 §4): actor URIs
9//!   are bounded by the `actor-id` schema, packet IDs are short ULIDs.
10//! * Signing-bytes derivation: SHA-256 over the SSZ-style concatenation
11//!   of the field values in a fixed canonical order, with the signature
12//!   field cleared. This is internally consistent — any sender and
13//!   receiver that uses this crate agrees byte-for-byte. See the crate
14//!   root for why we do not piggy-back on the std canonical-JSON path.
15
16use core::convert::TryInto;
17
18use ed25519_compact::{KeyPair, PublicKey, Seed, Signature};
19use heapless::String as HString;
20use heapless::Vec as HVec;
21use sha2::{Digest, Sha256};
22
23/// Maximum length, in bytes, of any single string field (signer / source /
24/// destination / packet_id / encoding / compression / priority / created_at
25/// / expires_at). 256 is generous for actor URIs and ISO timestamps.
26pub const STRING_CAP: usize = 256;
27
28/// Maximum payload size carried inline in a single packet, in bytes.
29/// Constrained channels (LoRa SF12) deliver tens of bytes; SF7 a few
30/// hundred. 1024 covers the practical envelope before fragmentation.
31pub const PAYLOAD_CAP: usize = 1024;
32
33/// Maximum signature size (ed25519 = 64).
34pub const SIGNATURE_CAP: usize = 64;
35
36/// A no_std packet header. Mirrors the field set of the std `Packet`
37/// struct in `tf-types::packet` minus features (fragmentation, route
38/// constraints) that K1 does not implement. K1 carries the data
39/// fields that the receiver MUST verify against the signature.
40#[derive(Clone, Debug)]
41pub struct Packet {
42    pub packet_version: HString<8>,
43    pub packet_id: HString<STRING_CAP>,
44    pub source: HString<STRING_CAP>,
45    pub destination: HString<STRING_CAP>,
46    pub priority: HString<8>,
47    pub emergency: bool,
48    pub created_at: HString<STRING_CAP>,
49    pub expires_at: Option<HString<STRING_CAP>>,
50    pub signer: HString<STRING_CAP>,
51    pub algorithm: HString<16>,
52    pub payload: HVec<u8, PAYLOAD_CAP>,
53    pub signature: HVec<u8, SIGNATURE_CAP>,
54}
55
56/// Errors from `verify_packet`.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum VerifyError {
59    /// `packet_version != "1"`.
60    UnsupportedVersion,
61    /// `signature.signer` does not match `source`.
62    SignerMismatch,
63    /// `priority == "P0"` but `emergency` is not set.
64    P0NotEmergency,
65    /// `expires_at` is set and `< now`.
66    Expired,
67    /// Signature failed to parse.
68    SignatureMalformed,
69    /// Public key failed to parse.
70    PublicKeyMalformed,
71    /// Signature did not verify.
72    SignatureInvalid,
73    /// String field overflowed `STRING_CAP`.
74    FieldTooLarge,
75}
76
77/// Errors from `sign_packet`.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum SignError {
80    /// String field overflowed its `STRING_CAP`.
81    FieldTooLarge,
82    /// Payload overflowed `PAYLOAD_CAP`.
83    PayloadTooLarge,
84    /// Priority `"P0"` requires `emergency = true`.
85    P0NotEmergency,
86}
87
88/// Sign a packet payload and produce a complete `Packet`.
89///
90/// `priority` must be one of `"P0".."P7"` and follows TF-0011 semantics:
91/// `P0` is emergency-only.
92#[allow(clippy::too_many_arguments)]
93pub fn sign_packet(
94    payload: &[u8],
95    private_key: &Seed,
96    signer: &str,
97    packet_id: &str,
98    source: &str,
99    destination: &str,
100    priority: &str,
101    expires_at: Option<&str>,
102) -> Result<Packet, SignError> {
103    if priority == "P0" {
104        return Err(SignError::P0NotEmergency);
105    }
106    let mut payload_vec: HVec<u8, PAYLOAD_CAP> = HVec::new();
107    payload_vec
108        .extend_from_slice(payload)
109        .map_err(|_| SignError::PayloadTooLarge)?;
110
111    let mut packet = Packet {
112        packet_version: hstring("1").map_err(|_| SignError::FieldTooLarge)?,
113        packet_id: hstring(packet_id).map_err(|_| SignError::FieldTooLarge)?,
114        source: hstring(source).map_err(|_| SignError::FieldTooLarge)?,
115        destination: hstring(destination).map_err(|_| SignError::FieldTooLarge)?,
116        priority: hstring(priority).map_err(|_| SignError::FieldTooLarge)?,
117        emergency: false,
118        // Embedded clocks vary; senders fill `created_at` themselves
119        // and the verifier compares it to `now`. For K1 we accept the
120        // caller's view of "now" via `expires_at`; created_at is set
121        // to the empty string here and the bridge / gateway is expected
122        // to fill it. Most embedded packet flows pin `created_at` from
123        // a monotonic local source the gateway re-stamps.
124        created_at: HString::new(),
125        expires_at: match expires_at {
126            Some(e) => Some(hstring(e).map_err(|_| SignError::FieldTooLarge)?),
127            None => None,
128        },
129        signer: hstring(signer).map_err(|_| SignError::FieldTooLarge)?,
130        algorithm: hstring("ed25519").map_err(|_| SignError::FieldTooLarge)?,
131        payload: payload_vec,
132        signature: HVec::new(),
133    };
134
135    let digest = packet_signing_bytes(&packet);
136    // ed25519-compact derives the keypair from the 32-byte seed.
137    let kp = KeyPair::from_seed(*private_key);
138    let sig: Signature = kp.sk.sign(digest, None);
139    let sig_bytes = sig.as_ref();
140    packet
141        .signature
142        .extend_from_slice(sig_bytes)
143        .expect("signature fits in 64-byte buffer");
144    Ok(packet)
145}
146
147/// Verify a packet against a known `public_key`. Mirrors the
148/// validation order of `tf-types::packet::verify_packet`.
149pub fn verify_packet(packet: &Packet, public_key: &[u8; 32], now: &str) -> Result<(), VerifyError> {
150    if packet.packet_version.as_str() != "1" {
151        return Err(VerifyError::UnsupportedVersion);
152    }
153    if packet.signer.as_str() != packet.source.as_str() {
154        return Err(VerifyError::SignerMismatch);
155    }
156    if packet.priority.as_str() == "P0" && !packet.emergency {
157        return Err(VerifyError::P0NotEmergency);
158    }
159    if let Some(exp) = packet.expires_at.as_ref() {
160        if exp.as_str() < now {
161            return Err(VerifyError::Expired);
162        }
163    }
164    let digest = packet_signing_bytes(packet);
165    let sig_bytes: &[u8; 64] = packet
166        .signature
167        .as_slice()
168        .try_into()
169        .map_err(|_| VerifyError::SignatureMalformed)?;
170    let sig = Signature::from_slice(sig_bytes).map_err(|_| VerifyError::SignatureMalformed)?;
171    let pk = PublicKey::from_slice(public_key).map_err(|_| VerifyError::PublicKeyMalformed)?;
172    pk.verify(digest, &sig)
173        .map_err(|_| VerifyError::SignatureInvalid)?;
174    Ok(())
175}
176
177/// Compute the 32-byte signing digest of a packet. The `signature`
178/// field is cleared before hashing.
179///
180/// Wire format hashed (each field is length-prefixed with a u32 BE):
181/// `version | packet_id | source | destination | priority | emergency
182///  | created_at | expires_at? | signer | algorithm | payload`.
183pub fn packet_signing_bytes(p: &Packet) -> [u8; 32] {
184    let mut h = Sha256::new();
185    write_field(&mut h, p.packet_version.as_bytes());
186    write_field(&mut h, p.packet_id.as_bytes());
187    write_field(&mut h, p.source.as_bytes());
188    write_field(&mut h, p.destination.as_bytes());
189    write_field(&mut h, p.priority.as_bytes());
190    write_field(&mut h, &[p.emergency as u8]);
191    write_field(&mut h, p.created_at.as_bytes());
192    match &p.expires_at {
193        Some(e) => {
194            h.update([1u8]);
195            write_field(&mut h, e.as_bytes());
196        }
197        None => {
198            h.update([0u8]);
199        }
200    }
201    write_field(&mut h, p.signer.as_bytes());
202    write_field(&mut h, p.algorithm.as_bytes());
203    write_field(&mut h, p.payload.as_slice());
204    let out = h.finalize();
205    let mut bytes = [0u8; 32];
206    bytes.copy_from_slice(&out);
207    bytes
208}
209
210fn write_field(h: &mut Sha256, bytes: &[u8]) {
211    let len = bytes.len() as u32;
212    h.update(len.to_be_bytes());
213    h.update(bytes);
214}
215
216fn hstring<const N: usize>(s: &str) -> Result<HString<N>, ()> {
217    let mut hs: HString<N> = HString::new();
218    hs.push_str(s).map_err(|_| ())?;
219    Ok(hs)
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    fn fixed_seed() -> Seed {
227        Seed::from_slice(&[7u8; 32]).expect("seed")
228    }
229
230    #[test]
231    fn sign_and_verify_round_trip() {
232        let seed = fixed_seed();
233        let kp = KeyPair::from_seed(seed);
234        let pk_bytes: [u8; 32] = kp.pk.as_ref().try_into().unwrap();
235
236        let signer = "tf:actor:agent:example.com/sensor-1";
237        let packet = sign_packet(
238            b"hello",
239            &seed,
240            signer,
241            "pkt-001",
242            signer,
243            "tf:actor:service:example.com/ingest",
244            "P3",
245            Some("2099-01-01T00:00:00Z"),
246        )
247        .expect("sign");
248
249        verify_packet(&packet, &pk_bytes, "2026-04-25T00:00:00Z").expect("verify ok");
250    }
251
252    #[test]
253    fn verify_rejects_tampered_payload() {
254        let seed = fixed_seed();
255        let kp = KeyPair::from_seed(seed);
256        let pk_bytes: [u8; 32] = kp.pk.as_ref().try_into().unwrap();
257        let signer = "tf:actor:agent:example.com/sensor-1";
258        let mut packet = sign_packet(
259            b"original",
260            &seed,
261            signer,
262            "pkt-002",
263            signer,
264            "tf:actor:service:example.com/ingest",
265            "P3",
266            None,
267        )
268        .expect("sign");
269        // Flip a payload byte.
270        packet.payload[0] ^= 0x01;
271        let r = verify_packet(&packet, &pk_bytes, "2026-04-25T00:00:00Z");
272        assert_eq!(r, Err(VerifyError::SignatureInvalid));
273    }
274
275    #[test]
276    fn verify_rejects_expired() {
277        let seed = fixed_seed();
278        let kp = KeyPair::from_seed(seed);
279        let pk_bytes: [u8; 32] = kp.pk.as_ref().try_into().unwrap();
280        let signer = "tf:actor:agent:example.com/x";
281        let packet = sign_packet(
282            b"x",
283            &seed,
284            signer,
285            "pkt-003",
286            signer,
287            "tf:actor:service:example.com/y",
288            "P3",
289            Some("2026-04-24T00:00:00Z"),
290        )
291        .expect("sign");
292        let r = verify_packet(&packet, &pk_bytes, "2026-04-25T00:00:00Z");
293        assert_eq!(r, Err(VerifyError::Expired));
294    }
295
296    #[test]
297    fn verify_rejects_signer_mismatch() {
298        let seed = fixed_seed();
299        let kp = KeyPair::from_seed(seed);
300        let pk_bytes: [u8; 32] = kp.pk.as_ref().try_into().unwrap();
301        let signer = "tf:actor:agent:example.com/a";
302        let mut packet = sign_packet(
303            b"x",
304            &seed,
305            signer,
306            "pkt-004",
307            signer,
308            "tf:actor:service:example.com/b",
309            "P3",
310            None,
311        )
312        .expect("sign");
313        // Change source after signing — signature still binds the
314        // mismatch directly.
315        packet.source = hstring("tf:actor:agent:example.com/other").unwrap();
316        let r = verify_packet(&packet, &pk_bytes, "2026-04-25T00:00:00Z");
317        assert_eq!(r, Err(VerifyError::SignerMismatch));
318    }
319}
320
321// `SecretKey` is unused publicly but kept available for downstream
322// callers that already hold one.
323#[doc(hidden)]
324pub use ed25519_compact::SecretKey as Ed25519SecretKey;