storebound/apps/desktop/src-tauri/src/confirm.rs
CRT 61a1e832a0 Desktop app shell (apps/desktop): Tauri 2 + React 19 around the storebound sidecar
- Rust host: spawns storebound serve --stdio with fixed args, hands over a per-session secret,
  mints confirm tokens only after a native dialog showing the summary and the dry run
- every app command re-checks window label and page origin; one capability (main window, app
  commands + event listen only, no plugin API in the webview); strict CSP; tests/security.rs pins it
- store keys picked in native dialogs, kept in the OS keychain, passed per call in memory only
- plugins: shell (Rust only), single-instance, deep-link storebound://, window-state, opener,
  dialog, notification, updater (placeholder endpoint and key until signing), process
- app-centric UI: Portfolio, App (Releases, Catalog, Listings, Health, Reviews, TestFlight),
  App folders, Accounts, Dependencies, Settings; light / dark with the storebound.dev tokens,
  reduced motion, home folder shown as ~
- macOS menu: Cmd+Q quits, Edit menu, Window menu keeps the main window; Linux DMABUF fix
- Bun workspace (npm files unchanged), ci runs desktop typecheck, build, cargo test and clippy;
  scrub covers home paths; docs/desktop.md (protocol, safety, dependency sources and licenses)
2026-10-11 01:57:33 +08:00

100 lines
4 KiB
Rust

//! Confirm tokens, host side. The same scheme as cli/lib/confirm-token.ts (the sidecar verifies):
//!
//! token = "sbt1." + expiry_ms + "." + nonce + "." + base64url(HMAC-SHA256(secret, payload))
//! payload = "storebound-confirm-v1\n" + method + "\n" + canonical(params) + "\n" + expiry_ms + "\n" + nonce
//!
//! The secret is 32 random bytes made per sidecar start and handed over in `session.hello` on the sidecar's stdin; it
//! never leaves this process otherwise. Tokens are minted only by `commands::write_with_confirm`, right after the owner
//! pressed the confirm button of the native dialog that showed the dry run.
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use hmac::{Hmac, Mac};
use serde_json::Value;
use sha2::Sha256;
pub const PREFIX: &str = "sbt1";
pub const DOMAIN: &str = "storebound-confirm-v1";
/// Lifetime of a minted token: the real call follows the dialog right away.
pub const TTL_MS: u64 = 60_000;
const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
/// Canonical JSON, byte for byte what the TypeScript side builds: keys sorted by UTF-16 code units, no whitespace,
/// JSON string escaping, numbers only as safe integers, null members kept (JSON has no undefined).
pub fn canonical(v: &Value) -> Result<String, String> {
let mut out = String::new();
write_canonical(v, &mut out)?;
Ok(out)
}
fn write_canonical(v: &Value, out: &mut String) -> Result<(), String> {
match v {
Value::Null => out.push_str("null"),
Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
Value::Number(n) => {
let i = n
.as_i64()
.filter(|i| (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(i))
.ok_or("canonical: only safe integers are allowed as numbers")?;
out.push_str(&i.to_string());
}
Value::String(s) => out.push_str(&serde_json::to_string(s).map_err(|e| e.to_string())?),
Value::Array(a) => {
out.push('[');
for (i, x) in a.iter().enumerate() {
if i > 0 {
out.push(',');
}
write_canonical(x, out)?;
}
out.push(']');
}
Value::Object(o) => {
let mut keys: Vec<&String> = o.keys().collect();
keys.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
out.push('{');
for (i, k) in keys.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&serde_json::to_string(k).map_err(|e| e.to_string())?);
out.push(':');
write_canonical(&o[k.as_str()], out)?;
}
out.push('}');
}
}
Ok(())
}
pub fn payload(method: &str, params: &Value, expiry: u64, nonce: &str) -> Result<String, String> {
Ok(format!("{DOMAIN}\n{method}\n{}\n{expiry}\n{nonce}", canonical(params)?))
}
pub fn mac(secret: &[u8], method: &str, params: &Value, expiry: u64, nonce: &str) -> Result<String, String> {
let mut m = Hmac::<Sha256>::new_from_slice(secret).map_err(|e| e.to_string())?;
m.update(payload(method, params, expiry, nonce)?.as_bytes());
Ok(URL_SAFE_NO_PAD.encode(m.finalize().into_bytes()))
}
/// A token for exactly (method, params), valid for TTL_MS from `now_ms`.
pub fn mint(secret: &[u8], method: &str, params: &Value, now_ms: u64, nonce: &str) -> Result<String, String> {
let expiry = now_ms + TTL_MS;
Ok(format!("{PREFIX}.{expiry}.{nonce}.{}", mac(secret, method, params, expiry, nonce)?))
}
/// 16 random bytes, base64url (22 characters).
pub fn new_nonce() -> Result<String, String> {
let mut b = [0u8; 16];
getrandom::fill(&mut b).map_err(|e| e.to_string())?;
Ok(URL_SAFE_NO_PAD.encode(b))
}
/// The per-session secret.
pub fn new_secret() -> Result<[u8; 32], String> {
let mut b = [0u8; 32];
getrandom::fill(&mut b).map_err(|e| e.to_string())?;
Ok(b)
}
pub fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}