use crate::bridge::envelope::SignedEnvelope;
use serde::{Deserialize, Serialize};
pub const MOBILE_WS_PATH: &str = "/api/v1/mobile/ws";
pub const HITL_RESPOND_METHOD: &str = "channel/hitl_respond";
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientFrame {
Hello {
pubkey: String,
token: String,
agent: String,
},
HelloInit {
proto: u32,
agent: String,
pubkey: String,
wid: String,
},
HelloProof { wid: String, proof: Vec<u8> },
Resume { pubkey: String, agent: String },
ResumeProof { envelope: SignedEnvelope },
Envelope { envelope: SignedEnvelope },
AudioStreamStart { sample_rate: u32 },
AudioChunk { data: String },
AudioStreamEnd,
ChannelQuery {
op: String,
#[serde(default)]
channel_id: Option<String>,
#[serde(default)]
since_seq: Option<u64>,
},
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerFrame {
Paired {
agent: String,
#[serde(default)]
confirm: Vec<u8>,
},
PairChallenge {
wid: String,
nonce: Vec<u8>,
did: String,
},
Rejected { reason: String },
Challenge { nonce: String },
Event {
name: String,
payload: serde_json::Value,
},
Transcript { text: String, is_final: bool },
AudioChunk {
base64: String,
sample_rate: u32,
done: bool,
},
ChannelData {
op: String,
payload: serde_json::Value,
},
}
const PAIR_DOMAIN: &[u8] = b"mur-pair-v2";
pub const PAIR_ROLE_PHONE_TO_DAEMON: &[u8] = b"mur-pair-v2/phone->daemon";
pub const PAIR_ROLE_DAEMON_TO_PHONE: &[u8] = b"mur-pair-v2/daemon->phone";
pub fn pair_transcript(
role: &[u8],
proto: u32,
agent: &str,
wid: &str,
did: &str,
phone_pubkey: &str,
nonce: &[u8],
) -> Vec<u8> {
fn put(out: &mut Vec<u8>, field: &[u8]) {
out.extend_from_slice(&(field.len() as u32).to_le_bytes());
out.extend_from_slice(field);
}
let mut out = Vec::new();
put(&mut out, PAIR_DOMAIN);
put(&mut out, role);
out.extend_from_slice(&proto.to_le_bytes());
put(&mut out, agent.as_bytes());
put(&mut out, wid.as_bytes());
put(&mut out, did.as_bytes());
put(&mut out, phone_pubkey.as_bytes());
put(&mut out, nonce);
out
}
pub fn pair_proof(token: &[u8], transcript: &[u8]) -> [u8; 32] {
use hmac::{Hmac, Mac};
let mut mac = <Hmac<sha2::Sha256> as Mac>::new_from_slice(token)
.expect("HMAC accepts a key of any length");
mac.update(transcript);
mac.finalize().into_bytes().into()
}
pub fn ct_verify(a: &[u8], b: &[u8]) -> bool {
use subtle::ConstantTimeEq;
a.len() == b.len() && a.ct_eq(b).into()
}
pub fn mint_nonce() -> [u8; 32] {
use rand_core::RngCore;
let mut n = [0u8; 32];
rand_core::OsRng.fill_bytes(&mut n);
n
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn channel_query_and_data_frames_round_trip() {
let q = ClientFrame::ChannelQuery {
op: "events".into(),
channel_id: Some("c1".into()),
since_seq: Some(3),
};
let s = serde_json::to_string(&q).unwrap();
assert!(s.contains("\"type\":\"channel_query\""));
let back: ClientFrame = serde_json::from_str(&s).unwrap();
matches!(back, ClientFrame::ChannelQuery { .. });
let d = ServerFrame::ChannelData {
op: "list".into(),
payload: serde_json::json!([]),
};
let s2 = serde_json::to_string(&d).unwrap();
assert!(s2.contains("\"type\":\"channel_data\""));
}
#[test]
fn pair_proof_round_trips_and_rejects_wrong_token() {
let token = b"the-122-bit-token";
let t = pair_transcript(
PAIR_ROLE_PHONE_TO_DAEMON,
2,
"mur",
"wid-1",
"did-1",
"zPHONE",
&[7u8; 32],
);
let proof = pair_proof(token, &t);
assert!(ct_verify(&proof, &pair_proof(token, &t)));
assert!(!ct_verify(&proof, &pair_proof(b"wrong-token", &t)));
}
#[test]
fn transcript_is_unambiguous_and_direction_tagged() {
let a = pair_transcript(
PAIR_ROLE_PHONE_TO_DAEMON,
2,
"mur",
"w",
"d",
"p",
&[0u8; 32],
);
let b = pair_transcript(
PAIR_ROLE_PHONE_TO_DAEMON,
2,
"mu",
"rw",
"d",
"p",
&[0u8; 32],
);
assert_ne!(a, b, "length-prefixing prevents field-boundary collisions");
let phone = pair_transcript(
PAIR_ROLE_PHONE_TO_DAEMON,
2,
"mur",
"w",
"d",
"p",
&[1u8; 32],
);
let daemon = pair_transcript(
PAIR_ROLE_DAEMON_TO_PHONE,
2,
"mur",
"w",
"d",
"p",
&[1u8; 32],
);
assert_ne!(phone, daemon, "per-direction role tags differ");
let tok = b"k";
assert!(!ct_verify(
&pair_proof(tok, &phone),
&pair_proof(tok, &daemon)
));
}
#[test]
fn mint_nonce_is_fresh() {
assert_ne!(mint_nonce(), mint_nonce(), "nonces must not repeat");
}
#[test]
fn enrollment_frames_never_carry_the_token() {
let token = "SECRET-TOKEN-do-not-leak-0000";
let t = pair_transcript(
PAIR_ROLE_PHONE_TO_DAEMON,
2,
"mur",
"wid",
"did",
"zPHONE",
&[0u8; 32],
);
let proof = pair_proof(token.as_bytes(), &t);
let frames = [
serde_json::to_string(&ClientFrame::HelloInit {
proto: 2,
agent: "mur".into(),
pubkey: "zPHONE".into(),
wid: "wid".into(),
})
.unwrap(),
serde_json::to_string(&ClientFrame::HelloProof {
wid: "wid".into(),
proof: proof.to_vec(),
})
.unwrap(),
];
for f in frames {
assert!(!f.contains(token), "token leaked into a wire frame: {f}");
}
}
}