Formatting only. CI now runs cargo fmt --check here as in the other Rust repos. Co-Authored-By: Claude Opus 5.5 <[email protected]>
719 lines
29 KiB
Rust
719 lines
29 KiB
Rust
//! The app's side of the browser protocol: pairing, encrypted sessions and request handling.
|
|
//!
|
|
//! Outer frames (JSON, field `t`), relayed unchanged by `kestrove-nmh`:
|
|
//!
|
|
//! | extension → app | app → extension |
|
|
//! |---|---|
|
|
//! | `{t:"nmh_hello", caller}` (sent by the host itself) | `{t:"ok"}` |
|
|
//! | `{t:"ping"}` | `{t:"pong", version}` |
|
|
//! | `{t:"pair_init", pub, name}` | `{t:"pair_started", pairing_id, pub}` (app shows the words) |
|
|
//! | `{t:"pair_confirm", pairing_id, proof}` | `{t:"pair_waiting"}` / `{t:"pair_done"}` / error |
|
|
//! | `{t:"open", pairing_id, nonce}` | `{t:"opened", nonce}` / `{t:"error", code:"not_paired"}` |
|
|
//! | `{t:"msg", n, c}` | `{t:"msg", n, c}` / `{t:"error", code:"bad_session"}` |
|
|
//!
|
|
//! `msg` plaintexts are `{rid, body}` where `body` is a `NativeRequest` / `NativeResponse` from
|
|
//! kestrove-browser/src/protocol.ts. Origins come from the extension's `sender`, and are
|
|
//! re-checked here against the vault before any secret leaves the app.
|
|
|
|
use std::io::{self, Read, Write};
|
|
use std::path::PathBuf;
|
|
use std::sync::{Arc, Mutex, MutexGuard};
|
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|
|
|
use base64::engine::general_purpose::STANDARD as B64;
|
|
use base64::Engine;
|
|
use kv_ffi::{CoreError, ItemDto, ItemKindDto, VaultCore};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{json, Value};
|
|
use zeroize::Zeroizing;
|
|
|
|
use crate::channel::{random, verify_confirm, Ephemeral, Session};
|
|
use crate::framing::{read_frame, write_json};
|
|
use crate::private_file;
|
|
|
|
/// How long a pairing request waits for the user in the app.
|
|
const PAIRING_TTL: Duration = Duration::from_secs(300);
|
|
/// How long a save prompt stays in the app.
|
|
const SAVE_TTL: Duration = Duration::from_secs(600);
|
|
const MAX_PENDING_SAVES: usize = 8;
|
|
|
|
/// What the app UI must show. Implemented with Tauri events in the app, and auto-approving in tests.
|
|
pub trait Ui: Send + Sync {
|
|
fn pairing_requested(&self, req: &PairingView);
|
|
fn save_requested(&self, req: &SaveView);
|
|
/// A user-driven request (a fill) arrived: counts as activity for auto-lock.
|
|
fn activity(&self) {}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct PairingView {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub caller: String,
|
|
pub words: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct SaveView {
|
|
pub id: String,
|
|
pub origin: String,
|
|
pub username: String,
|
|
/// Some item already has this username for this site: saving updates its password.
|
|
pub update: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct StoredPairing {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub caller: String,
|
|
pub words: Vec<String>,
|
|
pub created_at: u64,
|
|
key: String,
|
|
}
|
|
|
|
impl StoredPairing {
|
|
fn key(&self) -> Option<Zeroizing<[u8; 32]>> {
|
|
let raw = Zeroizing::new(B64.decode(&self.key).ok()?);
|
|
let arr: [u8; 32] = raw.as_slice().try_into().ok()?;
|
|
Some(Zeroizing::new(arr))
|
|
}
|
|
}
|
|
|
|
/// Persisted pairings (`pairings.json`, 0600). The pairing key only authenticates the
|
|
/// extension to the app; it can't decrypt the vault. Every secret still needs an unlocked vault.
|
|
pub struct PairingStore {
|
|
path: Option<PathBuf>,
|
|
items: Mutex<Vec<StoredPairing>>,
|
|
}
|
|
|
|
impl PairingStore {
|
|
pub fn in_memory() -> Self {
|
|
Self { path: None, items: Mutex::new(Vec::new()) }
|
|
}
|
|
|
|
pub fn open(path: PathBuf) -> io::Result<Self> {
|
|
let items = match private_file::read(&path)? {
|
|
Some(b) => serde_json::from_slice(&b).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?,
|
|
None => Vec::new(),
|
|
};
|
|
Ok(Self { path: Some(path), items: Mutex::new(items) })
|
|
}
|
|
|
|
fn guard(&self) -> MutexGuard<'_, Vec<StoredPairing>> {
|
|
self.items.lock().unwrap_or_else(|e| e.into_inner())
|
|
}
|
|
|
|
fn persist(&self, items: &[StoredPairing]) -> io::Result<()> {
|
|
match &self.path {
|
|
Some(path) => private_file::write(path, &serde_json::to_vec_pretty(items)?),
|
|
None => Ok(()),
|
|
}
|
|
}
|
|
|
|
pub fn list(&self) -> Vec<StoredPairing> {
|
|
self.guard().clone()
|
|
}
|
|
|
|
fn get(&self, id: &str) -> Option<StoredPairing> {
|
|
self.guard().iter().find(|p| p.id == id).cloned()
|
|
}
|
|
|
|
fn add(&self, p: StoredPairing) -> io::Result<()> {
|
|
let mut items = self.guard();
|
|
items.push(p);
|
|
self.persist(&items)
|
|
}
|
|
|
|
pub fn remove(&self, id: &str) -> io::Result<bool> {
|
|
let mut items = self.guard();
|
|
let before = items.len();
|
|
items.retain(|p| p.id != id);
|
|
self.persist(&items)?;
|
|
Ok(items.len() != before)
|
|
}
|
|
}
|
|
|
|
struct PendingPairing {
|
|
view: PairingView,
|
|
key: Zeroizing<[u8; 32]>,
|
|
at: Instant,
|
|
}
|
|
|
|
struct PendingSave {
|
|
id: String,
|
|
origin: String,
|
|
username: String,
|
|
password: Zeroizing<String>,
|
|
at: Instant,
|
|
}
|
|
|
|
pub struct Broker {
|
|
core: Arc<VaultCore>,
|
|
ui: Arc<dyn Ui>,
|
|
pairings: PairingStore,
|
|
pending_pairing: Mutex<Option<PendingPairing>>,
|
|
saves: Mutex<Vec<PendingSave>>,
|
|
}
|
|
|
|
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
|
|
m.lock().unwrap_or_else(|e| e.into_inner())
|
|
}
|
|
|
|
fn now_secs() -> u64 {
|
|
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
|
}
|
|
|
|
fn err(code: &str) -> Value {
|
|
json!({ "type": "error", "code": code })
|
|
}
|
|
|
|
fn core_err(e: &CoreError) -> Value {
|
|
match e {
|
|
CoreError::Locked | CoreError::NoAccount => err("locked"),
|
|
CoreError::NotFound => err("origin_mismatch"),
|
|
_ => err("internal"),
|
|
}
|
|
}
|
|
|
|
fn b64_array<const N: usize>(v: &Value) -> Option<[u8; N]> {
|
|
B64.decode(v.as_str()?).ok()?.try_into().ok()
|
|
}
|
|
|
|
/// `scheme://host[:port]` for http(s) origins; anything else (paths, credentials, other schemes) is rejected.
|
|
pub fn normalize_origin(raw: &str) -> Option<String> {
|
|
let u = url::Url::parse(raw).ok()?;
|
|
if !matches!(u.scheme(), "https" | "http") || u.host_str().is_none_or(str::is_empty) {
|
|
return None;
|
|
}
|
|
if !u.username().is_empty() || u.password().is_some() {
|
|
return None;
|
|
}
|
|
Some(u.origin().ascii_serialization())
|
|
}
|
|
|
|
/// Per-connection state.
|
|
pub struct Conn {
|
|
caller: String,
|
|
session: Option<Session>,
|
|
}
|
|
|
|
impl Broker {
|
|
pub fn new(core: Arc<VaultCore>, ui: Arc<dyn Ui>, pairings: PairingStore) -> Arc<Self> {
|
|
Arc::new(Self { core, ui, pairings, pending_pairing: Mutex::new(None), saves: Mutex::new(Vec::new()) })
|
|
}
|
|
|
|
pub fn core(&self) -> &Arc<VaultCore> {
|
|
&self.core
|
|
}
|
|
|
|
/// Serves one `kestrove-nmh` connection until it closes. Strict request → response.
|
|
pub fn serve(&self, mut io: impl Read + Write) -> io::Result<()> {
|
|
let mut conn = Conn { caller: String::new(), session: None };
|
|
while let Some(frame) = read_frame(&mut io)? {
|
|
let reply = match serde_json::from_slice::<Value>(&frame) {
|
|
Ok(v) => self.handle_frame(&mut conn, &v),
|
|
Err(_) => json!({ "t": "error", "code": "bad_request" }),
|
|
};
|
|
write_json(&mut io, &reply)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn handle_frame(&self, conn: &mut Conn, v: &Value) -> Value {
|
|
let outer_err = |code: &str| json!({ "t": "error", "code": code });
|
|
match v.get("t").and_then(Value::as_str) {
|
|
Some("nmh_hello") => {
|
|
conn.caller = v.get("caller").and_then(Value::as_str).unwrap_or("").chars().take(200).collect();
|
|
json!({ "t": "ok" })
|
|
}
|
|
Some("ping") => json!({ "t": "pong", "version": env!("CARGO_PKG_VERSION") }),
|
|
Some("pair_init") => {
|
|
let Some(ext_pub) = v.get("pub").and_then(b64_array::<32>) else { return outer_err("bad_request") };
|
|
let name: String =
|
|
v.get("name").and_then(Value::as_str).unwrap_or("Browser").chars().take(80).collect();
|
|
self.pair_init(conn, &ext_pub, name).unwrap_or_else(|| outer_err("bad_request"))
|
|
}
|
|
Some("pair_confirm") => {
|
|
let (Some(id), Some(proof)) = (
|
|
v.get("pairing_id").and_then(Value::as_str),
|
|
v.get("proof").and_then(Value::as_str).and_then(|p| B64.decode(p).ok()),
|
|
) else {
|
|
return outer_err("bad_request");
|
|
};
|
|
self.pair_confirm(id, &proof)
|
|
}
|
|
Some("open") => {
|
|
let (Some(id), Some(ne)) =
|
|
(v.get("pairing_id").and_then(Value::as_str), v.get("nonce").and_then(b64_array::<16>))
|
|
else {
|
|
return outer_err("bad_request");
|
|
};
|
|
let Some(key) = self.pairings.get(id).and_then(|p| p.key()) else { return outer_err("not_paired") };
|
|
let na = random::<16>();
|
|
conn.session = Some(Session::app(&key, &ne, &na));
|
|
json!({ "t": "opened", "nonce": B64.encode(na) })
|
|
}
|
|
Some("msg") => {
|
|
let Some(session) = conn.session.as_mut() else { return outer_err("bad_session") };
|
|
let (Some(n), Some(c)) = (
|
|
v.get("n").and_then(Value::as_u64),
|
|
v.get("c").and_then(Value::as_str).and_then(|c| B64.decode(c).ok()),
|
|
) else {
|
|
return outer_err("bad_request");
|
|
};
|
|
let Ok(pt) = session.open(n, &c) else {
|
|
conn.session = None; // the extension re-opens a fresh session
|
|
return outer_err("bad_session");
|
|
};
|
|
let parsed: Option<Value> = serde_json::from_slice(&pt).ok();
|
|
drop(pt);
|
|
let (rid, body) = match &parsed {
|
|
Some(p) => {
|
|
(p.get("rid").cloned().unwrap_or(Value::Null), p.get("body").cloned().unwrap_or(Value::Null))
|
|
}
|
|
None => (Value::Null, Value::Null),
|
|
};
|
|
let reply = Zeroizing::new(
|
|
serde_json::to_vec(&json!({ "rid": rid, "body": self.handle_request(&body) })).unwrap_or_default(),
|
|
);
|
|
let session = conn.session.as_mut().expect("session still open");
|
|
let (n, c) = session.seal(&reply);
|
|
json!({ "t": "msg", "n": n, "c": B64.encode(c) })
|
|
}
|
|
_ => outer_err("unknown_type"),
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------ pairing
|
|
|
|
fn pair_init(&self, conn: &Conn, ext_pub: &[u8; 32], name: String) -> Option<Value> {
|
|
let eph = Ephemeral::generate();
|
|
let p = eph.pair(ext_pub)?;
|
|
let view = PairingView {
|
|
id: uuid::Uuid::new_v4().to_string(),
|
|
name,
|
|
caller: conn.caller.clone(),
|
|
words: p.words.iter().map(|w| w.to_string()).collect(),
|
|
};
|
|
let reply = json!({ "t": "pair_started", "pairing_id": view.id, "pub": B64.encode(eph.public) });
|
|
self.ui.pairing_requested(&view);
|
|
// One pending request at a time; a new one replaces the old (and its words).
|
|
*lock(&self.pending_pairing) = Some(PendingPairing { view, key: p.key, at: Instant::now() });
|
|
Some(reply)
|
|
}
|
|
|
|
fn pair_confirm(&self, id: &str, proof: &[u8]) -> Value {
|
|
if let Some(key) = self.pairings.get(id).and_then(|p| p.key()) {
|
|
return if verify_confirm(&key, id, proof) {
|
|
json!({ "t": "pair_done" })
|
|
} else {
|
|
json!({ "t": "error", "code": "not_paired" })
|
|
};
|
|
}
|
|
match lock(&self.pending_pairing).as_ref() {
|
|
Some(p) if p.view.id == id && p.at.elapsed() < PAIRING_TTL && verify_confirm(&p.key, id, proof) => {
|
|
json!({ "t": "pair_waiting" })
|
|
}
|
|
_ => json!({ "t": "error", "code": "not_paired" }),
|
|
}
|
|
}
|
|
|
|
pub fn pending_pairing(&self) -> Option<PairingView> {
|
|
let mut g = lock(&self.pending_pairing);
|
|
if g.as_ref().is_some_and(|p| p.at.elapsed() >= PAIRING_TTL) {
|
|
*g = None;
|
|
}
|
|
g.as_ref().map(|p| p.view.clone())
|
|
}
|
|
|
|
/// The user compared the words and accepted (or rejected) the pairing. Requires an unlocked
|
|
/// vault, so someone at a locked machine can't pair a new extension.
|
|
pub fn resolve_pairing(&self, id: &str, accept: bool) -> Result<(), String> {
|
|
let mut g = lock(&self.pending_pairing);
|
|
let Some(p) = g.take_if(|p| p.view.id == id) else { return Err("This pairing request expired.".into()) };
|
|
if !accept {
|
|
return Ok(());
|
|
}
|
|
if self.core.is_locked() {
|
|
*g = Some(p);
|
|
return Err("Unlock Kestrove to pair a browser.".into());
|
|
}
|
|
if p.at.elapsed() >= PAIRING_TTL {
|
|
return Err("This pairing request expired.".into());
|
|
}
|
|
self.pairings
|
|
.add(StoredPairing {
|
|
id: p.view.id,
|
|
name: p.view.name,
|
|
caller: p.view.caller,
|
|
words: p.view.words,
|
|
created_at: now_secs(),
|
|
key: B64.encode(p.key.as_ref()),
|
|
})
|
|
.map_err(|e| format!("Could not save the pairing: {e}"))
|
|
}
|
|
|
|
pub fn pairings(&self) -> Vec<StoredPairing> {
|
|
self.pairings.list()
|
|
}
|
|
|
|
pub fn revoke_pairing(&self, id: &str) -> Result<(), String> {
|
|
self.pairings.remove(id).map(|_| ()).map_err(|e| e.to_string())
|
|
}
|
|
|
|
// ------------------------------------------------------------ requests
|
|
|
|
pub fn handle_request(&self, body: &Value) -> Value {
|
|
let Some(kind) = body.get("type").and_then(Value::as_str) else { return err("bad_request") };
|
|
if kind == "hello" {
|
|
return json!({ "type": "hello", "version": env!("CARGO_PKG_VERSION"), "paired": true });
|
|
}
|
|
let Some(origin) = body.get("origin").and_then(Value::as_str).and_then(normalize_origin) else {
|
|
return err("bad_request");
|
|
};
|
|
let item_id = || body.get("itemId").and_then(Value::as_str).filter(|s| !s.is_empty());
|
|
match kind {
|
|
"query" => match self.core.items_for_url(origin) {
|
|
Ok(items) => json!({
|
|
"type": "identities",
|
|
"items": items.iter().map(|i| json!({
|
|
"id": i.id, "title": i.title, "username": i.username.clone().unwrap_or_default(), "totp": i.has_totp,
|
|
})).collect::<Vec<_>>(),
|
|
}),
|
|
Err(e) => core_err(&e),
|
|
},
|
|
"fill" => {
|
|
let Some(id) = item_id() else { return err("bad_request") };
|
|
self.ui.activity();
|
|
match self.matching_item(&origin, id) {
|
|
Ok(d) => json!({
|
|
"type": "credential",
|
|
"username": d.username.clone().unwrap_or_default(),
|
|
"password": d.password.clone().unwrap_or_default(),
|
|
}),
|
|
Err(v) => v,
|
|
}
|
|
}
|
|
"totp" => {
|
|
let Some(id) = item_id() else { return err("bad_request") };
|
|
self.ui.activity();
|
|
match self.matching_item(&origin, id) {
|
|
Ok(d) => match d.totp.as_deref().map(|t| kv_ffi::totp_code(t.to_string(), now_secs())) {
|
|
Some(Ok(c)) => {
|
|
json!({ "type": "totp", "code": c.code, "secondsRemaining": c.seconds_remaining })
|
|
}
|
|
_ => err("bad_request"),
|
|
},
|
|
Err(v) => v,
|
|
}
|
|
}
|
|
"save" => {
|
|
let username = body.get("username").and_then(Value::as_str).unwrap_or("");
|
|
let Some(password) = body.get("password").and_then(Value::as_str).filter(|p| !p.is_empty()) else {
|
|
return err("bad_request");
|
|
};
|
|
if username.chars().count() > 512 || password.chars().count() > 4096 {
|
|
return err("bad_request");
|
|
}
|
|
self.queue_save(origin, username.to_string(), Zeroizing::new(password.to_string()))
|
|
}
|
|
_ => err("unknown_type"),
|
|
}
|
|
}
|
|
|
|
/// The item, if (and only if) it matches the requesting origin. The id alone is never enough.
|
|
fn matching_item(&self, origin: &str, id: &str) -> Result<ItemDto, Value> {
|
|
let candidates = self.core.items_for_url(origin.to_string()).map_err(|e| core_err(&e))?;
|
|
if !candidates.iter().any(|i| i.id == id) {
|
|
return Err(err("origin_mismatch"));
|
|
}
|
|
self.core.get_item(id.to_string()).map_err(|e| core_err(&e))
|
|
}
|
|
|
|
/// The existing login on this origin with this username, if the vault is unlocked.
|
|
fn existing(&self, origin: &str, username: &str) -> Option<ItemDto> {
|
|
let items = self.core.items_for_url(origin.to_string()).ok()?;
|
|
let s = items.into_iter().find(|i| i.username.as_deref().unwrap_or("") == username)?;
|
|
self.core.get_item(s.id).ok()
|
|
}
|
|
|
|
fn queue_save(&self, origin: String, username: String, password: Zeroizing<String>) -> Value {
|
|
if let Some(e) = self.existing(&origin, &username) {
|
|
if e.password.as_deref() == Some(password.as_str()) {
|
|
return json!({ "type": "saved" }); // already up to date: no prompt
|
|
}
|
|
}
|
|
let save = PendingSave { id: uuid::Uuid::new_v4().to_string(), origin, username, password, at: Instant::now() };
|
|
let view = self.save_view(&save);
|
|
{
|
|
let mut q = lock(&self.saves);
|
|
q.retain(|s| s.at.elapsed() < SAVE_TTL && !(s.origin == save.origin && s.username == save.username));
|
|
if q.len() >= MAX_PENDING_SAVES {
|
|
q.remove(0);
|
|
}
|
|
q.push(save);
|
|
}
|
|
self.ui.save_requested(&view);
|
|
json!({ "type": "save_prompted" })
|
|
}
|
|
|
|
fn save_view(&self, s: &PendingSave) -> SaveView {
|
|
SaveView {
|
|
id: s.id.clone(),
|
|
origin: s.origin.clone(),
|
|
username: s.username.clone(),
|
|
update: self.existing(&s.origin, &s.username).is_some(),
|
|
}
|
|
}
|
|
|
|
pub fn pending_saves(&self) -> Vec<SaveView> {
|
|
let mut q = lock(&self.saves);
|
|
q.retain(|s| s.at.elapsed() < SAVE_TTL);
|
|
let pending: Vec<_> = q.iter().map(|s| (s.id.clone(), s.origin.clone(), s.username.clone())).collect();
|
|
drop(q);
|
|
pending
|
|
.into_iter()
|
|
.map(|(id, origin, username)| SaveView {
|
|
update: self.existing(&origin, &username).is_some(),
|
|
id,
|
|
origin,
|
|
username,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// The user answered a save prompt in the app. Returns the saved item id.
|
|
pub fn resolve_save(&self, id: &str, accept: bool) -> Result<Option<String>, String> {
|
|
let s = {
|
|
let mut q = lock(&self.saves);
|
|
let Some(pos) = q.iter().position(|s| s.id == id) else { return Err("This save request expired.".into()) };
|
|
if accept && self.core.is_locked() {
|
|
return Err("Unlock Kestrove to save.".into());
|
|
}
|
|
q.remove(pos)
|
|
};
|
|
if !accept {
|
|
return Ok(None);
|
|
}
|
|
let item = match self.existing(&s.origin, &s.username) {
|
|
Some(mut e) => {
|
|
e.password = Some(s.password.to_string());
|
|
e
|
|
}
|
|
None => ItemDto {
|
|
id: String::new(),
|
|
kind: ItemKindDto::Login,
|
|
title: url::Url::parse(&s.origin)
|
|
.ok()
|
|
.and_then(|u| u.host_str().map(|h| h.trim_start_matches("www.").to_string()))
|
|
.unwrap_or_else(|| s.origin.clone()),
|
|
username: (!s.username.is_empty()).then(|| s.username.clone()),
|
|
password: Some(s.password.to_string()),
|
|
urls: vec![s.origin.clone()],
|
|
totp: None,
|
|
notes: None,
|
|
favorite: false,
|
|
tags: vec![],
|
|
modified_at: 0,
|
|
},
|
|
};
|
|
self.core.save_item(item).map(Some).map_err(|e| e.to_string())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::channel::{make_confirm, Session as Sess};
|
|
|
|
struct AutoUi;
|
|
impl Ui for AutoUi {
|
|
fn pairing_requested(&self, _: &PairingView) {}
|
|
fn save_requested(&self, _: &SaveView) {}
|
|
}
|
|
|
|
fn broker_with_item() -> (Arc<Broker>, String) {
|
|
let core = VaultCore::new_in_memory();
|
|
core.create_account("correct horse battery".into()).unwrap();
|
|
let id = core
|
|
.save_item(ItemDto {
|
|
id: String::new(),
|
|
kind: ItemKindDto::Login,
|
|
title: "Example".into(),
|
|
username: Some("alice".into()),
|
|
password: Some("s3cret".into()),
|
|
urls: vec!["https://example.com".into()],
|
|
totp: Some("JBSWY3DPEHPK3PXP".into()),
|
|
notes: None,
|
|
favorite: false,
|
|
tags: vec![],
|
|
modified_at: 0,
|
|
})
|
|
.unwrap();
|
|
(Broker::new(core, Arc::new(AutoUi), PairingStore::in_memory()), id)
|
|
}
|
|
|
|
/// Plays the extension: pairs, has the "user" approve in the app, opens a session.
|
|
fn pair_and_open(b: &Broker, conn: &mut Conn) -> Sess {
|
|
let ext = Ephemeral::generate();
|
|
let r = b.handle_frame(conn, &json!({ "t": "pair_init", "pub": B64.encode(ext.public), "name": "Test" }));
|
|
assert_eq!(r["t"], "pair_started");
|
|
let id = r["pairing_id"].as_str().unwrap().to_string();
|
|
let app_pub: [u8; 32] = B64.decode(r["pub"].as_str().unwrap()).unwrap().try_into().unwrap();
|
|
let shared_ext = ext.pair_as_ext(&app_pub).unwrap();
|
|
assert_eq!(shared_ext.words.to_vec(), b.pending_pairing().unwrap().words);
|
|
let proof = B64.encode(make_confirm(&shared_ext.key, &id));
|
|
let r = b.handle_frame(conn, &json!({ "t": "pair_confirm", "pairing_id": id, "proof": proof }));
|
|
assert_eq!(r["t"], "pair_waiting");
|
|
b.resolve_pairing(&id, true).unwrap();
|
|
let r = b.handle_frame(conn, &json!({ "t": "pair_confirm", "pairing_id": id, "proof": proof }));
|
|
assert_eq!(r["t"], "pair_done");
|
|
|
|
let ne = random::<16>();
|
|
let r = b.handle_frame(conn, &json!({ "t": "open", "pairing_id": id, "nonce": B64.encode(ne) }));
|
|
assert_eq!(r["t"], "opened");
|
|
let na: [u8; 16] = B64.decode(r["nonce"].as_str().unwrap()).unwrap().try_into().unwrap();
|
|
Sess::ext(&shared_ext.key, &ne, &na)
|
|
}
|
|
|
|
fn call(b: &Broker, conn: &mut Conn, s: &mut Sess, body: Value) -> Value {
|
|
let (n, c) = s.seal(&serde_json::to_vec(&json!({ "rid": 7, "body": body })).unwrap());
|
|
let r = b.handle_frame(conn, &json!({ "t": "msg", "n": n, "c": B64.encode(c) }));
|
|
assert_eq!(r["t"], "msg", "{r}");
|
|
let pt = s.open(r["n"].as_u64().unwrap(), &B64.decode(r["c"].as_str().unwrap()).unwrap()).unwrap();
|
|
let v: Value = serde_json::from_slice(&pt).unwrap();
|
|
assert_eq!(v["rid"], 7);
|
|
v["body"].clone()
|
|
}
|
|
|
|
#[test]
|
|
fn pair_query_fill_and_origin_checks() {
|
|
let (b, id) = broker_with_item();
|
|
let mut conn = Conn { caller: String::new(), session: None };
|
|
let mut s = pair_and_open(&b, &mut conn);
|
|
|
|
let r = call(&b, &mut conn, &mut s, json!({ "type": "query", "origin": "https://example.com" }));
|
|
assert_eq!(r["items"][0]["username"], "alice");
|
|
assert!(r["items"][0].get("password").is_none());
|
|
let r = call(&b, &mut conn, &mut s, json!({ "type": "fill", "origin": "https://example.com", "itemId": id }));
|
|
assert_eq!(r, json!({ "type": "credential", "username": "alice", "password": "s3cret" }));
|
|
let r = call(&b, &mut conn, &mut s, json!({ "type": "fill", "origin": "https://evil.test", "itemId": id }));
|
|
assert_eq!(r["code"], "origin_mismatch");
|
|
let r = call(&b, &mut conn, &mut s, json!({ "type": "fill", "origin": "javascript:alert(1)", "itemId": id }));
|
|
assert_eq!(r["code"], "bad_request");
|
|
let r = call(&b, &mut conn, &mut s, json!({ "type": "totp", "origin": "https://example.com", "itemId": id }));
|
|
assert_eq!(r["type"], "totp");
|
|
assert_eq!(r["code"].as_str().unwrap().len(), 6);
|
|
|
|
b.core().lock();
|
|
let r = call(&b, &mut conn, &mut s, json!({ "type": "fill", "origin": "https://example.com", "itemId": id }));
|
|
assert_eq!(r["code"], "locked");
|
|
}
|
|
|
|
#[test]
|
|
fn replayed_frame_kills_the_session() {
|
|
let (b, _) = broker_with_item();
|
|
let mut conn = Conn { caller: String::new(), session: None };
|
|
let mut s = pair_and_open(&b, &mut conn);
|
|
let (n, c) = s.seal(br#"{"rid":1,"body":{"type":"hello","version":"1"}}"#);
|
|
let frame = json!({ "t": "msg", "n": n, "c": B64.encode(c) });
|
|
assert_eq!(b.handle_frame(&mut conn, &frame)["t"], "msg");
|
|
assert_eq!(b.handle_frame(&mut conn, &frame)["code"], "bad_session");
|
|
assert_eq!(b.handle_frame(&mut conn, &frame)["code"], "bad_session");
|
|
}
|
|
|
|
#[test]
|
|
fn unpaired_and_revoked_extensions_are_refused() {
|
|
let (b, _) = broker_with_item();
|
|
let mut conn = Conn { caller: String::new(), session: None };
|
|
let r =
|
|
b.handle_frame(&mut conn, &json!({ "t": "open", "pairing_id": "nope", "nonce": B64.encode([0u8; 16]) }));
|
|
assert_eq!(r["code"], "not_paired");
|
|
let r = b.handle_frame(&mut conn, &json!({ "t": "msg", "n": 0, "c": "AAAA" }));
|
|
assert_eq!(r["code"], "bad_session");
|
|
|
|
let _ = pair_and_open(&b, &mut conn);
|
|
let id = b.pairings()[0].id.clone();
|
|
b.revoke_pairing(&id).unwrap();
|
|
let r = b.handle_frame(&mut conn, &json!({ "t": "open", "pairing_id": id, "nonce": B64.encode([0u8; 16]) }));
|
|
assert_eq!(r["code"], "not_paired");
|
|
}
|
|
|
|
#[test]
|
|
fn pairing_needs_unlock_and_rejects_bad_proof() {
|
|
let (b, _) = broker_with_item();
|
|
let mut conn = Conn { caller: String::new(), session: None };
|
|
let ext = Ephemeral::generate();
|
|
let r = b.handle_frame(&mut conn, &json!({ "t": "pair_init", "pub": B64.encode(ext.public) }));
|
|
let id = r["pairing_id"].as_str().unwrap().to_string();
|
|
let r = b
|
|
.handle_frame(&mut conn, &json!({ "t": "pair_confirm", "pairing_id": id, "proof": B64.encode([0u8; 40]) }));
|
|
assert_eq!(r["code"], "not_paired");
|
|
b.core().lock();
|
|
assert!(b.resolve_pairing(&id, true).is_err());
|
|
assert!(b.pending_pairing().is_some(), "still pending after a locked attempt");
|
|
assert!(b.pairings().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn save_prompts_then_creates_or_updates() {
|
|
let (b, id) = broker_with_item();
|
|
let r = b.handle_request(
|
|
&json!({ "type": "save", "origin": "https://example.com", "username": "alice", "password": "s3cret" }),
|
|
);
|
|
assert_eq!(r["type"], "saved"); // identical: nothing to ask
|
|
let r = b.handle_request(
|
|
&json!({ "type": "save", "origin": "https://example.com/login", "username": "alice", "password": "new" }),
|
|
);
|
|
assert_eq!(r["type"], "save_prompted");
|
|
let views = b.pending_saves();
|
|
assert_eq!((views.len(), views[0].update, views[0].origin.as_str()), (1, true, "https://example.com"));
|
|
assert_eq!(b.resolve_save(&views[0].id, true).unwrap().as_deref(), Some(id.as_str()));
|
|
assert_eq!(b.core().get_item(id).unwrap().password.as_deref(), Some("new"));
|
|
|
|
b.handle_request(
|
|
&json!({ "type": "save", "origin": "https://new.example.org", "username": "bob", "password": "pw" }),
|
|
);
|
|
let v = b.pending_saves().pop().unwrap();
|
|
assert!(!v.update);
|
|
let new_id = b.resolve_save(&v.id, true).unwrap().unwrap();
|
|
let d = b.core().get_item(new_id).unwrap();
|
|
assert_eq!((d.title.as_str(), d.urls[0].as_str()), ("new.example.org", "https://new.example.org"));
|
|
assert!(b.pending_saves().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn normalizes_origins() {
|
|
assert_eq!(normalize_origin("https://Example.com:443/x?y").as_deref(), Some("https://example.com"));
|
|
assert_eq!(normalize_origin("http://localhost:8080").as_deref(), Some("http://localhost:8080"));
|
|
assert_eq!(normalize_origin("https://u:[email protected]"), None);
|
|
assert_eq!(normalize_origin("file:///etc/passwd"), None);
|
|
assert_eq!(normalize_origin("chrome-extension://abc/"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn pairing_store_persists_with_private_mode() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("pairings.json");
|
|
let s = PairingStore::open(path.clone()).unwrap();
|
|
s.add(StoredPairing {
|
|
id: "a".into(),
|
|
name: "n".into(),
|
|
caller: String::new(),
|
|
words: vec![],
|
|
created_at: 1,
|
|
key: B64.encode([1u8; 32]),
|
|
})
|
|
.unwrap();
|
|
assert_eq!(PairingStore::open(path.clone()).unwrap().list().len(), 1);
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
|
|
}
|
|
assert!(s.remove("a").unwrap());
|
|
assert!(PairingStore::open(path).unwrap().list().is_empty());
|
|
}
|
|
}
|