Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/epix-ui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
260 changes: 248 additions & 12 deletions crates/epix-ui/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,14 @@ fn default_commands() -> Vec<Arc<dyn WsCommand>> {
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!({}))),
Expand Down Expand Up @@ -1215,18 +1223,189 @@ impl WsCommand for AesDecrypt {
"aesDecrypt"
}
async fn handle(&self, _s: &WsSession, p: &Value) -> Result<Value, String> {
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<Vec<u8>> = 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<u8>]) -> 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<Value, String> {
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<u64> {
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<Value, String> {
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<Value, String> {
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<Value, String> {
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<Value, String> {
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<Value, String> {
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<Value, String> {
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() })),
}
}
}

Expand Down Expand Up @@ -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");
Expand Down
20 changes: 20 additions & 0 deletions crates/epix-ui/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> {
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<String, String> {
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<Vec<u8>, String> {
let pk = self.user.read().await.encrypt_privatekey(address, index)?;
Expand Down