heddle-thread-api 0.25.4

Native Thread clients and durable peer replication over Iroh
Documentation
#![cfg(feature = "root-attachment")]
use biscuit_auth::{Biscuit, KeyPair};
use chrono::{DateTime, Utc};
use crypto::{Ed25519Signer, Signer};
use heddle_thread_api::{contract::*, root_attachment};

const ACCOUNT: &str = "00000000-0000-0000-0000-000000000011";
const NOW: i64 = 2_000_000_000;

#[test]
fn attached_endpoint_requires_trusted_root_exact_credential_and_subject_possession() {
    let root = KeyPair::new();
    let subject = Ed25519Signer::from_seed(&[17; 32]).expect("subject");
    let deadline = DateTime::<Utc>::from_timestamp(NOW + 300, 0).expect("expiry");
    let token = Biscuit::builder()
        .fact("user(\"account\")")
        .expect("account")
        .fact("session(\"attached-device\")")
        .expect("session")
        .fact(format!("device_pop_key(\"{}\")", hex::encode(subject.public_key())).as_str())
        .expect("proof key")
        .fact(format!("expires_at({})", deadline.to_rfc3339()).as_str())
        .expect("expiry")
        .check(format!("check if time($now), $now < {}", deadline.to_rfc3339()).as_str())
        .expect("expiry check")
        .build(&root)
        .expect("root credential")
        .to_vec()
        .expect("raw credential");
    let endpoint_signer = Ed25519Signer::from_seed(&[91; 32]).expect("endpoint");
    let endpoint = EndpointRef {
        public_key: endpoint_signer.public_key().to_vec(),
        kind: EndpointKind::Device as i32,
    };
    let attachment = signed(
        &subject,
        &endpoint_signer,
        root.public().to_bytes().as_slice(),
        &token,
        endpoint.clone(),
        NOW,
        NOW + 300,
    )
    .expect("attachment");
    let now = DateTime::<Utc>::from_timestamp(NOW + 1, 0).expect("now");
    let verified = root_attachment::verify(
        &attachment,
        &token,
        &[root.public()],
        ACCOUNT,
        &endpoint,
        now,
    )
    .expect("portable attachment");
    assert_eq!(verified.subject_public_key(), subject.public_key());
    assert_eq!(
        verified.root_public_key(),
        root.public().to_bytes().as_slice()
    );
    let wrong = KeyPair::new();
    assert!(
        root_attachment::verify(
            &attachment,
            &token,
            &[wrong.public()],
            ACCOUNT,
            &endpoint,
            now
        )
        .is_err()
    );
    let mut changed = attachment.clone();
    changed.subject_public_key = [4; 32].to_vec();
    assert!(
        root_attachment::verify(&changed, &token, &[root.public()], ACCOUNT, &endpoint, now)
            .is_err()
    );
    let mut changed = attachment.clone();
    changed.attachment.as_mut().expect("record").signatures[0].signature[0] ^= 1;
    assert!(
        root_attachment::verify(&changed, &token, &[root.public()], ACCOUNT, &endpoint, now)
            .is_err()
    );
    let other_endpoint = EndpointRef {
        public_key: vec![92; 32],
        ..endpoint.clone()
    };
    assert!(
        root_attachment::verify(
            &attachment,
            &token,
            &[root.public()],
            ACCOUNT,
            &other_endpoint,
            now
        )
        .is_err()
    );
    assert!(
        root_attachment::verify(
            &attachment,
            &token,
            &[root.public()],
            ACCOUNT,
            &endpoint,
            deadline
        )
        .is_err()
    );
    let mut different = token.clone();
    different.push(0);
    assert!(
        root_attachment::verify(
            &attachment,
            &different,
            &[root.public()],
            ACCOUNT,
            &endpoint,
            now
        )
        .is_err()
    );
}

#[test]
fn delegated_attachment_preserves_ancestor_expiry_and_proof_key_chain() {
    let root = KeyPair::new();
    let seed: [u8; 32] = root
        .private()
        .to_bytes()
        .as_slice()
        .try_into()
        .expect("root seed");
    let root_signer = Ed25519Signer::from_seed(&seed).expect("root signer");
    let child = Ed25519Signer::from_seed(&[31; 32]).expect("child");
    let now = DateTime::<Utc>::from_timestamp(NOW, 0).expect("now");
    let expiry = DateTime::<Utc>::from_timestamp(NOW + 60, 0).expect("expiry");
    let token = Biscuit::builder()
        .fact("user(\"owner\")")
        .expect("owner")
        .fact("session(\"owner-session\")")
        .expect("session")
        .fact(
            format!(
                "device_pop_key(\"{}\")",
                hex::encode(root_signer.public_key())
            )
            .as_str(),
        )
        .expect("root proof key")
        .build(&root)
        .expect("root token");
    let parent_id = token
        .revocation_identifiers()
        .last()
        .expect("parent revocation")
        .to_vec();
    let statement = [
        b"heddle-pop-delegation-v1\0".as_slice(),
        &parent_id,
        child.public_key(),
    ]
    .concat();
    let signature = root_signer.sign(&statement).expect("child delegation");
    let child_token = token
        .append(
            biscuit_verifier::delegation::AgentAttenuation::time_bounded("paired-device", expiry)
                .block()
                .expect("shared restrictions")
                .fact(
                    format!(
                        "pop_delegation(\"{}\", \"{}\", \"{}\")",
                        hex::encode(parent_id),
                        hex::encode(child.public_key()),
                        hex::encode(signature)
                    )
                    .as_str(),
                )
                .expect("proof transition"),
        )
        .expect("child token")
        .to_vec()
        .expect("raw child");
    let endpoint_signer = Ed25519Signer::from_seed(&[13; 32]).expect("endpoint");
    let endpoint = EndpointRef {
        public_key: endpoint_signer.public_key().to_vec(),
        kind: EndpointKind::Device as i32,
    };
    let attachment = signed(
        &child,
        &endpoint_signer,
        &root.public().to_bytes(),
        &child_token,
        endpoint.clone(),
        NOW,
        NOW + 60,
    )
    .expect("child attachment");
    root_attachment::verify(
        &attachment,
        &child_token,
        &[root.public()],
        ACCOUNT,
        &endpoint,
        now,
    )
    .expect("root derived proof chain");
    let overly_long = signed(
        &child,
        &endpoint_signer,
        &root.public().to_bytes(),
        &child_token,
        endpoint.clone(),
        NOW,
        NOW + 61,
    )
    .expect("untrusted claimed lifetime");
    assert!(
        root_attachment::verify(
            &overly_long,
            &child_token,
            &[root.public()],
            ACCOUNT,
            &endpoint,
            now
        )
        .is_err(),
        "attachment cannot outlive an ancestor attenuation"
    );
    assert!(
        root_attachment::verify(
            &attachment,
            &child_token,
            &[root.public()],
            ACCOUNT,
            &endpoint,
            expiry
        )
        .is_err()
    );
}

fn signed(
    subject: &impl Signer,
    endpoint: &impl Signer,
    root: &[u8],
    token: &[u8],
    device: EndpointRef,
    from: i64,
    to: i64,
) -> Result<RootAttachment, heddle_thread_api::transport::Error> {
    root_attachment::sign_binding(
        subject,
        endpoint,
        RootAttachmentBinding {
            format_version: 2,
            root_public_key: root.to_vec(),
            subject_public_key: subject.public_key().to_vec(),
            device: Some(device),
            credential_digest: blake3::hash(token).as_bytes().to_vec(),
            not_before_unix_seconds: from,
            expires_at_unix_seconds: to,
            account_id: "00000000-0000-0000-0000-000000000011".into(),
            pairing_challenge: vec![42; 32],
        },
    )
}

#[test]
fn binding_requires_distinct_endpoint_key_and_exact_approval_challenge() {
    use prost::Message;
    let subject = Ed25519Signer::from_seed(&[21; 32]).expect("subject");
    let endpoint = Ed25519Signer::from_seed(&[22; 32]).expect("endpoint");
    let binding = RootAttachmentBinding {
        format_version: 2,
        root_public_key: vec![23; 32],
        subject_public_key: subject.public_key().to_vec(),
        device: Some(EndpointRef {
            public_key: endpoint.public_key().to_vec(),
            kind: EndpointKind::Device as i32,
        }),
        credential_digest: vec![24; 32],
        not_before_unix_seconds: NOW,
        expires_at_unix_seconds: NOW + 300,
        account_id: ACCOUNT.into(),
        pairing_challenge: vec![25; 32],
    };
    let attachment =
        root_attachment::sign_binding(&subject, &endpoint, binding.clone()).expect("dual proof");
    root_attachment::verify_possession(&attachment, &binding).expect("both keys proved");
    let mut missing = attachment.clone();
    missing
        .attachment
        .as_mut()
        .expect("record")
        .signatures
        .pop();
    assert!(
        root_attachment::verify_possession(&missing, &binding).is_err(),
        "subject alone cannot register another endpoint"
    );
    let mut forged = attachment.clone();
    forged.attachment.as_mut().expect("record").signatures[1].signature[0] ^= 1;
    assert!(
        root_attachment::verify_possession(&forged, &binding).is_err(),
        "endpoint signature must verify"
    );
    let mut other = binding.clone();
    other.pairing_challenge[0] ^= 1;
    assert!(
        root_attachment::verify_possession(&attachment, &other).is_err(),
        "approval cannot move across pairings"
    );
    let mut shared = binding.clone();
    shared.device.as_mut().expect("device").public_key = subject.public_key().to_vec();
    let same = root_attachment::sign_binding(&subject, &subject, shared.clone())
        .expect("one key serves both roles");
    assert_eq!(
        same.attachment.as_ref().expect("record").signatures.len(),
        1
    );
    root_attachment::verify_possession(&same, &shared).expect("same-key proof");
    let proof = attachment.attachment.as_ref().expect("proof");
    let vector = format!(
        "binding={}\nsubject_public_key={}\nendpoint_public_key={}\nsubject_signature={}\nendpoint_signature={}\n",
        hex::encode(binding.encode_to_vec()),
        hex::encode(subject.public_key()),
        hex::encode(endpoint.public_key()),
        hex::encode(&proof.signatures[0].signature),
        hex::encode(&proof.signatures[1].signature)
    );
    assert_eq!(vector, include_str!("fixtures/root_attachment_v2.txt"));
}

#[test]
fn attached_endpoint_rejects_different_credential_account() {
    let root = KeyPair::new();
    let subject = Ed25519Signer::from_seed(&[17; 32]).expect("subject");
    let endpoint_signer = Ed25519Signer::from_seed(&[91; 32]).expect("endpoint");
    let endpoint = EndpointRef {
        public_key: endpoint_signer.public_key().to_vec(),
        kind: EndpointKind::Device as i32,
    };
    let token = Biscuit::builder()
        .fact("user(\"other-account\")")
        .expect("user")
        .fact("subject_user_uuid(\"00000000-0000-0000-0000-000000000022\")")
        .expect("different account")
        .fact("session(\"other-account-session\")")
        .expect("session")
        .fact(format!("device_pop_key(\"{}\")", hex::encode(subject.public_key())).as_str())
        .expect("subject")
        .build(&root)
        .expect("valid root credential")
        .to_vec()
        .expect("credential");
    let attachment = signed(
        &subject,
        &endpoint_signer,
        &root.public().to_bytes(),
        &token,
        endpoint.clone(),
        NOW,
        NOW + 300,
    )
    .expect("correct signatures");
    let error = root_attachment::verify(
        &attachment,
        &token,
        &[root.public()],
        ACCOUNT,
        &endpoint,
        DateTime::<Utc>::from_timestamp(NOW + 1, 0).expect("clock"),
    )
    .err()
    .unwrap_or_else(|| panic!("root trust cannot relabel credential account"));
    assert!(
        error.to_string().contains("credential subject or lifetime"),
        "{error}"
    );
}