2026-09-10 01:54:19 -04:00
|
|
|
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
|
|
|
|
|
|
|
|
|
pub type Result<T> = std::result::Result<T, Error>;
|
|
|
|
|
|
|
|
|
|
#[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<u8>,
|
|
|
|
|
pub onion: String,
|
|
|
|
|
pub signed_prekey: Vec<u8>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 19:52:44 -04:00
|
|
|
/// 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)}`
|
2026-09-10 01:54:19 -04:00
|
|
|
pub fn encode(identity_sk: &[u8], onion: &str, signed_prekey: &[u8]) -> Result<String> {
|
2026-09-10 19:52:44 -04:00
|
|
|
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()));
|
|
|
|
|
}
|
2026-09-10 01:54:19 -04:00
|
|
|
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);
|
2026-09-10 19:52:44 -04:00
|
|
|
let msg = sign_msg_v2(&k, onion, &spk);
|
2026-09-10 01:54:19 -04:00
|
|
|
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<QrPayload> {
|
2026-09-10 19:52:44 -04:00
|
|
|
if raw.len() > MAX_INVITE {
|
|
|
|
|
return Err(Error("invite too long".into()));
|
|
|
|
|
}
|
2026-09-10 01:54:19 -04:00
|
|
|
let (k, onion, spk, sig) = parse_fields(raw)?;
|
2026-09-10 19:52:44 -04:00
|
|
|
if !is_hex_len(&k, HEX_K) {
|
2026-09-10 01:54:19 -04:00
|
|
|
return Err(Error("k must be 32 bytes".into()));
|
|
|
|
|
}
|
2026-09-10 19:52:44 -04:00
|
|
|
if !is_hex_len(&spk, HEX_SPK) {
|
|
|
|
|
return Err(Error("spk must be 32 bytes".into()));
|
|
|
|
|
}
|
|
|
|
|
if !is_hex_len(&sig, HEX_SIG) {
|
2026-09-10 01:54:19 -04:00
|
|
|
return Err(Error("sig must be 64 bytes".into()));
|
|
|
|
|
}
|
2026-09-10 19:52:44 -04:00
|
|
|
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)?;
|
2026-09-10 01:54:19 -04:00
|
|
|
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);
|
2026-09-10 19:52:44 -04:00
|
|
|
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()));
|
|
|
|
|
}
|
2026-09-10 01:54:19 -04:00
|
|
|
Ok(QrPayload {
|
|
|
|
|
pubkey,
|
|
|
|
|
onion,
|
|
|
|
|
signed_prekey,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 19:52:44 -04:00
|
|
|
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<u8> {
|
|
|
|
|
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<u8> {
|
2026-09-10 01:54:19 -04:00
|
|
|
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<Vec<u8>> {
|
2026-09-10 19:52:44 -04:00
|
|
|
if s.len() > HEX_SIG || s.is_empty() || !s.len().is_multiple_of(2) {
|
2026-09-10 01:54:19 -04:00
|
|
|
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()
|
|
|
|
|
}
|
2026-09-10 19:52:44 -04:00
|
|
|
|
|
|
|
|
#[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]);
|
|
|
|
|
}
|
|
|
|
|
}
|