keyteleport 0.1.0

A Rust implementation of the COLDCARD Key Teleport protocol
Documentation
use bbqr::{
    encode::Encoding,
    file_type::FileType,
    join::Joined,
    split::{Split, SplitOptions},
};

use bitcoin::secp256k1::PublicKey;

use crate::{Error, Result, crypto};

const KEY_TELEPORT_DOMAIN: &str = "keyteleport.com";
const MIN_SENDER_PACKET_LEN: usize = 33 + 5;

/// A decoded KeyTeleport packet
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Packet {
    /// A receiver request packet
    Receiver(ReceiverPacket),
    /// An encrypted sender response packet
    Sender(SenderPacket),
    /// A PSBT packet
    Psbt(PsbtPacket),
}

impl Packet {
    /// Parses one complete BBQr part
    pub fn from_bbqr_part(value: &str) -> Result<Self> {
        if !value.is_ascii() {
            return Err(Error::InvalidPacket);
        }

        let joined = Joined::try_from_parts(vec![value.to_string()])?;
        if joined.encoding != Encoding::Base32 {
            return Err(Error::InvalidBbqrEncoding);
        }

        match joined.file_type {
            FileType::KeyTeleportReceiver => Ok(Self::Receiver(ReceiverPacket::new(joined.data)?)),
            FileType::KeyTeleportSender => Ok(Self::Sender(SenderPacket::new(joined.data)?)),
            FileType::KeyTeleportPsbt => Ok(Self::Psbt(PsbtPacket::new(joined.data))),
            _ => Err(Error::InvalidPacket),
        }
    }

    /// Parses a KeyTeleport URL or complete BBQr part
    pub fn from_url(value: &str) -> Result<Self> {
        let value = value.trim();
        if value.to_ascii_uppercase().starts_with("B$") {
            return Self::from_bbqr_part(value);
        }

        let url = parse_keyteleport_url(value)?;
        let fragment = url.fragment().ok_or(Error::InvalidUrl)?;

        Self::from_bbqr_part(fragment)
    }

    /// Encodes the packet as one BBQr part
    pub fn to_bbqr_part(&self) -> Result<String> {
        match self {
            Self::Receiver(packet) => packet.to_bbqr_part(),
            Self::Sender(packet) => packet.to_bbqr_part(),
            Self::Psbt(packet) => packet.to_bbqr_part(),
        }
    }

    /// Encodes the packet as a KeyTeleport URL
    pub fn to_url(&self) -> Result<String> {
        Ok(format!("https://{KEY_TELEPORT_DOMAIN}/#{}", self.to_bbqr_part()?))
    }
}

/// A validated receiver request packet
#[derive(Clone, PartialEq, Eq)]
pub struct ReceiverPacket(Vec<u8>);

impl ReceiverPacket {
    /// Validates and wraps a receiver packet payload
    pub fn new(payload: Vec<u8>) -> Result<Self> {
        if payload.len() != crypto::RECEIVER_PACKET_LEN {
            return Err(Error::InvalidReceiverPacket);
        }

        Ok(Self(payload))
    }

    /// Returns the encoded packet bytes
    pub fn as_bytes(&self) -> &[u8] {
        &self.0
    }

    /// Encodes the packet as one BBQr part
    pub fn to_bbqr_part(&self) -> Result<String> {
        to_single_part_bbqr(&self.0, FileType::KeyTeleportReceiver)
    }

    /// Encodes the packet as a KeyTeleport URL
    pub fn to_url(&self) -> Result<String> {
        Packet::Receiver(self.clone()).to_url()
    }
}

impl std::fmt::Debug for ReceiverPacket {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_tuple("ReceiverPacket").field(&format_args!("{} bytes", self.0.len())).finish()
    }
}

/// A validated encrypted sender response packet
#[derive(Clone, PartialEq, Eq)]
pub struct SenderPacket(Vec<u8>);

impl SenderPacket {
    /// Validates and wraps a sender packet payload
    pub fn new(payload: Vec<u8>) -> Result<Self> {
        if payload.len() < MIN_SENDER_PACKET_LEN || PublicKey::from_slice(&payload[..33]).is_err() {
            return Err(Error::InvalidSenderPacket);
        }

        Ok(Self(payload))
    }

    /// Returns the encoded packet bytes
    pub fn as_bytes(&self) -> &[u8] {
        &self.0
    }

    /// Returns the encoded sender public key
    pub fn sender_pubkey_bytes(&self) -> &[u8] {
        &self.0[..33]
    }

    /// Returns the encrypted payload body
    pub fn encrypted_body(&self) -> &[u8] {
        &self.0[33..]
    }

    /// Encodes the packet as one BBQr part
    pub fn to_bbqr_part(&self) -> Result<String> {
        to_single_part_bbqr(&self.0, FileType::KeyTeleportSender)
    }

    /// Encodes the packet as a KeyTeleport URL
    pub fn to_url(&self) -> Result<String> {
        Packet::Sender(self.clone()).to_url()
    }
}

impl std::fmt::Debug for SenderPacket {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_tuple("SenderPacket").field(&format_args!("{} bytes", self.0.len())).finish()
    }
}

/// A PSBT payload transported in a KeyTeleport-compatible BBQr packet
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PsbtPacket(Vec<u8>);

impl PsbtPacket {
    /// Wraps a PSBT packet payload
    pub fn new(payload: Vec<u8>) -> Self {
        Self(payload)
    }

    /// Returns the encoded packet bytes
    pub fn as_bytes(&self) -> &[u8] {
        &self.0
    }

    /// Encodes the packet as one BBQr part
    pub fn to_bbqr_part(&self) -> Result<String> {
        to_single_part_bbqr(&self.0, FileType::KeyTeleportPsbt)
    }
}

fn to_single_part_bbqr(payload: &[u8], file_type: FileType) -> Result<String> {
    let split = Split::try_from_data(
        payload,
        file_type,
        SplitOptions {
            encoding: Encoding::Base32,
            min_split_number: 1,
            max_split_number: 1,
            ..Default::default()
        },
    )?;

    split.parts.into_iter().next().ok_or(Error::InvalidPacket)
}

fn parse_keyteleport_url(value: &str) -> Result<url::Url> {
    let trimmed = value.trim();
    let parseable = if trimmed.to_ascii_lowercase().starts_with(&format!("{KEY_TELEPORT_DOMAIN}/"))
    {
        format!("https://{trimmed}")
    } else {
        trimmed.to_string()
    };

    let url = url::Url::parse(&parseable)?;
    if url.scheme() != "https"
        || url.host_str().is_none_or(|host| !host.eq_ignore_ascii_case(KEY_TELEPORT_DOMAIN))
        || url.port().is_some()
        || !url.username().is_empty()
        || url.password().is_some()
        || url.path() != "/"
        || url.query().is_some()
    {
        return Err(Error::InvalidUrl);
    }

    Ok(url)
}