//! 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 { 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 { 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 { let mut m = Hmac::::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 { 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 { 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() }