keyteleport 0.1.0

A Rust implementation of the COLDCARD Key Teleport protocol
Documentation
use std::{fmt, str::FromStr};

use data_encoding::BASE32_NOPAD;
use rand::RngExt as _;
use zeroize::{Zeroize as _, Zeroizing};

use crate::{Error, Result};

/// A five-byte password used to decrypt a KeyTeleport sender response
#[derive(Clone, PartialEq, Eq)]
pub struct TeleportPassword {
    bytes: [u8; 5],
}

impl TeleportPassword {
    /// Generates a random transfer password
    pub fn generate() -> Self {
        let bytes = rand::rng().random::<[u8; 5]>();

        Self { bytes }
    }

    /// Creates a transfer password from its raw bytes
    pub fn from_bytes(bytes: [u8; 5]) -> Self {
        Self { bytes }
    }

    /// Exposes the raw password bytes
    pub fn expose_bytes(&self) -> &[u8; 5] {
        &self.bytes
    }

    /// Returns the ungrouped Base32 password text
    pub fn as_display_text(&self) -> String {
        BASE32_NOPAD.encode(&self.bytes)
    }

    /// Returns the password grouped into pairs for display
    pub fn grouped(&self) -> String {
        self.as_display_text()
            .as_bytes()
            .chunks(2)
            .map(|chunk| std::str::from_utf8(chunk).expect("password text is ASCII"))
            .collect::<Vec<_>>()
            .join(" ")
    }
}

impl fmt::Debug for TeleportPassword {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("TeleportPassword(****)")
    }
}

impl fmt::Display for TeleportPassword {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.as_display_text())
    }
}

impl Drop for TeleportPassword {
    fn drop(&mut self) {
        self.bytes.zeroize();
    }
}

impl FromStr for TeleportPassword {
    type Err = Error;

    fn from_str(value: &str) -> Result<Self> {
        let normalized = normalize_password(value)?;
        let decoded = Zeroizing::new(BASE32_NOPAD.decode(normalized.as_bytes())?);
        let bytes: [u8; 5] =
            decoded.as_slice().try_into().map_err(|_| Error::InvalidTeleportPassword)?;

        Ok(Self { bytes })
    }
}

fn normalize_password(value: &str) -> Result<Zeroizing<String>> {
    let mut normalized = Zeroizing::new(String::with_capacity(8));

    for ch in value.chars() {
        if ch.is_ascii_whitespace() || ch == '-' {
            continue;
        }

        let ch = ch.to_ascii_uppercase();
        let ch = match ch {
            '0' => 'O',
            '1' => 'L',
            '8' => 'B',
            _ => ch,
        };

        // reject characters outside the RFC 4648 Base32 alphabet
        if !matches!(ch, 'A'..='Z' | '2'..='7') {
            return Err(Error::InvalidTeleportPassword);
        }

        normalized.push(ch);
    }

    if normalized.len() != 8 {
        return Err(Error::InvalidTeleportPassword);
    }

    Ok(normalized)
}