onionwire/src/qr.rs
Sirius DevOps 4fb4648552
[verified] Add QR codec and ratatui roster.
M2: onionwire:v1 signed payload, Unicode QR, same-k locator update,
unknown-k approval in TUI. F4 is a no-op stub. No Tor in the bin.
2026-09-10 01:54:19 -04:00

138 lines
4.5 KiB
Rust

use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use qrcode::render::unicode::Dense1x2;
use qrcode::QrCode;
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>,
}
/// `onionwire:v1:k={pubkey_hex}:o={v3onion}:spk={signed_prekey}:sig={sign(k||o||spk)}`
pub fn encode(identity_sk: &[u8], onion: &str, signed_prekey: &[u8]) -> Result<String> {
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(&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<QrPayload> {
let (k, onion, spk, sig) = parse_fields(raw)?;
let pubkey = from_hex(&k)?;
let signed_prekey = from_hex(&spk)?;
let sig_bytes = from_hex(&sig)?;
if pubkey.len() != 32 {
return Err(Error("k must be 32 bytes".into()));
}
if sig_bytes.len() != 64 {
return Err(Error("sig must be 64 bytes".into()));
}
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 msg = sign_msg(&k, &onion, &spk);
vk.verify(&msg, &signature)
.map_err(|_| Error("bad signature".into()))?;
Ok(QrPayload {
pubkey,
onion,
signed_prekey,
})
}
pub fn render_unicode(payload: &str) -> Result<String> {
let code = QrCode::new(payload.as_bytes()).map_err(|e| Error(e.to_string()))?;
Ok(code.render::<Dense1x2>().build())
}
fn sign_msg(k: &str, onion: &str, spk: &str) -> Vec<u8> {
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>> {
if 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()
}