tsp_sdk 0.13.0

Rust implementation of the Trust Spanning Protocol
Documentation
//! Golden decode tests over the Rev 3 test vectors.
//!
//! Every other test in this repository is a round trip: it encodes something
//! and decodes it back, so it passes whatever the format is, and a change to
//! the wire that is applied consistently on both sides goes unnoticed. These
//! tests decode bytes that were fixed when the vectors were generated, so a
//! change to the wire fails them.
//!
//! The vectors are in `test_vectors/rev3.json`, generated by
//! `cargo run -p tsp_sdk --example generate_test_vectors`. Re-running that
//! reproduces the file byte for byte: every random value a message drew comes
//! from a seed the file records, so a vector can be verified twice over — by
//! opening it with the private keys it carries, and by regenerating its exact
//! bytes.

#[cfg(test)]
mod test {
    use crate::cesr::{CryptoType, SignatureType};
    use crate::definitions::{Payload, VerifiedVid};
    use crate::vid::OwnedVid;
    use base64ct::{Base64UrlUnpadded, Encoding};
    use wasm_bindgen_test::wasm_bindgen_test;

    const VECTORS: &str = include_str!("../test_vectors/rev3.json");

    struct Vectors {
        vids: serde_json::Map<String, serde_json::Value>,
        vectors: Vec<serde_json::Value>,
    }

    fn load() -> Vectors {
        let doc: serde_json::Value = serde_json::from_str(VECTORS).expect("vectors parse");

        Vectors {
            vids: doc["vids"].as_object().expect("vids").clone(),
            vectors: doc["vectors"].as_array().expect("vectors").clone(),
        }
    }

    impl Vectors {
        fn vid(&self, name: &str) -> OwnedVid {
            // the VID deserializer borrows from its input, so it needs the
            // text rather than an already-parsed value
            serde_json::from_str(&self.vids[name].to_string()).expect("VID parse")
        }
    }

    fn signature_type(expect: &serde_json::Value) -> SignatureType {
        // most vectors are Ed25519 and say nothing; the post-quantum one names
        // ML-DSA-65, which is the only thing about it that is not HPKE-Base
        match expect["signature"].as_str().unwrap_or("Ed25519") {
            "Ed25519" => SignatureType::Ed25519,
            "MlDsa65" => SignatureType::MlDsa65,
            other => panic!("unknown signature type in vectors: {other}"),
        }
    }

    fn crypto_type(name: &str) -> CryptoType {
        match name {
            "SealedBox" => CryptoType::SealedBox,
            "HpkeBase" => CryptoType::HpkeBase,
            "Plaintext" => CryptoType::Plaintext,
            other => panic!("unknown crypto type in vectors: {other}"),
        }
    }

    #[test]
    #[wasm_bindgen_test]
    fn every_vector_opens_to_what_it_says_it_does() {
        let vectors = load();

        for vector in &vectors.vectors {
            let name = vector["name"].as_str().expect("name");
            let sender = vectors.vid(vector["sender"].as_str().expect("sender"));
            let receiver = vectors.vid(vector["receiver"].as_str().expect("receiver"));
            let mut message =
                Base64UrlUnpadded::decode_vec(vector["message"].as_str().expect("message"))
                    .expect("message decodes");

            let expect = &vector["expect"];
            let expected_crypto = crypto_type(expect["crypto"].as_str().expect("crypto"));

            // the layout each vector declares is the spec 9.4 field sequence
            // for its type; its second element is the payload type code, which
            // has to agree with what the message actually decodes to
            let layout: Vec<&str> = vector["layout"]
                .as_array()
                .expect("layout")
                .iter()
                .map(|f| f.as_str().expect("layout field"))
                .collect();
            assert_eq!(layout[0], "-Z##", "{name}: every payload is -Z## framed");
            let declared_type = layout[1];

            // a signed-only message is opened by verifying it; a confidential
            // one by decrypting it
            if expected_crypto == CryptoType::Plaintext {
                let (content, message_type) = crate::crypto::verify(&sender, &mut message)
                    .unwrap_or_else(|e| panic!("{name}: signed-only vector does not verify: {e}"));
                assert_eq!(declared_type, "XSCS", "{name}: payload type");
                assert_eq!(message_type.crypto_type, CryptoType::Plaintext, "{name}");
                assert_eq!(
                    message_type.signature_type,
                    signature_type(expect),
                    "{name}"
                );
                assert_eq!(
                    content,
                    expect["payload"]["content"]
                        .as_str()
                        .expect("content")
                        .as_bytes(),
                    "{name}"
                );
                continue;
            }

            let (payload, crypto, signature) =
                crate::crypto::open(&receiver, &sender, &mut message)
                    .unwrap_or_else(|e| panic!("{name}: vector does not open: {e}"));

            assert_eq!(crypto, expected_crypto, "{name}: crypto type");
            assert_eq!(signature, signature_type(expect), "{name}: signature type");

            match payload {
                Payload::Content(content) => {
                    assert_eq!(declared_type, "XSCS", "{name}: payload type");
                    assert_eq!(
                        content,
                        expect["payload"]["content"]
                            .as_str()
                            .expect("content")
                            .as_bytes(),
                        "{name}"
                    );
                }
                Payload::RequestRelationship {
                    thread_id,
                    reply_path,
                    ..
                } => {
                    assert_eq!(declared_type, "XRFI", "{name}: payload type");
                    let e = &expect["payload"]["request_relationship"];
                    assert_eq!(
                        Base64UrlUnpadded::encode_string(&thread_id),
                        e["thread_id"].as_str().expect("thread_id"),
                        "{name}: the digest is the message's own SAID"
                    );
                    assert!(
                        reply_path.is_empty(),
                        "{name}: a direct invite has no reply path"
                    );
                }
                Payload::AcceptRelationship {
                    thread_id,
                    reply_thread_id,
                    ..
                } => {
                    assert_eq!(declared_type, "XRFA", "{name}: payload type");
                    let e = &expect["payload"]["accept_relationship"];
                    assert_eq!(
                        Base64UrlUnpadded::encode_string(&thread_id),
                        e["thread_id"].as_str().expect("thread_id"),
                        "{name}: the invite's digest is echoed verbatim"
                    );
                    assert_eq!(
                        Base64UrlUnpadded::encode_string(&reply_thread_id),
                        e["reply_thread_id"].as_str().expect("reply_thread_id"),
                        "{name}: the accept carries its own SAID"
                    );
                }
                Payload::CancelRelationship { thread_id } => {
                    assert_eq!(declared_type, "XRFD", "{name}: payload type");
                    assert_eq!(
                        Base64UrlUnpadded::encode_string(&thread_id),
                        expect["payload"]["cancel_relationship"]["thread_id"]
                            .as_str()
                            .expect("thread_id"),
                        "{name}"
                    );
                }
                Payload::ControlMessage(_) | Payload::Padding => {
                    panic!("{name}: no vector carries these payload types yet")
                }
                Payload::NestedMessage(inner) => {
                    assert_eq!(declared_type, "XHOP", "{name}: payload type");
                    let e = &expect["payload"]["nested"];
                    let inner_sender = vectors.vid("nested_alice");
                    let inner_receiver = vectors.vid("nested_bob");
                    assert_eq!(inner_sender.identifier(), e["inner_sender"], "{name}");
                    assert_eq!(inner_receiver.identifier(), e["inner_receiver"], "{name}");

                    // the inner message is a complete TSP message in its own right
                    let mut inner = inner.to_vec();
                    let (inner_payload, ..) =
                        crate::crypto::open(&inner_receiver, inner_sender.vid(), &mut inner)
                            .unwrap_or_else(|e| panic!("{name}: inner message does not open: {e}"));
                    let Payload::Content(content) = inner_payload else {
                        panic!("{name}: the inner message carries an application payload");
                    };
                    assert_eq!(
                        content,
                        e["inner_content"]
                            .as_str()
                            .expect("inner_content")
                            .as_bytes(),
                        "{name}"
                    );
                }
                Payload::RoutedMessage(hops, inner) => {
                    assert_eq!(declared_type, "XHOP", "{name}: payload type");
                    let e = &expect["payload"]["routed"];
                    let expected_hops: Vec<&str> = e["hops"]
                        .as_array()
                        .expect("hops")
                        .iter()
                        .map(|h| h.as_str().expect("hop"))
                        .collect();
                    let hops: Vec<String> = hops
                        .iter()
                        .map(|h| String::from_utf8(h.to_vec()).expect("hop is a VID"))
                        .collect();
                    assert_eq!(hops, expected_hops, "{name}: the hop list");

                    // the last hop is the destination's own VID at its
                    // intermediary, not the intermediary's (spec 5.3.3)
                    assert_eq!(
                        hops.last().map(String::as_str),
                        e["inner_receiver"].as_str(),
                        "{name}: the exit entry is the destination's VID"
                    );

                    let inner_sender = vectors.vid("nested_alice");
                    let inner_receiver = vectors.vid("nested_bob");
                    let mut inner = inner.to_vec();
                    let (inner_payload, ..) =
                        crate::crypto::open(&inner_receiver, inner_sender.vid(), &mut inner)
                            .unwrap_or_else(|e| {
                                panic!("{name}: endpoint-to-endpoint message does not open: {e}")
                            });
                    let Payload::Content(content) = inner_payload else {
                        panic!("{name}: the inner message carries an application payload");
                    };
                    assert_eq!(
                        content,
                        e["inner_content"]
                            .as_str()
                            .expect("inner_content")
                            .as_bytes(),
                        "{name}"
                    );
                }
            }
        }

        assert_eq!(vectors.vectors.len(), 10, "every vector was exercised");
    }

    #[test]
    #[wasm_bindgen_test]
    fn every_vector_regenerates_from_what_the_file_records() {
        // The point of recording the seed and the nonce: a verifier holding
        // the keys and those values reproduces the exact bytes, so a vector
        // tests an encoder and not only a decoder.
        let vectors = load();
        let mut checked = 0;

        for vector in &vectors.vectors {
            let name = vector["name"].as_str().expect("name");
            let Some(seed) = vector["seed"].as_str() else {
                // a signed-only message draws nothing; Ed25519 is deterministic
                continue;
            };
            let seed: [u8; 32] = Base64UrlUnpadded::decode_vec(seed)
                .expect("seed")
                .try_into()
                .expect("32-byte seed");
            let nonce: Option<[u8; 16]> = vector["nonce"].as_str().map(|n| {
                Base64UrlUnpadded::decode_vec(n)
                    .expect("nonce")
                    .try_into()
                    .expect("16-byte nonce")
            });

            let sender = vectors.vid(vector["sender"].as_str().expect("sender"));
            let receiver = vectors.vid(vector["receiver"].as_str().expect("receiver"));
            let crypto = crypto_type(vector["expect"]["crypto"].as_str().expect("crypto"));
            let expect = &vector["expect"]["payload"];

            // only the shapes whose payload the file describes in full can be
            // rebuilt from it; the nested and routed ones carry an inner
            // message whose own randomness is not separately recorded
            let payload = if let Some(content) = expect["content"].as_str() {
                Payload::Content(content.as_bytes())
            } else if expect["request_relationship"].is_object() {
                Payload::RequestRelationship {
                    thread_id: Default::default(),
                    reply_path: vec![],
                    form: crate::definitions::RelationshipForm::Direct,
                }
            } else if let Some(cancel) = expect["cancel_relationship"].as_object() {
                let thread_id: [u8; 32] =
                    Base64UrlUnpadded::decode_vec(cancel["thread_id"].as_str().expect("thread_id"))
                        .expect("digest")
                        .try_into()
                        .expect("32-byte digest");
                Payload::CancelRelationship { thread_id }
            } else {
                continue;
            };

            // a relationship payload has its digest computed and embedded
            // during sealing, so the out-parameter has to be supplied or a
            // different payload is sealed
            let mut digest = Default::default();
            let regenerated = crate::crypto::seal_reproducibly(
                &sender,
                receiver.vid(),
                payload,
                Some(&mut digest),
                crypto,
                seed,
                nonce,
            )
            .unwrap_or_else(|e| panic!("{name}: could not regenerate: {e}"));

            assert_eq!(
                Base64UrlUnpadded::encode_string(&regenerated),
                vector["message"].as_str().expect("message"),
                "{name}: regenerating from the recorded seed must give the same bytes"
            );
            checked += 1;
        }

        assert!(
            checked >= 4,
            "several vectors were regenerated, got {checked}"
        );
    }

    #[test]
    #[wasm_bindgen_test]
    fn a_vector_whose_bytes_changed_does_not_open() {
        // the point of a golden vector: altering the wire breaks it
        let vectors = load();
        let vector = &vectors.vectors[0];
        let sender = vectors.vid(vector["sender"].as_str().unwrap());
        let receiver = vectors.vid(vector["receiver"].as_str().unwrap());

        let pristine = Base64UrlUnpadded::decode_vec(vector["message"].as_str().unwrap()).unwrap();

        for position in [8, pristine.len() / 2, pristine.len() - 1] {
            let mut tampered = pristine.clone();
            tampered[position] ^= 0x01;

            assert!(
                crate::crypto::open(&receiver, &sender, &mut tampered).is_err(),
                "a vector altered at byte {position} must not open"
            );
        }
    }

    /// The segmenter is what "TSP Rev 3 on the Wire" and the specification's
    /// appendix are drawn from. It is a presentation aid rather than a parser,
    /// so nothing else would catch it falling behind the encoder: this does.
    #[test]
    #[wasm_bindgen_test]
    fn the_segmenter_knows_every_code_in_every_vector() {
        use crate::cesr::{SegmentKind, segments};

        let vectors = load();

        for vector in &vectors.vectors {
            let name = vector["name"].as_str().unwrap();
            let text = vector["message"].as_str().unwrap();
            let message = Base64UrlUnpadded::decode_vec(text).unwrap();
            let segments = segments(&message);

            let unparsed: Vec<_> = segments
                .iter()
                .filter(|s| s.kind == SegmentKind::Unparsed)
                .collect();
            assert!(
                unparsed.is_empty(),
                "{name}: the segmenter does not know {unparsed:#?}"
            );

            let covered: String = segments.iter().map(|s| s.text.as_str()).collect();
            assert_eq!(covered, text, "{name}: the segments must cover the message");
        }
    }
}