diff --git a/rust/Cargo.lock b/rust/Cargo.lock index eafec44cd..7b23c0111 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -51,6 +51,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "ahash" version = "0.8.12" @@ -1044,6 +1055,7 @@ dependencies = [ "cove-cspp", "cove-device", "cove-http", + "cove-keyteleport", "cove-macros", "cove-nfc", "cove-tap-card", @@ -1187,6 +1199,25 @@ dependencies = [ "webpki-roots 1.0.6", ] +[[package]] +name = "cove-keyteleport" +version = "0.1.0" +dependencies = [ + "aes", + "bbqr", + "bip39", + "bitcoin", + "ctr", + "data-encoding", + "hex", + "hmac", + "pbkdf2", + "rand 0.10.0", + "sha2", + "thiserror 2.0.18", + "zeroize", +] + [[package]] name = "cove-macros" version = "0.1.0" @@ -1404,6 +1435,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "data-encoding" version = "2.10.0" @@ -2918,6 +2958,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "percent-encoding" version = "2.3.2" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 76ad583b5..2b1182896 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -127,6 +127,7 @@ cove-device = { path = "./crates/cove-device" } cove-bdk = { path = "./crates/cove-bdk" } cove-tokio = { path = "./crates/cove-tokio" } cove-http = { path = "./crates/cove-http" } +cove-keyteleport = { path = "./crates/cove-keyteleport" } # bitcoin bitcoin = { workspace = true } diff --git a/rust/crates/cove-keyteleport/Cargo.toml b/rust/crates/cove-keyteleport/Cargo.toml new file mode 100644 index 000000000..c65a2ea5e --- /dev/null +++ b/rust/crates/cove-keyteleport/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "cove-keyteleport" +version = "0.1.0" +edition = "2024" + +[dependencies] +# bitcoin / secp256k1 +bitcoin = { workspace = true } +bip39 = { workspace = true } + +# crypto primitives +sha2 = { workspace = true } +hmac = { workspace = true } +rand = { workspace = true } +zeroize = { workspace = true, features = ["derive"] } + +# AES-256-CTR +aes = "0.8" +ctr = "0.9" + +# PBKDF2-SHA512 +pbkdf2 = "0.12" + +# encoding +data-encoding = { workspace = true } +bbqr = { workspace = true } + +# error handling +thiserror = { workspace = true } + +[dev-dependencies] +hex = { workspace = true } diff --git a/rust/crates/cove-keyteleport/src/bbqr.rs b/rust/crates/cove-keyteleport/src/bbqr.rs new file mode 100644 index 000000000..3b078d7c5 --- /dev/null +++ b/rust/crates/cove-keyteleport/src/bbqr.rs @@ -0,0 +1,137 @@ +/// Minimal BBQr encoder/decoder for Key Teleport packet types (R and S). +/// +/// BBQr format: `B$` +/// For single-frame packets: num_parts=01, part_index=00. +/// Encoding byte `2` = Base32, no compression (as required by the COLDCARD spec). +use bbqr::encode::Encoding; +use data_encoding::BASE32_NOPAD; + +use crate::error::Error; + +/// Key Teleport BBQr file type codes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyTeleportFileType { + /// `R` — receiver packet (encrypted pubkey) + Receiver, + /// `S` — sender packet (sender pubkey + encrypted body) + Sender, +} + +impl KeyTeleportFileType { + pub fn as_char(self) -> char { + match self { + KeyTeleportFileType::Receiver => 'R', + KeyTeleportFileType::Sender => 'S', + } + } + + fn from_char(c: char) -> Result { + match c { + 'R' => Ok(KeyTeleportFileType::Receiver), + 'S' => Ok(KeyTeleportFileType::Sender), + other => Err(Error::InvalidBbqr(format!("unknown Key Teleport type: '{other}'"))), + } + } +} + +/// Encode binary data as a single-frame BBQr string with the given Key Teleport file type. +pub fn encode(data: &[u8], file_type: KeyTeleportFileType) -> String { + let b32 = BASE32_NOPAD.encode(data); + // num_parts=01 (1 frame total), part_index=00 (first/only frame) + // Encoding::Base32 corresponds to byte '2' per the BBQr spec + format!("B${}{}0100{}", Encoding::Base32.as_byte() as char, file_type.as_char(), b32) +} + +/// Decode a single-frame BBQr string, returning the file type and binary payload. +/// Multi-frame packets are rejected — higher-level transport code handles reassembly. +pub fn decode(s: &str) -> Result<(KeyTeleportFileType, Vec), Error> { + let s = s.trim().to_uppercase(); + + let rest = + s.strip_prefix("B$").ok_or_else(|| Error::InvalidBbqr("missing 'B$' header".into()))?; + + if rest.len() < 6 { + return Err(Error::InvalidBbqr("too short to be a valid BBQr packet".into())); + } + + let mut chars = rest.chars(); + let encoding_char = chars.next().unwrap(); + let encoding = Encoding::from_byte(encoding_char as u8).ok_or_else(|| { + Error::InvalidBbqr(format!("unknown encoding '{encoding_char}'")) + })?; + if encoding != Encoding::Base32 { + return Err(Error::InvalidBbqr(format!( + "unsupported encoding '{encoding_char}' (only Base32/'2' is supported)" + ))); + } + + let file_type = KeyTeleportFileType::from_char(chars.next().unwrap())?; + + // num_parts and part_index are 2 uppercase hex chars each + let header_tail: String = chars.take(4).collect(); + if header_tail.len() != 4 { + return Err(Error::InvalidBbqr("truncated header".into())); + } + let num_parts = u8::from_str_radix(&header_tail[0..2], 16) + .map_err(|_| Error::InvalidBbqr("bad num_parts".into()))?; + let part_index = u8::from_str_radix(&header_tail[2..4], 16) + .map_err(|_| Error::InvalidBbqr("bad part_index".into()))?; + + if num_parts != 1 || part_index != 0 { + return Err(Error::InvalidBbqr(format!( + "multi-frame BBQr not supported here (num_parts={num_parts}, part_index={part_index})" + ))); + } + + let b32_data = &s[8..]; // "B$" + encoding + type + 4 header chars = 8 + let data = BASE32_NOPAD + .decode(b32_data.as_bytes()) + .map_err(|e| Error::InvalidBbqr(format!("Base32 decode failed: {e}")))?; + + Ok((file_type, data)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encode_decode_roundtrip_receiver() { + let data = vec![0xAAu8; 33]; + let encoded = encode(&data, KeyTeleportFileType::Receiver); + assert!(encoded.starts_with("B$2R0100")); + let (ft, decoded) = decode(&encoded).unwrap(); + assert_eq!(ft, KeyTeleportFileType::Receiver); + assert_eq!(decoded, data); + } + + #[test] + fn encode_decode_roundtrip_sender() { + let mut data = vec![0x01u8; 33]; + data.extend_from_slice(&[0xBBu8; 80]); + let encoded = encode(&data, KeyTeleportFileType::Sender); + assert!(encoded.starts_with("B$2S0100")); + let (ft, decoded) = decode(&encoded).unwrap(); + assert_eq!(ft, KeyTeleportFileType::Sender); + assert_eq!(decoded, data); + } + + #[test] + fn decode_known_example() { + // From keyteleport.com: B$2R0100VHT2AGUUH7KUZUUSTOWOIWHJX3XM7GA2N4BHQOXDFHXLVHVA7K6ZO + let s = "B$2R0100VHT2AGUUH7KUZUUSTOWOIWHJX3XM7GA2N4BHQOXDFHXLVHVA7K6ZO"; + let (ft, data) = decode(s).unwrap(); + assert_eq!(ft, KeyTeleportFileType::Receiver); + assert_eq!(data.len(), 33); + } + + #[test] + fn rejects_wrong_header() { + assert!(decode("QR2R0100AAAA").is_err()); + } + + #[test] + fn rejects_unknown_file_type() { + assert!(decode("B$2E0100AAAA").is_err()); + } +} diff --git a/rust/crates/cove-keyteleport/src/crypto.rs b/rust/crates/cove-keyteleport/src/crypto.rs new file mode 100644 index 000000000..30f74c6e1 --- /dev/null +++ b/rust/crates/cove-keyteleport/src/crypto.rs @@ -0,0 +1,121 @@ +use aes::Aes256; +use aes::cipher::{KeyIvInit as _, StreamCipher as _}; +use bitcoin::secp256k1::{PublicKey, Scalar, Secp256k1, SecretKey}; +use ctr::Ctr128BE; +use pbkdf2::pbkdf2_hmac; +use sha2::{Digest as _, Sha256, Sha512}; + +/// Derive the shared session key using ECDH. +/// +/// Per the Key Teleport spec: SHA256(X || Y) of the shared point where X and Y +/// are the full uncompressed coordinates (64 bytes total). +pub(crate) fn session_key(local_privkey: &SecretKey, remote_pubkey: &PublicKey) -> [u8; 32] { + let secp = Secp256k1::new(); + let scalar = Scalar::from_be_bytes(local_privkey.secret_bytes()) + .expect("secret key bytes are always a valid scalar"); + let point = remote_pubkey.mul_tweak(&secp, &scalar).expect("valid EC multiplication"); + // serialize_uncompressed: 04 || X(32) || Y(32) — drop the 04 prefix + let uncompressed = point.serialize_uncompressed(); + Sha256::digest(&uncompressed[1..]).into() +} + +/// AES-256-CTR encrypt or decrypt (same operation — XOR keystream). +/// Zero IV as specified by the Key Teleport protocol. +pub(crate) fn aes256ctr(key: &[u8; 32], data: &[u8]) -> Vec { + let iv = [0u8; 16]; + let mut cipher = Ctr128BE::::new(key.into(), &iv.into()); + let mut out = data.to_vec(); + cipher.apply_keystream(&mut out); + out +} + +/// Derive the AES key used to encrypt/decrypt the receiver's pubkey in the R packet. +/// Key = SHA256(zero-padded 8-digit decimal string of the numeric code). +pub(crate) fn receiver_pubkey_key(numeric_code: u32) -> [u8; 32] { + let code_str = format!("{:08}", numeric_code); + Sha256::digest(code_str.as_bytes()).into() +} + +/// Stretch the teleport password using PBKDF2-SHA512. +/// Per spec: password = session_key, salt = teleport_pass, iter = 5000. +/// Returns the upper 256 bits (first 32 bytes) of the 512-bit output. +pub(crate) fn pbkdf2_stretch(session_key: &[u8; 32], teleport_pass: &[u8]) -> [u8; 32] { + let mut out = [0u8; 64]; + pbkdf2_hmac::(session_key, teleport_pass, 5000, &mut out); + out[..32].try_into().expect("32 bytes from 64-byte output") +} + +/// 2-byte checksum: last 2 bytes of SHA256(data). +pub(crate) fn checksum(data: &[u8]) -> [u8; 2] { + let hash = Sha256::digest(data); + [hash[30], hash[31]] +} + +/// Verify the 2-byte checksum appended to `data_with_checksum`. +/// Returns the payload bytes (without checksum) if valid. +pub(crate) fn verify_checksum(data_with_checksum: &[u8]) -> Option<&[u8]> { + if data_with_checksum.len() < 2 { + return None; + } + let (body, cs) = data_with_checksum.split_at(data_with_checksum.len() - 2); + let expected = checksum(body); + if cs == expected { Some(body) } else { None } +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::secp256k1::Secp256k1; + + #[test] + fn session_key_is_symmetric() { + use rand::RngExt as _; + let secp = Secp256k1::new(); + let mut bytes_a = [0u8; 32]; + let mut bytes_b = [0u8; 32]; + rand::rng().fill(&mut bytes_a); + rand::rng().fill(&mut bytes_b); + let sk_a = SecretKey::from_slice(&bytes_a).unwrap(); + let sk_b = SecretKey::from_slice(&bytes_b).unwrap(); + let pk_a = sk_a.public_key(&secp); + let pk_b = sk_b.public_key(&secp); + + let ka = session_key(&sk_a, &pk_b); + let kb = session_key(&sk_b, &pk_a); + assert_eq!(ka, kb, "ECDH must be symmetric"); + } + + #[test] + fn aes256ctr_roundtrip() { + let key = [0x42u8; 32]; + let plain = b"hello key teleport"; + let cipher = aes256ctr(&key, plain); + let recovered = aes256ctr(&key, &cipher); + assert_eq!(recovered, plain); + } + + #[test] + fn checksum_verify_roundtrip() { + let data = b"some payload data"; + let cs = checksum(data); + let mut with_cs = data.to_vec(); + with_cs.extend_from_slice(&cs); + assert_eq!(verify_checksum(&with_cs), Some(data.as_slice())); + } + + #[test] + fn checksum_detects_corruption() { + let data = b"some payload data"; + let cs = checksum(data); + let mut with_cs = data.to_vec(); + with_cs.extend_from_slice(&cs); + with_cs[0] ^= 0xFF; + assert_eq!(verify_checksum(&with_cs), None); + } + + #[test] + fn receiver_pubkey_key_is_deterministic() { + assert_eq!(receiver_pubkey_key(12345678), receiver_pubkey_key(12345678)); + assert_ne!(receiver_pubkey_key(12345678), receiver_pubkey_key(99999999)); + } +} diff --git a/rust/crates/cove-keyteleport/src/error.rs b/rust/crates/cove-keyteleport/src/error.rs new file mode 100644 index 000000000..95edfba62 --- /dev/null +++ b/rust/crates/cove-keyteleport/src/error.rs @@ -0,0 +1,27 @@ +/// All errors that can occur during Key Teleport operations. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum Error { + #[error("checksum mismatch — wrong key or corrupted data")] + ChecksumMismatch, + + #[error("invalid receiver packet: {0}")] + InvalidReceiverPacket(String), + + #[error("invalid sender packet: {0}")] + InvalidSenderPacket(String), + + #[error("invalid payload: {0}")] + InvalidPayload(String), + + #[error("invalid BBQr: {0}")] + InvalidBbqr(String), + + #[error("secp256k1 error: {0}")] + Secp(String), +} + +impl From for Error { + fn from(e: bitcoin::secp256k1::Error) -> Self { + Error::Secp(e.to_string()) + } +} diff --git a/rust/crates/cove-keyteleport/src/lib.rs b/rust/crates/cove-keyteleport/src/lib.rs new file mode 100644 index 000000000..d245ccf0b --- /dev/null +++ b/rust/crates/cove-keyteleport/src/lib.rs @@ -0,0 +1,139 @@ +//! `cove-keyteleport` — Rust implementation of the Key Teleport cryptographic protocol. +//! +//! Implements the non-multisig (mnemonic / xprv) participant flow: +//! - Receiver generates an R packet + numeric code and can decode incoming S packets. +//! - Sender parses an R packet + numeric code, picks a teleport password, and encrypts +//! a payload into an S packet. +//! +//! No UniFFI surface, no UI, no persistence — pure protocol primitives. +//! +//! # Example +//! ```rust +//! use cove_keyteleport::{ReceiverSession, SenderSession, Payload}; +//! use bip39::Mnemonic; +//! +//! // Receiver side +//! let receiver = ReceiverSession::generate(); +//! let r_packet = receiver.to_packet(); +//! let code = receiver.numeric_code(); +//! +//! // --- out-of-band: share r_packet.to_bbqr() and code --- +//! +//! // Sender side +//! let entropy = [0xABu8; 32]; // 32 bytes of entropy → 24-word mnemonic +//! let mnemonic = Mnemonic::from_entropy(&entropy).unwrap(); +//! let payload = Payload::Mnemonic(mnemonic); +//! let sender = SenderSession::new(&r_packet, code).unwrap(); +//! let s_packet = sender.encrypt(&payload); +//! let teleport_pass = sender.teleport_password().to_string(); +//! +//! // --- out-of-band: share s_packet.to_bbqr() and teleport_pass --- +//! +//! // Receiver decodes +//! let decoded = receiver.decode(&s_packet, &teleport_pass).unwrap(); +//! ``` + +mod bbqr; +mod crypto; +mod error; +mod packet; +mod payload; +mod receiver; +mod sender; + +pub use error::Error; +pub use packet::{ReceiverPacket, SenderPacket}; +pub use payload::Payload; +pub use receiver::ReceiverSession; +pub use sender::SenderSession; + +#[cfg(test)] +mod tests { + use bip39::Mnemonic; + + use super::*; + + fn random_mnemonic(words: usize) -> Mnemonic { + use rand::RngExt as _; + let entropy_len = match words { + 12 => 16, + 18 => 24, + 24 => 32, + _ => panic!("unsupported word count"), + }; + let mut entropy = vec![0u8; entropy_len]; + rand::rng().fill(entropy.as_mut_slice()); + Mnemonic::from_entropy(&entropy).unwrap() + } + + fn roundtrip(payload: Payload) -> Payload { + let receiver = ReceiverSession::generate(); + let r_pkt = receiver.to_packet(); + let code = receiver.numeric_code(); + + let sender = SenderSession::new(&r_pkt, code).unwrap(); + let s_pkt = sender.encrypt(&payload); + let pass = sender.teleport_password().to_string(); + + receiver.decode(&s_pkt, &pass).unwrap() + } + + #[test] + fn roundtrip_mnemonic_12_words() { + let m = random_mnemonic(12); + let original = m.to_string(); + let decoded = roundtrip(Payload::Mnemonic(m)); + match decoded { + Payload::Mnemonic(m2) => assert_eq!(m2.to_string(), original), + _ => panic!("expected mnemonic"), + } + } + + #[test] + fn roundtrip_mnemonic_24_words() { + let m = random_mnemonic(24); + let original = m.to_string(); + let decoded = roundtrip(Payload::Mnemonic(m)); + match decoded { + Payload::Mnemonic(m2) => assert_eq!(m2.to_string(), original), + _ => panic!("expected mnemonic"), + } + } + + #[test] + fn wrong_teleport_password_fails() { + let receiver = ReceiverSession::generate(); + let r_pkt = receiver.to_packet(); + + let sender = SenderSession::new(&r_pkt, receiver.numeric_code()).unwrap(); + let m = random_mnemonic(24); + let s_pkt = sender.encrypt(&Payload::Mnemonic(m)); + + let result = receiver.decode(&s_pkt, "WRONGPAS"); + assert_eq!(result.unwrap_err(), Error::ChecksumMismatch); + } + + #[test] + fn bbqr_transport_roundtrip() { + let receiver = ReceiverSession::generate(); + let r_pkt = receiver.to_packet(); + + // simulate transmission via BBQr strings + let r_bbqr = r_pkt.to_bbqr(); + let r_pkt_parsed = ReceiverPacket::from_bbqr(&r_bbqr).unwrap(); + + let sender = SenderSession::new(&r_pkt_parsed, receiver.numeric_code()).unwrap(); + let m = random_mnemonic(24); + let original = m.to_string(); + let s_pkt = sender.encrypt(&Payload::Mnemonic(m)); + + let s_bbqr = s_pkt.to_bbqr(); + let s_pkt_parsed = SenderPacket::from_bbqr(&s_bbqr).unwrap(); + + let decoded = receiver.decode(&s_pkt_parsed, sender.teleport_password()).unwrap(); + match decoded { + Payload::Mnemonic(m2) => assert_eq!(m2.to_string(), original), + _ => panic!("expected mnemonic"), + } + } +} diff --git a/rust/crates/cove-keyteleport/src/packet.rs b/rust/crates/cove-keyteleport/src/packet.rs new file mode 100644 index 000000000..31324ed16 --- /dev/null +++ b/rust/crates/cove-keyteleport/src/packet.rs @@ -0,0 +1,135 @@ +use bitcoin::secp256k1::PublicKey; + +use crate::bbqr::{self, KeyTeleportFileType}; +use crate::error::Error; + +/// The `R` packet generated by the receiver. +/// Binary layout: 33 bytes = AES-256-CTR(SHA256(8-digit-code), compressed_pubkey). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReceiverPacket { + /// The AES-encrypted compressed public key (33 bytes). + encrypted_pubkey: [u8; 33], +} + +impl ReceiverPacket { + pub(crate) fn new(encrypted_pubkey: [u8; 33]) -> Self { + Self { encrypted_pubkey } + } + + pub(crate) fn encrypted_pubkey(&self) -> &[u8; 33] { + &self.encrypted_pubkey + } + + /// Encode as a single-frame BBQr `R` string. + pub fn to_bbqr(&self) -> String { + bbqr::encode(&self.encrypted_pubkey, KeyTeleportFileType::Receiver) + } + + /// Parse from a BBQr `R` string. + pub fn from_bbqr(s: &str) -> Result { + let (ft, data) = bbqr::decode(s)?; + if ft != KeyTeleportFileType::Receiver { + return Err(Error::InvalidReceiverPacket("expected R-type BBQr".into())); + } + let arr: [u8; 33] = data.try_into().map_err(|_| { + Error::InvalidReceiverPacket("encrypted pubkey must be 33 bytes".into()) + })?; + Ok(Self { encrypted_pubkey: arr }) + } + + /// Parse from raw bytes (33-byte encrypted pubkey). + pub fn from_bytes(bytes: &[u8]) -> Result { + let arr: [u8; 33] = bytes + .try_into() + .map_err(|_| Error::InvalidReceiverPacket("must be exactly 33 bytes".into()))?; + Ok(Self { encrypted_pubkey: arr }) + } +} + +/// The `S` packet generated by the sender. +/// Binary layout: sender_pubkey (33 bytes) || encrypted_body (variable). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SenderPacket { + sender_pubkey: PublicKey, + encrypted_body: Vec, +} + +impl SenderPacket { + pub(crate) fn new(sender_pubkey: PublicKey, encrypted_body: Vec) -> Self { + Self { sender_pubkey, encrypted_body } + } + + pub(crate) fn sender_pubkey(&self) -> &PublicKey { + &self.sender_pubkey + } + + pub(crate) fn encrypted_body(&self) -> &[u8] { + &self.encrypted_body + } + + /// Encode as a single-frame BBQr `S` string. + pub fn to_bbqr(&self) -> String { + let mut raw = self.sender_pubkey.serialize().to_vec(); + raw.extend_from_slice(&self.encrypted_body); + bbqr::encode(&raw, KeyTeleportFileType::Sender) + } + + /// Parse from a BBQr `S` string. + pub fn from_bbqr(s: &str) -> Result { + let (ft, data) = bbqr::decode(s)?; + if ft != KeyTeleportFileType::Sender { + return Err(Error::InvalidSenderPacket("expected S-type BBQr".into())); + } + Self::from_bytes(&data) + } + + /// Parse from raw bytes: sender_pubkey (33 bytes) || encrypted_body. + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() < 33 { + return Err(Error::InvalidSenderPacket("too short (need ≥33 bytes)".into())); + } + let sender_pubkey = PublicKey::from_slice(&bytes[..33]) + .map_err(|e| Error::InvalidSenderPacket(format!("bad sender pubkey: {e}")))?; + let encrypted_body = bytes[33..].to_vec(); + Ok(Self { sender_pubkey, encrypted_body }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::secp256k1::Secp256k1; + + #[test] + fn receiver_packet_bbqr_roundtrip() { + let encrypted = [0xABu8; 33]; + let pkt = ReceiverPacket::new(encrypted); + let bbqr = pkt.to_bbqr(); + assert!(bbqr.starts_with("B$2R")); + let recovered = ReceiverPacket::from_bbqr(&bbqr).unwrap(); + assert_eq!(pkt, recovered); + } + + #[test] + fn sender_packet_bbqr_roundtrip() { + use rand::RngExt as _; + let secp = Secp256k1::new(); + let mut bytes = [0u8; 32]; + rand::rng().fill(&mut bytes); + let sk = bitcoin::secp256k1::SecretKey::from_slice(&bytes).unwrap(); + let pk = sk.public_key(&secp); + let body = vec![0x11u8; 50]; + let pkt = SenderPacket::new(pk, body); + let bbqr = pkt.to_bbqr(); + assert!(bbqr.starts_with("B$2S")); + let recovered = SenderPacket::from_bbqr(&bbqr).unwrap(); + assert_eq!(pkt, recovered); + } + + #[test] + fn sender_packet_wrong_type_is_error() { + let data = [0xBBu8; 33]; + let r_bbqr = bbqr::encode(&data, KeyTeleportFileType::Receiver); + assert!(SenderPacket::from_bbqr(&r_bbqr).is_err()); + } +} diff --git a/rust/crates/cove-keyteleport/src/payload.rs b/rust/crates/cove-keyteleport/src/payload.rs new file mode 100644 index 000000000..42b0a30ee --- /dev/null +++ b/rust/crates/cove-keyteleport/src/payload.rs @@ -0,0 +1,93 @@ +use bip39::Mnemonic; + +use crate::error::Error; + +/// Type byte prefixes as defined in the Key Teleport spec. +const TYPE_MNEMONIC: u8 = b's'; +const TYPE_XPRV: u8 = b'x'; + +/// A decrypted Key Teleport payload — the secret being transferred. +#[derive(Debug)] +pub enum Payload { + /// A BIP-39 mnemonic (12 / 18 / 24 words). Type byte `s`. + Mnemonic(Mnemonic), + /// A base58-encoded XPRV (serialised `ExtendedPrivKey`). Type byte `x`. + Xprv(String), +} + +impl Payload { + /// Serialise the payload for encryption: `type_byte || secret_bytes`. + pub(crate) fn to_bytes(&self) -> Vec { + match self { + Payload::Mnemonic(m) => { + // Encode the mnemonic entropy as raw bytes, prefixed with the type byte + let entropy = m.to_entropy(); + let mut out = Vec::with_capacity(1 + entropy.len()); + out.push(TYPE_MNEMONIC); + out.extend_from_slice(&entropy); + out + } + Payload::Xprv(xprv) => { + // base58-decoded binary XPRV (78 bytes), prefixed with the type byte + let decoded = + bitcoin::base58::decode(xprv).unwrap_or_else(|_| xprv.as_bytes().to_vec()); + let mut out = Vec::with_capacity(1 + decoded.len()); + out.push(TYPE_XPRV); + out.extend_from_slice(&decoded); + out + } + } + } + + /// Deserialise from `type_byte || secret_bytes` after decryption. + pub(crate) fn from_bytes(bytes: &[u8]) -> Result { + if bytes.is_empty() { + return Err(Error::InvalidPayload("empty payload".into())); + } + match bytes[0] { + TYPE_MNEMONIC => { + let entropy = &bytes[1..]; + let m = Mnemonic::from_entropy(entropy) + .map_err(|e| Error::InvalidPayload(format!("invalid mnemonic entropy: {e}")))?; + Ok(Payload::Mnemonic(m)) + } + TYPE_XPRV => { + let bin = &bytes[1..]; + let xprv = bitcoin::base58::encode(bin); + Ok(Payload::Xprv(xprv)) + } + other => Err(Error::InvalidPayload(format!("unknown payload type byte 0x{other:02X}"))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mnemonic_roundtrip() { + let entropy = [0x11u8; 32]; // 32 bytes → 24-word mnemonic + let m = Mnemonic::from_entropy(&entropy).unwrap(); + let p = Payload::Mnemonic(m); + let bytes = p.to_bytes(); + assert_eq!(bytes[0], TYPE_MNEMONIC); + let recovered = Payload::from_bytes(&bytes).unwrap(); + match (p, recovered) { + (Payload::Mnemonic(a), Payload::Mnemonic(b)) => { + assert_eq!(a.to_string(), b.to_string()) + } + _ => panic!("type mismatch"), + } + } + + #[test] + fn unknown_type_byte_is_error() { + assert!(Payload::from_bytes(&[0xFF, 1, 2, 3]).is_err()); + } + + #[test] + fn empty_bytes_is_error() { + assert!(Payload::from_bytes(&[]).is_err()); + } +} diff --git a/rust/crates/cove-keyteleport/src/receiver.rs b/rust/crates/cove-keyteleport/src/receiver.rs new file mode 100644 index 000000000..9ff54f82c --- /dev/null +++ b/rust/crates/cove-keyteleport/src/receiver.rs @@ -0,0 +1,119 @@ +use bitcoin::secp256k1::{Secp256k1, SecretKey}; +use rand::RngExt as _; +use zeroize::Zeroizing; + +use crate::crypto::{aes256ctr, pbkdf2_stretch, receiver_pubkey_key, session_key, verify_checksum}; +use crate::error::Error; +use crate::packet::{ReceiverPacket, SenderPacket}; +use crate::payload::Payload; + +/// Maximum value for the 8-digit numeric code (inclusive). +const MAX_NUMERIC_CODE: u32 = 99_999_999; + +/// A receiver session: holds the ephemeral EC private key bytes and numeric code. +/// The key bytes are zeroed on drop via `Zeroizing`. +#[derive(Debug)] +pub struct ReceiverSession { + /// Raw 32-byte secret key — kept as bytes so we can zero them on drop. + privkey_bytes: Zeroizing<[u8; 32]>, + /// 8-digit code shown to the receiver, shared out-of-band with the sender. + numeric_code: u32, +} + +impl ReceiverSession { + /// Generate a fresh receiver session (random keypair + random 8-digit code). + pub fn generate() -> Self { + let mut key_bytes = [0u8; 32]; + rand::rng().fill(&mut key_bytes); + // Retry if we somehow hit an invalid scalar (astronomically unlikely) + while SecretKey::from_slice(&key_bytes).is_err() { + rand::rng().fill(&mut key_bytes); + } + + let numeric_code = rand::random::() % (MAX_NUMERIC_CODE + 1); + Self { privkey_bytes: Zeroizing::new(key_bytes), numeric_code } + } + + fn privkey(&self) -> SecretKey { + SecretKey::from_slice(&self.privkey_bytes[..]).expect("stored key is always valid") + } + + /// The 8-digit numeric code (raw value). + pub fn numeric_code(&self) -> u32 { + self.numeric_code + } + + /// The numeric code formatted as a zero-padded 8-digit string for display. + pub fn numeric_code_display(&self) -> String { + format!("{:08}", self.numeric_code) + } + + /// Build the `R` packet to share with the sender (via QR / NFC / link). + /// + /// The receiver's compressed pubkey is AES-256-CTR encrypted using a key derived + /// from the numeric code. + pub fn to_packet(&self) -> ReceiverPacket { + let secp = Secp256k1::new(); + let pubkey = self.privkey().public_key(&secp); + let compressed = pubkey.serialize(); // 33 bytes + + let key = receiver_pubkey_key(self.numeric_code); + let encrypted = aes256ctr(&key, &compressed); + let arr: [u8; 33] = encrypted.try_into().expect("33 bytes in, 33 bytes out"); + ReceiverPacket::new(arr) + } + + /// Decode an incoming sender packet using this session's private key and + /// the teleport password supplied by the sender. + /// + /// Decryption flow (per spec): + /// 1. ECDH(privkey, sender_pubkey) → session key + /// 2. AES-CTR(session_key) decrypt → outer plaintext + /// 3. Verify 2-byte checksum on outer plaintext + /// 4. PBKDF2(session_key, teleport_pass) → inner key + /// 5. AES-CTR(inner_key) decrypt → inner plaintext + /// 6. Verify 2-byte checksum on inner plaintext + /// 7. Parse payload type byte + pub fn decode( + &self, + sender_pkt: &SenderPacket, + teleport_password: &str, + ) -> Result { + let sk = session_key(&self.privkey(), sender_pkt.sender_pubkey()); + + // Outer decryption + checksum + let outer_plain = aes256ctr(&sk, sender_pkt.encrypted_body()); + let intermediate = verify_checksum(&outer_plain).ok_or(Error::ChecksumMismatch)?; + + // Inner decryption + checksum + let inner_key = pbkdf2_stretch(&sk, teleport_password.as_bytes()); + let inner_plain = aes256ctr(&inner_key, intermediate); + let payload_bytes = verify_checksum(&inner_plain).ok_or(Error::ChecksumMismatch)?; + + Payload::from_bytes(payload_bytes) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generate_produces_valid_packet() { + let session = ReceiverSession::generate(); + let pkt = session.to_packet(); + let bbqr = pkt.to_bbqr(); + assert!(bbqr.starts_with("B$2R")); + let recovered = ReceiverPacket::from_bbqr(&bbqr).unwrap(); + assert_eq!(recovered, pkt); + } + + #[test] + fn numeric_code_is_in_range() { + for _ in 0..20 { + let s = ReceiverSession::generate(); + assert!(s.numeric_code() <= MAX_NUMERIC_CODE); + assert_eq!(s.numeric_code_display().len(), 8); + } + } +} diff --git a/rust/crates/cove-keyteleport/src/sender.rs b/rust/crates/cove-keyteleport/src/sender.rs new file mode 100644 index 000000000..15432af31 --- /dev/null +++ b/rust/crates/cove-keyteleport/src/sender.rs @@ -0,0 +1,127 @@ +use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; +use data_encoding::BASE32_NOPAD; +use rand::RngExt as _; +use zeroize::Zeroizing; + +use crate::crypto::{aes256ctr, checksum, pbkdf2_stretch, receiver_pubkey_key, session_key}; +use crate::error::Error; +use crate::packet::{ReceiverPacket, SenderPacket}; +use crate::payload::Payload; + +/// A sender session: holds the ephemeral private key and derived session state. +/// Key bytes are zeroed on drop via `Zeroizing`. +#[derive(Debug)] +pub struct SenderSession { + privkey_bytes: Zeroizing<[u8; 32]>, + session_key: Zeroizing<[u8; 32]>, + /// 8-character Base32 teleport password shown to the sender, shared out-of-band. + teleport_password: Zeroizing, +} + +impl SenderSession { + /// Create a sender session from a `ReceiverPacket` and the numeric code. + /// + /// Steps: + /// 1. Decrypt receiver pubkey from R packet using SHA256(numeric_code) + /// 2. Generate ephemeral sender keypair + /// 3. ECDH(sender_privkey, receiver_pubkey) → session key + /// 4. Generate random 5-byte teleport password → Base32 (8 chars) + pub fn new(r_packet: &ReceiverPacket, numeric_code: u32) -> Result { + // Decrypt the receiver's pubkey + let key = receiver_pubkey_key(numeric_code); + let pubkey_bytes = aes256ctr(&key, r_packet.encrypted_pubkey()); + let receiver_pubkey = PublicKey::from_slice(&pubkey_bytes).map_err(|_| { + Error::InvalidReceiverPacket( + "decrypted bytes are not a valid pubkey — wrong numeric code?".into(), + ) + })?; + + // Generate ephemeral keypair from random bytes + let mut key_bytes = [0u8; 32]; + rand::rng().fill(&mut key_bytes); + while SecretKey::from_slice(&key_bytes).is_err() { + rand::rng().fill(&mut key_bytes); + } + let privkey = SecretKey::from_slice(&key_bytes).expect("validated above"); + + // Derive session key + let sk = session_key(&privkey, &receiver_pubkey); + + // Generate teleport password: 5 random bytes → 8 Base32 chars + let mut raw = [0u8; 5]; + rand::rng().fill(&mut raw[..]); + let teleport_password = Zeroizing::new(BASE32_NOPAD.encode(&raw)); + + Ok(Self { + privkey_bytes: Zeroizing::new(key_bytes), + session_key: Zeroizing::new(sk), + teleport_password, + }) + } + + fn privkey(&self) -> SecretKey { + SecretKey::from_slice(&self.privkey_bytes[..]).expect("stored key is always valid") + } + + /// The 8-character Base32 teleport password to share with the receiver out-of-band. + pub fn teleport_password(&self) -> &str { + self.teleport_password.as_str() + } + + /// Encrypt the payload and produce a `SenderPacket`. + /// + /// Encryption flow (per spec): + /// 1. Serialize payload → inner_plain + /// 2. Append 2-byte checksum → inner_with_cs + /// 3. inner_key = PBKDF2(session_key, teleport_pass) + /// 4. layer2 = AES-CTR(inner_key, inner_with_cs) + /// 5. Append 2-byte checksum to layer2 → outer_with_cs + /// 6. body = AES-CTR(session_key, outer_with_cs) + /// 7. S packet = sender_pubkey (33 bytes) || body + pub fn encrypt(&self, payload: &Payload) -> SenderPacket { + let secp = Secp256k1::new(); + let sender_pubkey = self.privkey().public_key(&secp); + + let inner_plain = payload.to_bytes(); + let inner_cs = checksum(&inner_plain); + let mut inner_with_cs = inner_plain; + inner_with_cs.extend_from_slice(&inner_cs); + + let inner_key = pbkdf2_stretch(&self.session_key, self.teleport_password.as_bytes()); + let layer2 = aes256ctr(&inner_key, &inner_with_cs); + + let outer_cs = checksum(&layer2); + let mut outer_with_cs = layer2; + outer_with_cs.extend_from_slice(&outer_cs); + + let body = aes256ctr(&self.session_key, &outer_with_cs); + + SenderPacket::new(sender_pubkey, body) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::receiver::ReceiverSession; + + #[test] + fn teleport_password_is_8_base32_chars() { + let receiver = ReceiverSession::generate(); + let r_pkt = receiver.to_packet(); + let sender = SenderSession::new(&r_pkt, receiver.numeric_code()).unwrap(); + let pw = sender.teleport_password(); + assert_eq!(pw.len(), 8); + assert!(pw.chars().all(|c| c.is_ascii_alphanumeric())); + } + + #[test] + fn wrong_numeric_code_gives_error() { + let receiver = ReceiverSession::generate(); + let r_pkt = receiver.to_packet(); + let wrong_code = (receiver.numeric_code() + 1) % 100_000_000; + // With overwhelming probability, wrong key → invalid pubkey bytes → error + let result = SenderSession::new(&r_pkt, wrong_code); + assert!(result.is_err()); + } +}