//! The browser's device key: a non-extractable WebCrypto ECDSA P-256 key. //! //! WebCrypto's ECDSA signature is already IEEE P1363 `r || s` (64 bytes), the format the RFC 9421 //! profile uses, so no DER conversion is needed. The `CryptoKey` is a JS object, so it stays in a //! thread-local registry keyed by device id (the wasm module runs on one thread; several vaults may //! share it) and the signer itself is plain data, which keeps it `Send + Sync` as //! `kv_sync::DeviceSigner` requires. use std::{cell::RefCell, collections::HashMap}; use js_sys::{Array, Object, Reflect, Uint8Array}; use kv_sync::{DeviceSigner, SignFuture, SyncError}; use uuid::Uuid; use wasm_bindgen::{prelude::*, JsCast}; use wasm_bindgen_futures::JsFuture; use web_sys::{Crypto, CryptoKey, EcKeyGenParams, EcdsaParams, SubtleCrypto}; thread_local! { static PRIVATE_KEYS: RefCell> = RefCell::new(HashMap::new()); } fn subtle() -> Result { let crypto: Crypto = Reflect::get(&js_sys::global(), &JsValue::from_str("crypto"))?.dyn_into()?; Ok(crypto.subtle()) } pub struct WebCryptoSigner { id: Uuid, public_key: Vec, } impl WebCryptoSigner { /// Generates a fresh non-extractable key pair. Returns the signer and the private `CryptoKey` /// (to store in IndexedDB; structured clone keeps it non-extractable). pub async fn generate() -> Result<(Self, CryptoKey), JsValue> { let subtle = subtle()?; let params = EcKeyGenParams::new("ECDSA", "P-256"); let usages = Array::of1(&JsValue::from_str("sign")); let pair = JsFuture::from(subtle.generate_key_with_object(¶ms, false, &usages)?).await?; let private: CryptoKey = Reflect::get(&pair, &JsValue::from_str("privateKey"))?.dyn_into()?; let public: CryptoKey = Reflect::get(&pair, &JsValue::from_str("publicKey"))?.dyn_into()?; // Public keys are always exportable; "raw" is the 65-byte SEC1 uncompressed point. let raw = JsFuture::from(subtle.export_key("raw", &public)?).await?; let public_key = Uint8Array::new(&raw).to_vec(); let signer = Self { id: Uuid::new_v4(), public_key }; signer.activate(private.clone()); Ok((signer, private)) } /// Restores a stored key. pub fn restore(id: Uuid, public_key: Vec, private: CryptoKey) -> Result { if public_key.len() != 65 || public_key[0] != 4 { return Err(JsValue::from_str("stored device public key is not an uncompressed P-256 point")); } let signer = Self { id, public_key }; signer.activate(private); Ok(signer) } fn activate(&self, private: CryptoKey) { PRIVATE_KEYS.with(|k| k.borrow_mut().insert(self.id, private)); } /// Drops this device's key from the registry (wipe, or the vault was freed). pub fn forget(&self) { PRIVATE_KEYS.with(|k| k.borrow_mut().remove(&self.id)); } /// The IndexedDB record for this key. pub fn record(&self, private: &CryptoKey, name: &str, platform: &str) -> Result { let o = Object::new(); Reflect::set(&o, &"deviceId".into(), &self.id.to_string().into())?; Reflect::set(&o, &"name".into(), &name.into())?; Reflect::set(&o, &"platform".into(), &platform.into())?; Reflect::set(&o, &"privateKey".into(), private)?; Reflect::set(&o, &"publicKey".into(), &Uint8Array::from(self.public_key.as_slice()))?; Ok(o.into()) } async fn sign_js(&self, message: &[u8]) -> Result, JsValue> { let key = PRIVATE_KEYS .with(|k| k.borrow().get(&self.id).cloned()) .ok_or_else(|| JsValue::from_str("device key is not loaded"))?; let params = EcdsaParams::new("ECDSA", &JsValue::from_str("SHA-256")); let sig = JsFuture::from(subtle()?.sign_with_object_and_u8_array(¶ms, &key, message)?).await?; Ok(Uint8Array::new(&sig).to_vec()) } } fn js_err(e: JsValue) -> SyncError { SyncError::Signer(e.as_string().unwrap_or_else(|| format!("{e:?}"))) } impl DeviceSigner for WebCryptoSigner { fn device_id(&self) -> Uuid { self.id } fn public_key(&self) -> Vec { self.public_key.clone() } fn sign(&self, _message: &[u8]) -> Result, SyncError> { Err(SyncError::Signer("WebCrypto signs asynchronously; use sign_async".into())) } fn sign_async<'a>(&'a self, message: &'a [u8]) -> SignFuture<'a> { Box::pin(async move { self.sign_js(message).await.map_err(js_err) }) } }