dmsg_protocol 0.4.0

Deterministic encoding, COSE document verification, and approval helpers for dMsg
Documentation
use super::*;
use crate::integration::validate_identifier;

fn key() -> String {
    let mut der = P256_SPKI_PREFIX.to_vec();
    der.push(4);
    der.extend([7u8; 64]);
    STANDARD.encode(der)
}

fn command(method: &str) -> ConnectCommand {
    let create = CREATE_METHODS.contains(&method);
    ConnectCommand {
        method: method.into(),
        app_id: "token-list".into(),
        operation_id: "01".repeat(32),
        public_key: key(),
        payload: create.then(|| STANDARD.encode(b"payload")),
        result_digest: (method == "acknowledge").then(|| "ab".repeat(32)),
    }
}

#[test]
fn commands_have_a_closed_shape() {
    for method in METHODS {
        validate_command(&command(method)).unwrap();
    }
    validate_identifier("token-list").unwrap();
    let mut c = command("signDocument");
    c.method = "sign".into();
    assert_eq!(validate_command(&c), Err(Error::UnsupportedProtocol));
    for change in [
        |c: &mut ConnectCommand| c.operation_id = "00".repeat(32),
        |c: &mut ConnectCommand| c.operation_id = "AB".repeat(32),
        |c: &mut ConnectCommand| c.operation_id = "ab".repeat(31),
        |c: &mut ConnectCommand| c.app_id = "Token".into(),
        |c: &mut ConnectCommand| c.public_key = STANDARD.encode([4u8; 91]),
        |c: &mut ConnectCommand| c.public_key = c.public_key.trim_end_matches('=').into(),
        |c: &mut ConnectCommand| c.payload = None,
        |c: &mut ConnectCommand| {
            c.payload = Some(STANDARD.encode(vec![0u8; MAX_COMMAND_PAYLOAD + 1]))
        },
        |c: &mut ConnectCommand| c.payload = Some("cGF5bG9hZA".into()),
        |c: &mut ConnectCommand| c.result_digest = Some("ab".repeat(32)),
    ] {
        let mut c = command("signDocument");
        change(&mut c);
        assert!(
            matches!(validate_command(&c), Err(Error::InvalidInput(_))),
            "{c:?}"
        );
    }
    let mut c = command("getOperation");
    c.payload = Some(STANDARD.encode(b"x"));
    assert!(validate_command(&c).is_err());
    let mut c = command("acknowledge");
    c.result_digest = None;
    assert!(validate_command(&c).is_err());
}

#[test]
fn digests_bind_every_field() {
    let c = command("signDocument");
    let a = operation_digest("https://app.example", &c).unwrap();
    assert_ne!(a, operation_digest("https://other.example", &c).unwrap());
    let mut d = c.clone();
    d.payload = Some(STANDARD.encode(b"other"));
    assert_ne!(a, operation_digest("https://app.example", &d).unwrap());
    assert!(operation_digest("https://app.example", &command("getOperation")).is_err());
    let nonce = Hash::new([1; 32]);
    let connection = Hash::new([2; 32]);
    let p = proof_message(&nonce, "https://app.example", &connection, &c);
    assert_ne!(
        p,
        proof_message(&connection, "https://app.example", &nonce, &c)
    );
    let account = AccountId([3; 12]);
    let invite = Hash::new([4; 32]);
    let secret = Hash::new([5; 32]);
    let pub_key = STANDARD.decode(key()).unwrap();
    let mac = pairing_mac(
        &secret,
        &Environment::Local,
        &account,
        &invite,
        &pub_key,
        "Chrome · macOS",
    );
    assert_ne!(
        mac,
        pairing_mac(
            &secret,
            &Environment::Local,
            &account,
            &invite,
            &pub_key,
            "Firefox · macOS"
        )
    );
    assert_ne!(
        mac,
        pairing_mac(
            &secret,
            &Environment::Staging,
            &account,
            &invite,
            &pub_key,
            "Chrome · macOS"
        )
    );
}

#[test]
fn requests_are_bound_to_the_deployment_and_clock() {
    let now = 1_800_000_000_000;
    let mut request = ConnectRequest {
        protocol: CONNECT_PROTOCOL.into(),
        environment: Environment::Local,
        account_id: AccountId([3; 12]),
        pairing_id: Hash::new([1; 32]),
        request_id: Hash::new([2; 32]),
        kind: RequestKind::Create,
        origin: "http://localhost:5173".into(),
        connection_id: Hash::new([4; 32]),
        nonce: Hash::new([5; 32]),
        command: command("signDocument"),
        page_signature: serde_bytes::ByteArray::new([6; 64]),
        result_key: Hash::new([7; 32]),
        created_at: now,
        expires_at: now + CREATE_TTL_MS,
    };
    validate_request(&request, &Environment::Local, now).unwrap();
    assert_eq!(
        validate_request(&request, &Environment::Staging, now),
        Err(Error::UnsupportedProtocol)
    );
    request.expires_at += 1;
    assert_eq!(
        validate_request(&request, &Environment::Local, now),
        Err(Error::Expired)
    );
    request.expires_at = now + MINUTE;
    request.created_at = now + 2 * MINUTE;
    assert_eq!(
        validate_request(&request, &Environment::Local, now),
        Err(Error::Expired)
    );
    request.created_at = now;
    request.kind = RequestKind::Read;
    assert!(validate_request(&request, &Environment::Local, now).is_err());
    request.command = command("getOperation");
    validate_request(&request, &Environment::Local, now).unwrap();
    request.expires_at = now + READ_TTL_MS + 1;
    assert_eq!(
        validate_request(&request, &Environment::Local, now),
        Err(Error::Expired)
    );
    request.expires_at = now + READ_TTL_MS;
    request.origin = "chrome-extension://abcdefghijklmnopabcdefghijklmnop".into();
    assert!(validate_request(&request, &Environment::Local, now).is_err());
}

#[test]
fn grants_bind_their_key_and_lifetime() {
    let pairing_pub = STANDARD.decode(key()).unwrap();
    let mut grant = PairingGrant {
        invite_id: Hash::new([1; 32]),
        pairing_id: pairing_id(&pairing_pub),
        pairing_pub: pairing_pub.into(),
        label: "Chrome · macOS".into(),
        home: candid::Principal::from_slice(&[1; 10]),
        granted_at: 1_000,
        expires_at: 1_000 + PAIRING_TTL_MS,
    };
    validate_grant(&grant, 2_000).unwrap();
    assert_eq!(
        validate_grant(&grant, grant.expires_at),
        Err(Error::Expired)
    );
    grant.expires_at += 1;
    assert!(validate_grant(&grant, 2_000).is_err());
    grant.expires_at -= 1;
    grant.pairing_id = Hash::new([9; 32]);
    assert!(validate_grant(&grant, 2_000).is_err());
}