use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; pub type Result = std::result::Result; #[derive(Debug)] pub struct Error(String); impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.0.fmt(f) } } impl std::error::Error for Error {} #[derive(Debug, Clone, PartialEq, Eq)] pub struct QrPayload { pub pubkey: Vec, pub onion: String, pub signed_prekey: Vec, } /// Cap invite text before hex decode. Honest v1 is a few hundred bytes. const MAX_INVITE: usize = 4096; const HEX_K: usize = 64; const HEX_SPK: usize = 64; const HEX_SIG: usize = 128; /// `onionwire:v1:k={pubkey_hex}:o={v3onion}:spk={signed_prekey}:sig={sign(v2)}` pub fn encode(identity_sk: &[u8], onion: &str, signed_prekey: &[u8]) -> Result { if signed_prekey.len() != 32 { return Err(Error("spk must be 32 bytes".into())); } if !is_v3_onion(onion) { return Err(Error("o must be a v3 onion".into())); } let sk_bytes: [u8; 32] = identity_sk .try_into() .map_err(|_| Error("identity secret key must be 32 bytes".into()))?; let sk = SigningKey::from_bytes(&sk_bytes); let k = to_hex(&sk.verifying_key().to_bytes()); let spk = to_hex(signed_prekey); let msg = sign_msg_v2(&k, onion, &spk); let sig = to_hex(&sk.sign(&msg).to_bytes()); Ok(format!("onionwire:v1:k={k}:o={onion}:spk={spk}:sig={sig}")) } pub fn decode(raw: &str) -> Result { if raw.len() > MAX_INVITE { return Err(Error("invite too long".into())); } let (k, onion, spk, sig) = parse_fields(raw)?; if !is_hex_len(&k, HEX_K) { return Err(Error("k must be 32 bytes".into())); } if !is_hex_len(&spk, HEX_SPK) { return Err(Error("spk must be 32 bytes".into())); } if !is_hex_len(&sig, HEX_SIG) { return Err(Error("sig must be 64 bytes".into())); } if !is_v3_onion(&onion) { return Err(Error("o must be a v3 onion".into())); } let pubkey = from_hex(&k)?; let signed_prekey = from_hex(&spk)?; let sig_bytes = from_hex(&sig)?; let pk_arr: [u8; 32] = pubkey .as_slice() .try_into() .map_err(|_| Error("k must be 32 bytes".into()))?; let sig_arr: [u8; 64] = sig_bytes .as_slice() .try_into() .map_err(|_| Error("sig must be 64 bytes".into()))?; let vk = VerifyingKey::from_bytes(&pk_arr).map_err(|e| Error(e.to_string()))?; let signature = Signature::from_bytes(&sig_arr); let ok_v2 = vk .verify(&sign_msg_v2(&k, &onion, &spk), &signature) .is_ok(); let ok_v1 = vk .verify(&sign_msg_v1(&k, &onion, &spk), &signature) .is_ok(); if !ok_v2 && !ok_v1 { return Err(Error("bad signature".into())); } Ok(QrPayload { pubkey, onion, signed_prekey, }) } fn is_v3_onion(s: &str) -> bool { let Some(addr) = s.strip_suffix(".onion") else { return false; }; addr.len() == 56 && addr.bytes().all(|b| matches!(b, b'a'..=b'z' | b'2'..=b'7')) } fn is_hex_len(s: &str, n: usize) -> bool { s.len() == n && s.bytes().all(|c| c.is_ascii_hexdigit()) } fn sign_msg_v2(k: &str, onion: &str, spk: &str) -> Vec { let mut msg = Vec::with_capacity(19 + 3 + k.len() + onion.len() + spk.len()); msg.extend_from_slice(b"onionwire-invite-v1"); msg.push(0); msg.extend_from_slice(k.as_bytes()); msg.push(0); msg.extend_from_slice(onion.as_bytes()); msg.push(0); msg.extend_from_slice(spk.as_bytes()); msg } fn sign_msg_v1(k: &str, onion: &str, spk: &str) -> Vec { let mut msg = Vec::with_capacity(k.len() + onion.len() + spk.len()); msg.extend_from_slice(k.as_bytes()); msg.extend_from_slice(onion.as_bytes()); msg.extend_from_slice(spk.as_bytes()); msg } fn parse_fields(raw: &str) -> Result<(String, String, String, String)> { let rest = raw .strip_prefix("onionwire:v1:") .ok_or_else(|| Error("not onionwire:v1".into()))?; let mut k = None; let mut o = None; let mut spk = None; let mut sig = None; for part in rest.split(':') { if let Some(v) = part.strip_prefix("k=") { if k.is_some() { return Err(Error("duplicate k".into())); } k = Some(v.to_string()); } else if let Some(v) = part.strip_prefix("o=") { if o.is_some() { return Err(Error("duplicate o".into())); } o = Some(v.to_string()); } else if let Some(v) = part.strip_prefix("spk=") { if spk.is_some() { return Err(Error("duplicate spk".into())); } spk = Some(v.to_string()); } else if let Some(v) = part.strip_prefix("sig=") { if sig.is_some() { return Err(Error("duplicate sig".into())); } sig = Some(v.to_string()); } else { return Err(Error("unknown field".into())); } } Ok(( k.ok_or_else(|| Error("missing k".into()))?, o.ok_or_else(|| Error("missing o".into()))?, spk.ok_or_else(|| Error("missing spk".into()))?, sig.ok_or_else(|| Error("missing sig".into()))?, )) } fn to_hex(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{b:02x}")).collect() } fn from_hex(s: &str) -> Result> { if s.len() > HEX_SIG || s.is_empty() || !s.len().is_multiple_of(2) { return Err(Error("invalid hex".into())); } if !s.bytes().all(|c| c.is_ascii_hexdigit()) { return Err(Error("invalid hex".into())); } (0..s.len()) .step_by(2) .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| Error("invalid hex".into()))) .collect() } #[cfg(test)] mod tests { use super::*; fn v3() -> String { format!("{}.onion", "a".repeat(56)) } #[test] fn well_formed_v1_concat_still_decodes() { let sk = SigningKey::from_bytes(&[7u8; 32]); let k = to_hex(&sk.verifying_key().to_bytes()); let onion = v3(); let spk = to_hex(&[9u8; 32]); let sig = to_hex(&sk.sign(&sign_msg_v1(&k, &onion, &spk)).to_bytes()); let raw = format!("onionwire:v1:k={k}:o={onion}:spk={spk}:sig={sig}"); let p = decode(&raw).expect("legacy concat"); assert_eq!(p.onion, onion); assert_eq!(p.signed_prekey, vec![9u8; 32]); } }