- 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)
100 lines
4 KiB
Rust
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()
|
|
}
|