diff --git a/Cargo.lock b/Cargo.lock index 91c0adc..da3ed01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -771,6 +771,7 @@ dependencies = [ "async-trait", "axum 0.7.9", "base64", + "epix-chain", "epix-core", "epix-crypt", "epix-db", diff --git a/crates/epix-ui/Cargo.toml b/crates/epix-ui/Cargo.toml index 80dc5f4..ba62cc1 100644 --- a/crates/epix-ui/Cargo.toml +++ b/crates/epix-ui/Cargo.toml @@ -15,6 +15,7 @@ epix-transport = { path = "../epix-transport" } epix-propagation = { path = "../epix-propagation" } epix-worker = { path = "../epix-worker" } epix-crypt = { path = "../epix-crypt" } +epix-chain = { path = "../epix-chain" } base64 = "0.22" sha2 = "0.10" hex = "0.4" diff --git a/crates/epix-ui/src/command.rs b/crates/epix-ui/src/command.rs index 1f31753..415a2d2 100644 --- a/crates/epix-ui/src/command.rs +++ b/crates/epix-ui/src/command.rs @@ -320,6 +320,14 @@ fn default_commands() -> Vec> { Arc::new(AesEncrypt), Arc::new(AesDecrypt), Arc::new(EcdsaVerify), + Arc::new(EcdsaSign), + // Chain: Vrf randomness + XidResolver. + Arc::new(VrfGetBeacon), + Arc::new(VrfLatestBeacon), + Arc::new(VrfMultiBlockBeacon), + Arc::new(VrfDeriveRandom), + Arc::new(VrfInvalidateCache), + Arc::new(XidResolveName), Arc::new(EccPrivToPub), Arc::new(EccPubToAddr), Arc::new(simple("userGetSettings", json!({}))), @@ -1215,18 +1223,189 @@ impl WsCommand for AesDecrypt { "aesDecrypt" } async fn handle(&self, _s: &WsSession, p: &Value) -> Result { - let a = p.as_array().ok_or("aesDecrypt: expected [iv, ciphertext, key]")?; - let get = |i: usize| a.get(i).and_then(|v| v.as_str()).and_then(b64_decode); - let (iv, ct, key) = ( - get(0).ok_or("aesDecrypt: iv")?, - get(1).ok_or("aesDecrypt: ciphertext")?, - get(2).ok_or("aesDecrypt: key")?, - ); - Ok(epix_crypt::ecies::aes_decrypt(&ct, &key, &iv) - .ok() - .and_then(|pt| String::from_utf8(pt).ok()) - .map(Value::from) - .unwrap_or(Value::Null)) + let a = p.as_array().ok_or("aesDecrypt: expected params array")?; + // Single form: [iv, ciphertext, key] (all strings). + if a.len() == 3 && a.iter().all(|v| v.is_string()) { + let get = |i: usize| a.get(i).and_then(|v| v.as_str()).and_then(b64_decode); + let (iv, ct, key) = ( + get(0).ok_or("aesDecrypt: iv")?, + get(1).ok_or("aesDecrypt: ciphertext")?, + get(2).ok_or("aesDecrypt: key")?, + ); + return Ok(aes_try_keys(&iv, &ct, std::slice::from_ref(&key))); + } + // Batch form: [ [[iv, ciphertext], …], [key, …] ]. For each ciphertext, + // try every key and return the text of the first that decrypts (or null). + let items = a.first().and_then(|v| v.as_array()).ok_or("aesDecrypt: encrypted_texts")?; + let keys: Vec> = a + .get(1) + .and_then(|v| v.as_array()) + .ok_or("aesDecrypt: keys")? + .iter() + .filter_map(|k| k.as_str().and_then(b64_decode)) + .collect(); + let mut out = Vec::with_capacity(items.len()); + for pair in items { + let iv = pair.get(0).and_then(|v| v.as_str()).and_then(b64_decode); + let ct = pair.get(1).and_then(|v| v.as_str()).and_then(b64_decode); + match (iv, ct) { + (Some(iv), Some(ct)) => out.push(aes_try_keys(&iv, &ct, &keys)), + _ => out.push(Value::Null), + } + } + Ok(Value::Array(out)) + } +} + +/// Try each key against `(iv, ciphertext)`, returning the first valid-UTF-8 +/// plaintext as a JSON string, else `Null`. +fn aes_try_keys(iv: &[u8], ct: &[u8], keys: &[Vec]) -> Value { + for key in keys { + if let Ok(pt) = epix_crypt::ecies::aes_decrypt(ct, key, iv) { + if let Ok(text) = String::from_utf8(pt) { + return Value::from(text); + } + } + } + Value::Null +} + +/// `ecdsaSign(data, privatekey?)` - sign `data`. With no key, the user's auth +/// private key for the bound site is used. +struct EcdsaSign; +#[async_trait] +impl WsCommand for EcdsaSign { + fn name(&self) -> &'static str { + "ecdsaSign" + } + async fn handle(&self, s: &WsSession, p: &Value) -> Result { + let data = arg_str(p, "data", 0).ok_or("ecdsaSign: data required")?; + let privatekey = match arg_str(p, "privatekey", 1) { + Some(pk) => pk.to_string(), + None => { + let address = s.address()?.to_string(); + s.state.user_auth_privatekey(&address).await? + } + }; + Ok(Value::from(epix_crypt::sign(data, &privatekey)?)) + } +} + +// ---- Chain: Vrf randomness beacon + XidResolver ---------------------------- + +/// Read a positional-or-named integer arg. +fn arg_u64(p: &Value, key: &str, idx: usize) -> Option { + p.get(key) + .or_else(|| p.as_array().and_then(|a| a.get(idx))) + .and_then(|v| v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))) +} + +/// Serialize a Vrf beacon for the wire. +fn beacon_json(b: &epix_chain::Beacon) -> Value { + json!({ + "height": b.height, + "beacon": b.beacon, + "proposer": b.proposer, + "timestamp": b.timestamp, + }) +} + +/// `vrfGetBeacon(height)` - the randomness beacon at a block height. +struct VrfGetBeacon; +#[async_trait] +impl WsCommand for VrfGetBeacon { + fn name(&self) -> &'static str { + "vrfGetBeacon" + } + async fn handle(&self, s: &WsSession, p: &Value) -> Result { + let height = arg_u64(p, "height", 0).ok_or("vrfGetBeacon: height required")?; + let vrf = epix_chain::Vrf::new(s.state.chain_rpc_url().await); + match vrf.beacon(height).await { + Ok(b) => Ok(beacon_json(&b)), + Err(e) => Ok(json!({ "error": e.to_string() })), + } + } +} + +/// `vrfLatestBeacon()` - the most recent finalized beacon. +struct VrfLatestBeacon; +#[async_trait] +impl WsCommand for VrfLatestBeacon { + fn name(&self) -> &'static str { + "vrfLatestBeacon" + } + async fn handle(&self, s: &WsSession, _p: &Value) -> Result { + let vrf = epix_chain::Vrf::new(s.state.chain_rpc_url().await); + match vrf.latest_beacon().await { + Ok(b) => Ok(beacon_json(&b)), + Err(e) => Ok(json!({ "error": e.to_string() })), + } + } +} + +/// `vrfMultiBlockBeacon(end_height, blocks)` - a beacon combined over a window. +struct VrfMultiBlockBeacon; +#[async_trait] +impl WsCommand for VrfMultiBlockBeacon { + fn name(&self) -> &'static str { + "vrfMultiBlockBeacon" + } + async fn handle(&self, s: &WsSession, p: &Value) -> Result { + let end = arg_u64(p, "end_height", 0).ok_or("vrfMultiBlockBeacon: end_height required")?; + let blocks = arg_u64(p, "blocks", 1).unwrap_or(1); + let vrf = epix_chain::Vrf::new(s.state.chain_rpc_url().await); + match vrf.multi_block_beacon(end, blocks).await { + Ok(combined) => Ok(json!({ "beacon": combined })), + Err(e) => Ok(json!({ "error": e.to_string() })), + } + } +} + +/// `vrfDeriveRandom(beacon, seed, count)` - deterministic values from a beacon. +/// Pure (no RPC): `sha256(beacon ‖ seed ‖ i)` per the reference derivation. +struct VrfDeriveRandom; +#[async_trait] +impl WsCommand for VrfDeriveRandom { + fn name(&self) -> &'static str { + "vrfDeriveRandom" + } + async fn handle(&self, _s: &WsSession, p: &Value) -> Result { + let beacon = arg_str(p, "beacon", 0).ok_or("vrfDeriveRandom: beacon required")?; + let seed = arg_str(p, "seed", 1).unwrap_or(""); + let count = arg_u64(p, "count", 2).unwrap_or(1) as usize; + Ok(json!(epix_chain::derive_random(beacon, seed, count))) + } +} + +/// `vrfInvalidateCache()` - the node builds a fresh Vrf client per request, so +/// there is no persistent cache to clear; a successful no-op. +struct VrfInvalidateCache; +#[async_trait] +impl WsCommand for VrfInvalidateCache { + fn name(&self) -> &'static str { + "vrfInvalidateCache" + } + async fn handle(&self, _s: &WsSession, _p: &Value) -> Result { + Ok(Value::from("ok")) + } +} + +/// `xidResolveName(name, tld)` - resolve a chain name to its attested snapshot +/// (owner, identities, DNS records) via the Merkle-proof-verified resolver. +struct XidResolveName; +#[async_trait] +impl WsCommand for XidResolveName { + fn name(&self) -> &'static str { + "xidResolveName" + } + async fn handle(&self, s: &WsSession, p: &Value) -> Result { + let name = arg_str(p, "name", 0).ok_or("xidResolveName: name required")?; + let tld = arg_str(p, "tld", 1).unwrap_or("epix"); + let resolver = epix_chain::XidResolver::new(s.state.chain_rpc_url().await); + match resolver.resolve(name, tld).await { + Ok(snap) => Ok(serde_json::to_value(snap).unwrap_or(Value::Null)), + Err(e) => Ok(json!({ "error": e.to_string() })), + } } } @@ -1816,6 +1995,63 @@ mod tests { use crate::state::XiteEntry; use epix_xite::XiteStorage; + #[tokio::test] + async fn aes_decrypt_handles_single_and_batch_forms() { + let state = AppState::new("test"); + let session = WsSession::new(state, Some("1site".into())); + let iv = [7u8; 16]; + let key1 = [1u8; 32]; + let key2 = [2u8; 32]; + let ct1 = epix_crypt::ecies::aes_encrypt(b"hello", &key1, &iv).unwrap(); + let ct2 = epix_crypt::ecies::aes_encrypt(b"world", &key2, &iv).unwrap(); + + // Single form: [iv, ct, key]. + let single = AesDecrypt + .handle(&session, &json!([b64_encode(&iv), b64_encode(&ct1), b64_encode(&key1)])) + .await + .unwrap(); + assert_eq!(single, json!("hello")); + + // Batch form: two ciphertexts, two candidate keys; each finds its key. + let batch = AesDecrypt + .handle( + &session, + &json!([ + [[b64_encode(&iv), b64_encode(&ct1)], [b64_encode(&iv), b64_encode(&ct2)]], + [b64_encode(&key1), b64_encode(&key2)] + ]), + ) + .await + .unwrap(); + assert_eq!(batch, json!(["hello", "world"])); + } + + #[tokio::test] + async fn vrf_derive_random_wires_through_the_command() { + let state = AppState::new("test"); + let session = WsSession::new(state, Some("1site".into())); + let out = VrfDeriveRandom + .handle(&session, &json!(["deadbeef", "myseed", 3])) + .await + .unwrap(); + // Returns `count` deterministic values, matching the pure derivation. + let arr = out.as_array().unwrap(); + assert_eq!(arr.len(), 3); + assert_eq!(out, json!(epix_chain::derive_random("deadbeef", "myseed", 3))); + } + + #[tokio::test] + async fn ecdsa_sign_then_verify_roundtrips() { + let state = AppState::new("test"); + let session = WsSession::new(state, Some("1site".into())); + // Sign with the user's auth key for the site (no explicit privatekey). + let sig = EcdsaSign.handle(&session, &json!(["a message"])).await.unwrap(); + let sig = sig.as_str().unwrap(); + // The signer address is the user's auth address for this site. + let address = session.state.user_auth_address("1site").await.unwrap(); + assert!(epix_crypt::verify("a message", &address, sig)); + } + #[tokio::test] async fn config_set_saves_and_notifies_on_language() { let state = AppState::new("test"); diff --git a/crates/epix-ui/src/state.rs b/crates/epix-ui/src/state.rs index 6eb5dd0..afc5a01 100644 --- a/crates/epix-ui/src/state.rs +++ b/crates/epix-ui/src/state.rs @@ -791,6 +791,26 @@ impl AppState { self.user.read().await.encrypt_privatekey(address, index) } + /// The user's auth (identity) private key (WIF) for a xite - used by + /// `ecdsaSign` when no explicit key is given. + pub async fn user_auth_privatekey(&self, address: &str) -> Result { + self.user.write().await.site_data(address).map(|d| d.auth_privatekey.clone()) + } + + /// The user's auth (identity) address for a xite. + pub async fn user_auth_address(&self, address: &str) -> Result { + self.user.write().await.auth_address(address) + } + + /// The configured Epix chain RPC URL (Vrf / XidResolver), or the default. + pub async fn chain_rpc_url(&self) -> String { + self.config_get("chain_rpc_url") + .await + .and_then(|v| v.as_str().map(str::to_string)) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| epix_chain::DEFAULT_RPC_URL.to_string()) + } + /// The user's CryptMessage encryption public key (compressed SEC1) for a xite. pub async fn user_encrypt_publickey(&self, address: &str, index: u64) -> Result, String> { let pk = self.user.read().await.encrypt_privatekey(address, index)?;