localharness 0.15.0

A Rust-native agent SDK for Gemini. Streaming, custom tools, safety policies, background triggers — zero external binaries.
Documentation
//! At-rest encryption for sensitive OPFS files (the Gemini API key and
//! conversation history).
//!
//! ## Threat model — read this before assuming too much
//! The encryption key is a per-origin random AES-256-GCM key kept in
//! **localStorage**, a separate store from OPFS. So a copy of the OPFS
//! files *alone* (a future export feature, some extension scopes, casual
//! inspection) yields only ciphertext. It does **NOT** defend against
//! XSS / untrusted JS in the origin — the page can read the localStorage
//! key, so any code running here can decrypt. (The active XSS path was
//! closed separately in the security audit.) It's defense-in-depth that
//! removes plaintext secrets from OPFS at rest, nothing more.
//!
//! The **wallet seed is deliberately NOT encrypted here**: losing the
//! localStorage key would then risk locking the user out of their
//! identity. Seed protection needs its own recovery design.
//!
//! Format: `[12-byte IV][ciphertext + 16-byte GCM tag]`.

use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture;

const IV_LEN: usize = 12;
/// localStorage slot holding the hex-encoded per-origin AES key.
const STORAGE_KEY: &str = "lh_enc_key_v1";

/// Encrypt bytes for at-rest storage with the per-origin device key.
/// Returns `None` on any failure so the caller can fall back to writing
/// plaintext rather than losing data.
pub(crate) async fn seal(plaintext: &[u8]) -> Option<Vec<u8>> {
    let key = device_key().await.ok()?;
    encrypt(&key, plaintext).await.ok()
}

/// Decrypt at-rest bytes. Returns `None` if they aren't our ciphertext
/// (legacy plaintext, or a wrong/lost key) — the caller treats `None`
/// as "use the raw bytes / re-prompt", which also auto-migrates old
/// plaintext files (they re-encrypt on the next write).
pub(crate) async fn open(data: &[u8]) -> Option<Vec<u8>> {
    let key = device_key().await.ok()?;
    decrypt(&key, data).await.ok()
}

/// Seal with an explicit 32-byte key (e.g. a wallet-seed-derived key)
/// rather than the per-origin device key. Used by the on-chain API-key
/// sync flow so the ciphertext follows the seed, not the device.
pub(crate) async fn seal_with_raw_key(raw: &[u8; 32], plaintext: &[u8]) -> Option<Vec<u8>> {
    let key = import_aes_key(raw).await.ok()?;
    encrypt(&key, plaintext).await.ok()
}

/// Open bytes sealed by [`seal_with_raw_key`] with the same 32-byte key.
pub(crate) async fn open_with_raw_key(raw: &[u8; 32], data: &[u8]) -> Option<Vec<u8>> {
    let key = import_aes_key(raw).await.ok()?;
    decrypt(&key, data).await.ok()
}

/// Load (or generate + persist) the per-origin AES key and import it as
/// a non-extractable WebCrypto `CryptoKey`.
async fn device_key() -> Result<web_sys::CryptoKey, String> {
    let raw = load_or_create_key_bytes()?;
    import_aes_key(&raw).await
}

fn load_or_create_key_bytes() -> Result<[u8; 32], String> {
    let window = web_sys::window().ok_or("no window")?;
    let storage = window
        .local_storage()
        .map_err(|_| "no localStorage")?
        .ok_or("no localStorage")?;

    if let Ok(Some(hex)) = storage.get_item(STORAGE_KEY) {
        if let Some(bytes) = hex_to_32(&hex) {
            return Ok(bytes);
        }
    }

    // First use on this origin — generate a fresh key and persist it.
    let crypto = window.crypto().map_err(|_| "no crypto")?;
    let bytes = [0u8; 32];
    let view = unsafe { js_sys::Uint8Array::view(&bytes) };
    crypto
        .get_random_values_with_array_buffer_view(&view)
        .map_err(|_| "getRandomValues failed")?;
    drop(view);
    let _ = storage.set_item(STORAGE_KEY, &hex32(&bytes));
    Ok(bytes)
}

async fn import_aes_key(raw: &[u8]) -> Result<web_sys::CryptoKey, String> {
    let window = web_sys::window().ok_or("no window")?;
    let crypto = window.crypto().map_err(|_| "no crypto")?;
    let subtle = crypto.subtle();

    let key_data = js_sys::Uint8Array::from(raw);
    let algo = js_sys::Object::new();
    let _ = js_sys::Reflect::set(&algo, &JsValue::from_str("name"), &JsValue::from_str("AES-GCM"));

    let usages = js_sys::Array::new();
    usages.push(&JsValue::from_str("encrypt"));
    usages.push(&JsValue::from_str("decrypt"));

    let promise = subtle
        .import_key_with_object("raw", &key_data.buffer(), &algo, false, &usages)
        .map_err(|e| format!("importKey: {e:?}"))?;
    let result = JsFuture::from(promise)
        .await
        .map_err(|e| format!("importKey await: {e:?}"))?;
    result
        .dyn_into::<web_sys::CryptoKey>()
        .map_err(|_| "importKey did not return CryptoKey".into())
}

/// Encrypt plaintext bytes. Returns `IV || ciphertext || tag`.
async fn encrypt(key: &web_sys::CryptoKey, plaintext: &[u8]) -> Result<Vec<u8>, String> {
    let window = web_sys::window().ok_or("no window")?;
    let crypto = window.crypto().map_err(|_| "no crypto")?;
    let subtle = crypto.subtle();

    let iv_bytes = [0u8; IV_LEN];
    let iv_view = unsafe { js_sys::Uint8Array::view(&iv_bytes) };
    crypto
        .get_random_values_with_array_buffer_view(&iv_view)
        .map_err(|_| "getRandomValues failed")?;

    let algo = js_sys::Object::new();
    let _ = js_sys::Reflect::set(&algo, &JsValue::from_str("name"), &JsValue::from_str("AES-GCM"));
    let _ = js_sys::Reflect::set(&algo, &JsValue::from_str("iv"), &iv_view);

    let data = plaintext.to_vec();
    let promise = subtle
        .encrypt_with_object_and_u8_array(&algo, key, &data)
        .map_err(|e| format!("encrypt: {e:?}"))?;
    let result = JsFuture::from(promise)
        .await
        .map_err(|e| format!("encrypt await: {e:?}"))?;

    let ciphertext = js_sys::Uint8Array::new(&result);
    let ct_bytes = ciphertext.to_vec();

    let mut out = Vec::with_capacity(IV_LEN + ct_bytes.len());
    out.extend_from_slice(&iv_bytes);
    out.extend_from_slice(&ct_bytes);
    Ok(out)
}

/// Decrypt `IV || ciphertext || tag` back to plaintext.
async fn decrypt(key: &web_sys::CryptoKey, encrypted: &[u8]) -> Result<Vec<u8>, String> {
    if encrypted.len() < IV_LEN + 16 {
        return Err("ciphertext too short".into());
    }

    let window = web_sys::window().ok_or("no window")?;
    let crypto = window.crypto().map_err(|_| "no crypto")?;
    let subtle = crypto.subtle();

    let iv = js_sys::Uint8Array::from(&encrypted[..IV_LEN]);

    let algo = js_sys::Object::new();
    let _ = js_sys::Reflect::set(&algo, &JsValue::from_str("name"), &JsValue::from_str("AES-GCM"));
    let _ = js_sys::Reflect::set(&algo, &JsValue::from_str("iv"), &iv);

    let ct = encrypted[IV_LEN..].to_vec();
    let promise = subtle
        .decrypt_with_object_and_u8_array(&algo, key, &ct)
        .map_err(|e| format!("decrypt: {e:?}"))?;
    let result = JsFuture::from(promise)
        .await
        .map_err(|e| format!("decrypt await: {e:?}"))?;

    let plaintext = js_sys::Uint8Array::new(&result);
    Ok(plaintext.to_vec())
}

fn hex32(b: &[u8; 32]) -> String {
    let mut s = String::with_capacity(64);
    for x in b {
        s.push_str(&format!("{x:02x}"));
    }
    s
}

fn hex_to_32(s: &str) -> Option<[u8; 32]> {
    if s.len() != 64 {
        return None;
    }
    let mut out = [0u8; 32];
    for (i, b) in out.iter_mut().enumerate() {
        *b = u8::from_str_radix(s.get(i * 2..i * 2 + 2)?, 16).ok()?;
    }
    Some(out)
}