hns-chat-protocol 0.2.0

Owner-bound HNS Chat identity and opaque mailbox protocol values
Documentation
use hns_service_authority::AuthorityRecord;
use hns_transaction::Output;
use k256::ecdsa::VerifyingKey;

use crate::{ChatIdentityBindingV1, ChatProtocolError};

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ChatIdentityTrust {
    ResourceAuthenticated,
    CurrentOwnerVerified,
    StaleOwner,
    UnsupportedOwnerScript,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct VerifiedOwnerBindingV1 {
    binding: ChatIdentityBindingV1,
    original_compressed_public_key: [u8; 33],
    trust: ChatIdentityTrust,
}

impl VerifiedOwnerBindingV1 {
    pub const fn binding(&self) -> ChatIdentityBindingV1 {
        self.binding
    }

    pub const fn original_compressed_public_key(&self) -> [u8; 33] {
        self.original_compressed_public_key
    }

    pub const fn trust(&self) -> ChatIdentityTrust {
        self.trust
    }
}

pub fn xonly_from_compressed_public_key(key: &[u8; 33]) -> Result<[u8; 32], ChatProtocolError> {
    if !matches!(key[0], 0x02 | 0x03) || VerifyingKey::from_sec1_bytes(key).is_err() {
        return Err(ChatProtocolError::Invalid(
            "invalid compressed secp256k1 public key",
        ));
    }
    let mut xonly = [0_u8; 32];
    xonly.copy_from_slice(&key[1..]);
    Ok(xonly)
}

pub fn resolve_compressed_owner_key(
    owner_output: &Output,
    xonly_public_key: &[u8; 32],
) -> Result<[u8; 33], ChatProtocolError> {
    if owner_output.address.version != 0 || owner_output.address.hash.len() != 20 {
        return Err(ChatProtocolError::UnsupportedOwnerScript);
    }

    let mut even_candidate = [0_u8; 33];
    even_candidate[0] = 0x02;
    even_candidate[1..].copy_from_slice(xonly_public_key);
    VerifyingKey::from_sec1_bytes(&even_candidate)
        .map_err(|_| ChatProtocolError::Invalid("invalid secp256k1 x-only public key"))?;

    let mut matched = None;
    for parity in [0x02, 0x03] {
        let mut candidate = [0_u8; 33];
        candidate[0] = parity;
        candidate[1..].copy_from_slice(xonly_public_key);
        if VerifyingKey::from_sec1_bytes(&candidate).is_err() {
            continue;
        }
        let candidate_address = hns_transaction::Address::from_compressed_public_key(&candidate)
            .map_err(|_| ChatProtocolError::Invalid("owner public key cannot form an address"))?;
        if candidate_address.version == owner_output.address.version
            && candidate_address.hash == owner_output.address.hash
            && matched.replace(candidate).is_some()
        {
            return Err(ChatProtocolError::AmbiguousOwnerKey);
        }
    }
    matched.ok_or(ChatProtocolError::StaleOwner)
}

pub fn verify_current_owner_binding(
    binding: &ChatIdentityBindingV1,
    owner_output: &Output,
) -> Result<VerifiedOwnerBindingV1, ChatProtocolError> {
    binding.validate()?;
    let original_compressed_public_key =
        resolve_compressed_owner_key(owner_output, &binding.xonly_public_key)?;
    Ok(VerifiedOwnerBindingV1 {
        binding: *binding,
        original_compressed_public_key,
        trust: ChatIdentityTrust::CurrentOwnerVerified,
    })
}

pub fn owner_authority_record(
    verified: &VerifiedOwnerBindingV1,
) -> Result<AuthorityRecord, ChatProtocolError> {
    if verified.trust != ChatIdentityTrust::CurrentOwnerVerified
        || verified.binding.generation == 0
        || xonly_from_compressed_public_key(&verified.original_compressed_public_key)?
            != verified.binding.xonly_public_key
    {
        return Err(ChatProtocolError::Invalid(
            "owner authority requires a current verified binding",
        ));
    }
    Ok(AuthorityRecord {
        root_key: verified.original_compressed_public_key,
        epoch: verified.binding.generation,
    })
}

#[cfg(test)]
mod tests {
    use hns_covenants::{Covenant, CovenantKind};
    use hns_primitives::Dollarydoos;
    use k256::ecdsa::SigningKey;

    use super::*;

    fn owner_output(public_key: &[u8; 33]) -> Output {
        Output {
            value: Dollarydoos::new(1),
            address: hns_transaction::Address::from_compressed_public_key(public_key)
                .expect("address"),
            covenant: Covenant {
                kind: CovenantKind::Update,
                items: Vec::new(),
            },
        }
    }

    fn public_key(private_key: [u8; 32]) -> [u8; 33] {
        SigningKey::from_bytes((&private_key).into())
            .expect("private key")
            .verifying_key()
            .to_encoded_point(true)
            .as_bytes()
            .try_into()
            .expect("compressed key")
    }

    #[test]
    fn both_original_owner_parities_are_recovered_without_normalization() {
        let mut seen_even = false;
        let mut seen_odd = false;
        for scalar in 1_u8..=32 {
            let mut private_key = [0_u8; 32];
            private_key[31] = scalar;
            let compressed = public_key(private_key);
            seen_even |= compressed[0] == 0x02;
            seen_odd |= compressed[0] == 0x03;
            let binding = ChatIdentityBindingV1 {
                key_mode: crate::ChatKeyMode::Owner,
                xonly_public_key: xonly_from_compressed_public_key(&compressed).expect("x-only"),
                generation: u32::from(scalar),
            };
            let verified = verify_current_owner_binding(&binding, &owner_output(&compressed))
                .expect("current owner");
            assert_eq!(verified.original_compressed_public_key(), compressed);
            assert_eq!(
                owner_authority_record(&verified).expect("authority"),
                AuthorityRecord {
                    root_key: compressed,
                    epoch: u32::from(scalar),
                }
            );
            if seen_even && seen_odd {
                break;
            }
        }
        assert!(seen_even && seen_odd, "test keys must cover both parities");
    }

    #[test]
    fn stale_and_script_controlled_owner_outputs_are_rejected() {
        let key = public_key([1; 32]);
        let other_key = public_key([2; 32]);
        assert_eq!(
            resolve_compressed_owner_key(
                &owner_output(&other_key),
                &xonly_from_compressed_public_key(&key).expect("x-only")
            ),
            Err(ChatProtocolError::StaleOwner)
        );
        let mut script_owner = owner_output(&key);
        script_owner.address = hns_transaction::Address::new(0, vec![1; 32]).expect("P2WSH");
        assert_eq!(
            resolve_compressed_owner_key(
                &script_owner,
                &xonly_from_compressed_public_key(&key).expect("x-only")
            ),
            Err(ChatProtocolError::UnsupportedOwnerScript)
        );
    }

    #[test]
    fn invalid_x_coordinate_is_rejected() {
        let key = public_key([3; 32]);
        assert!(resolve_compressed_owner_key(&owner_output(&key), &[0xff; 32]).is_err());
    }
}