From 90b76f038712f569c7a5cad7fecb8e8f2aa03e2c Mon Sep 17 00:00:00 2001 From: Justas Date: Mon, 4 May 2026 16:56:51 +0100 Subject: [PATCH 1/3] Implement callback functionality --- crates/iris-crypto/src/cheetah.rs | 17 ++ crates/iris-crypto/src/lib.rs | 2 +- .../src/tx_engine/builder.rs | 69 +++++-- crates/iris-wasm/src/crypto.rs | 25 +++ crates/iris-wasm/src/tx.rs | 191 ++++++++++++++++-- 5 files changed, 272 insertions(+), 32 deletions(-) diff --git a/crates/iris-crypto/src/cheetah.rs b/crates/iris-crypto/src/cheetah.rs index 08e5c7f..369ab05 100644 --- a/crates/iris-crypto/src/cheetah.rs +++ b/crates/iris-crypto/src/cheetah.rs @@ -277,9 +277,26 @@ impl Hashable for Signature { } } +/// ECDSA signing for transaction witnesses. Implemented by [`PrivateKey`] and +/// wasm-side key handles that delegate to local or external signers. +pub trait SigningKey { + fn signing_public_key(&self) -> PublicKey; + fn sign_digest(&self, digest: &Digest) -> Signature; +} + #[derive(Debug, Clone)] pub struct PrivateKey(pub U256); +impl SigningKey for PrivateKey { + fn signing_public_key(&self) -> PublicKey { + PrivateKey::public_key(self) + } + + fn sign_digest(&self, digest: &Digest) -> Signature { + PrivateKey::sign(self, digest) + } +} + impl Drop for PrivateKey { fn drop(&mut self) { unsafe { diff --git a/crates/iris-crypto/src/lib.rs b/crates/iris-crypto/src/lib.rs index fc4839a..40b8c37 100644 --- a/crates/iris-crypto/src/lib.rs +++ b/crates/iris-crypto/src/lib.rs @@ -8,7 +8,7 @@ use alloc::string::{String, ToString}; pub mod cheetah; pub mod slip10; -pub use cheetah::{PrivateKey, PublicKey, Signature}; +pub use cheetah::{PrivateKey, PublicKey, Signature, SigningKey}; pub use slip10::{derive_master_key, ExtendedKey}; #[cfg(feature = "mnemonic")] diff --git a/crates/iris-nockchain-types/src/tx_engine/builder.rs b/crates/iris-nockchain-types/src/tx_engine/builder.rs index 44874ed..efa8992 100644 --- a/crates/iris-nockchain-types/src/tx_engine/builder.rs +++ b/crates/iris-nockchain-types/src/tx_engine/builder.rs @@ -2,7 +2,7 @@ use alloc::collections::btree_map::BTreeMap; use alloc::collections::btree_set::BTreeSet; use alloc::vec; use alloc::vec::Vec; -use iris_crypto::{PrivateKey, PublicKey}; +use iris_crypto::{PublicKey, Signature, SigningKey}; use iris_ztd::{noun_deserialize, noun_serialize, Digest, Hashable as HashableTrait, Noun, ZMap}; use serde::{Deserialize, Serialize}; @@ -343,19 +343,43 @@ impl SpendBuilder { None } - pub fn sign(&mut self, signing_key: &PrivateKey) -> bool { - match &mut self.spend { + pub fn sig_hash(&self) -> Digest { + self.spend.sig_hash() + } + + pub fn accepts_signing_pubkey(&self, public_key: &PublicKey) -> bool { + match &self.spend { Spend::S1(spend) => { - let pkpkh = signing_key.public_key().hash(); + let pkpkh = public_key.hash(); + spend + .witness + .lock_merkle_proof + .spend_condition() + .pkh() + .any(|p| p.hashes.contains(&pkpkh)) + } + Spend::S0(_) => { + let Some(sig) = &self.note_info.sig else { + panic!("Note is not V0"); + }; + sig.pubkeys.contains(public_key) + } + } + } + pub fn try_apply_signature( + &mut self, + public_key: PublicKey, + signature: Signature, + ) -> bool { + match &mut self.spend { + Spend::S1(spend) => { + let pkpkh = public_key.hash(); for p in spend.witness.lock_merkle_proof.spend_condition().pkh() { if p.hashes.contains(&pkpkh) { spend.witness.pkh_signature.0.insert( - signing_key.public_key().hash(), - ( - signing_key.public_key(), - signing_key.sign(&spend.sig_hash()), - ), + public_key.hash(), + (public_key, signature), ); return true; } @@ -365,19 +389,25 @@ impl SpendBuilder { let Some(sig) = &self.note_info.sig else { panic!("Note is not V0"); }; - if sig.pubkeys.contains(&signing_key.public_key()) { - spend.signature.0.insert( - signing_key.public_key(), - signing_key.sign(&spend.sig_hash()), - ); + if sig.pubkeys.contains(&public_key) { + spend.signature.0.insert(public_key, signature); return true; } } } - false } + pub fn sign(&mut self, signing_key: &impl SigningKey) -> bool { + let pk = signing_key.signing_public_key(); + if !self.accepts_signing_pubkey(&pk) { + return false; + } + let digest = self.sig_hash(); + let sig = signing_key.sign_digest(&digest); + self.try_apply_signature(pk, sig) + } + fn missing_unlocks_fee(&self, settings: &TxEngineSettings) -> Nicks { let mut fee = Nicks(0); @@ -544,13 +574,18 @@ impl TxBuilder { ret } - pub fn sign(&mut self, signing_key: &PrivateKey) -> &mut Self { + pub fn sign(&mut self, signing_key: &impl SigningKey) -> &mut Self { for spend in self.spends.values_mut() { spend.sign(signing_key); } self } + /// Mutable spend map for embedders (e.g. async WASM signers) that apply signatures out of band. + pub fn spends_mut(&mut self) -> &mut BTreeMap { + &mut self.spends + } + pub fn validate(&mut self) -> Result<&mut Self, BuildError> { let cur_fee = self.cur_fee(); let needed_fee = self.calc_fee(); @@ -901,7 +936,7 @@ mod tests { use crate::v1::{self, LockPrimitive, LockTim}; use alloc::{string::ToString, vec}; use bip39::Mnemonic; - use iris_crypto::{derive_master_key, PublicKey}; + use iris_crypto::{derive_master_key, PrivateKey, PublicKey}; use iris_ztd::{jam, NounEncode}; fn keys() -> (PrivateKey, PublicKey) { diff --git a/crates/iris-wasm/src/crypto.rs b/crates/iris-wasm/src/crypto.rs index 458b0d6..cdae7c9 100644 --- a/crates/iris-wasm/src/crypto.rs +++ b/crates/iris-wasm/src/crypto.rs @@ -137,6 +137,31 @@ pub fn hash_noun(noun: &[u8]) -> Result { Ok(digest.to_string()) } +/// Sign a 40-byte digest (same encoding as [`iris_ztd::Digest::to_bytes`]) with a 32-byte +/// private key. Returns 64 bytes: `c` then `s`, each 32-byte little-endian scalars. +#[wasm_bindgen(js_name = signDigestBytes)] +pub fn sign_digest_bytes( + private_key_bytes: &[u8], + digest_bytes: &[u8], +) -> Result { + use iris_ztd::Digest; + if private_key_bytes.len() != 32 { + return Err(JsValue::from_str("Private key must be 32 bytes")); + } + if digest_bytes.len() != 40 { + return Err(JsValue::from_str( + "Digest must be 40 bytes (nockchain digest limb encoding)", + )); + } + let private_key = PrivateKey(U256::from_be_slice(private_key_bytes)); + let digest = Digest::from_bytes(digest_bytes); + let sig = private_key.sign(&digest); + let mut out = [0u8; 64]; + out[..32].copy_from_slice(&sig.c.to_le_bytes()); + out[32..].copy_from_slice(&sig.s.to_le_bytes()); + Ok(js_sys::Uint8Array::from(out.as_slice())) +} + /// Sign a message string with a private key #[wasm_bindgen(js_name = signMessage)] pub fn sign_message(private_key_bytes: &[u8], message: &str) -> Result { diff --git a/crates/iris-wasm/src/tx.rs b/crates/iris-wasm/src/tx.rs index ab7a812..4199264 100644 --- a/crates/iris-wasm/src/tx.rs +++ b/crates/iris-wasm/src/tx.rs @@ -2,7 +2,7 @@ use alloc::format; use alloc::string::{String, ToString}; use alloc::vec::Vec; -use iris_crypto::PrivateKey as CryptoPrivateKey; +use iris_crypto::{PrivateKey as CryptoPrivateKey, PublicKey, Signature, SigningKey}; use iris_grpc_proto::pb::common::v1 as pb_v1; use iris_grpc_proto::pb::common::v2 as pb; use iris_nockchain_types::{ @@ -13,8 +13,11 @@ use iris_nockchain_types::{ Nicks, SpendBuilder, TxEngineSettings, }; use iris_ztd::{cue, Digest, U256}; +use js_sys::{Function, Reflect, Uint8Array}; use serde::{Deserialize, Serialize}; use wasm_bindgen::prelude::*; +use wasm_bindgen::JsCast; +use wasm_bindgen_futures::JsFuture; // ============================================================================ // Wasm Types - Adapters and Helpers @@ -127,6 +130,7 @@ impl TxLock { enum PrivateKeyBackend { Bytes(BytesPrivateKeyBackend), + Callback(CallbackPrivateKeyBackend), } struct BytesPrivateKeyBackend { @@ -134,11 +138,131 @@ struct BytesPrivateKeyBackend { public_key_bytes: [u8; 97], } +struct CallbackPrivateKeyBackend { + get_public_key: Function, + sign_digest: Function, +} + +/// Bridges in-memory keys to [`SigningKey`] for synchronous `TxBuilder::sign`. +struct BytesSigningAdapter<'a>(&'a CryptoPrivateKey); + +impl SigningKey for BytesSigningAdapter<'_> { + fn signing_public_key(&self) -> PublicKey { + self.0.public_key() + } + + fn sign_digest(&self, digest: &Digest) -> Signature { + self.0.sign(digest) + } +} + +fn get_callback_fn(obj: &js_sys::Object, keys: &[&str]) -> Result { + for key in keys { + let v = Reflect::get(obj, &JsValue::from_str(key)).map_err(|_| { + JsValue::from_str("fromCallbacks: failed to read property from callbacks object") + })?; + if v.is_undefined() || v.is_null() { + continue; + } + if let Ok(f) = v.dyn_into::() { + return Ok(f); + } + } + Err(JsValue::from_str(&format!( + "fromCallbacks: expected a function at one of: {:?}", + keys + ))) +} + +async fn await_resolved_promise(v: JsValue) -> Result { + JsFuture::from(js_sys::Promise::resolve(&v)) + .await + .map_err(|e| e) +} + +async fn callback_fetch_public_key(cb: &CallbackPrivateKeyBackend) -> Result { + let v = await_resolved_promise(cb.get_public_key.call0(&JsValue::NULL)?).await?; + let arr = v + .dyn_into::() + .map_err(|_| JsValue::from_str("getPublicKey must return Uint8Array of length 97"))?; + if arr.length() as usize != 97 { + return Err(JsValue::from_str("getPublicKey: expected 97 bytes")); + } + let mut b = [0u8; 97]; + arr.copy_to(&mut b); + Ok(PublicKey::from_be_bytes(&b)) +} + +async fn callback_sign_digest( + cb: &CallbackPrivateKeyBackend, + digest: &Digest, +) -> Result { + let digest_bytes = digest.to_bytes(); + let arg = Uint8Array::from(digest_bytes.as_slice()); + let v = await_resolved_promise(cb.sign_digest.call1(&JsValue::NULL, &arg)?).await?; + let arr = v + .dyn_into::() + .map_err(|_| JsValue::from_str("sign must return Uint8Array of length 64 (c||s LE)"))?; + if arr.length() as usize != 64 { + return Err(JsValue::from_str("sign: expected 64 bytes (32 c + 32 s LE)")); + } + let mut buf = [0u8; 64]; + arr.copy_to(&mut buf); + Ok(Signature { + c: U256::from_le_slice(&buf[..32]), + s: U256::from_le_slice(&buf[32..]), + }) +} + +async fn sign_tx_builder_with_callback( + builder: &mut TxBuilder, + cb: &CallbackPrivateKeyBackend, +) -> Result<(), JsValue> { + let pk = callback_fetch_public_key(cb).await?; + for (_, spend) in builder.spends_mut() { + if spend.accepts_signing_pubkey(&pk) { + let digest = spend.sig_hash(); + let sig = callback_sign_digest(cb, &digest).await?; + spend.try_apply_signature(pk, sig); + } + } + Ok(()) +} + +async fn sign_spend_builder_with_callback( + spend: &mut SpendBuilder, + cb: &CallbackPrivateKeyBackend, +) -> Result { + let pk = callback_fetch_public_key(cb).await?; + if !spend.accepts_signing_pubkey(&pk) { + return Ok(false); + } + let digest = spend.sig_hash(); + let sig = callback_sign_digest(cb, &digest).await?; + Ok(spend.try_apply_signature(pk, sig)) +} + +/// Holds raw key bytes in WASM; [`Drop`] zeroizes the scalar so the secret does not linger +/// after the JS handle is released. #[wasm_bindgen(js_name = PrivateKey)] pub struct WasmPrivateKey { backend: PrivateKeyBackend, } +impl Drop for WasmPrivateKey { + fn drop(&mut self) { + match &mut self.backend { + PrivateKeyBackend::Bytes(b) => { + unsafe { + core::ptr::write_volatile(&mut b.signing_key.0, U256::ZERO); + } + core::sync::atomic::compiler_fence(core::sync::atomic::Ordering::SeqCst); + } + PrivateKeyBackend::Callback(_) => {} + } + } +} + #[wasm_bindgen(js_class = PrivateKey)] impl WasmPrivateKey { /// Construct a wasm `PrivateKey` from 32-byte private key material. @@ -185,11 +309,45 @@ impl WasmPrivateKey { }) } - /// Return this key's public key as 97-byte uncompressed bytes. + /// External signer: JavaScript object with async (or sync) functions: + /// - `getPublicKey` / `get_public_key` → `Uint8Array(97)` uncompressed pubkey + /// - `sign` / `signDigest` / `sign_digest` → `(digest: Uint8Array(40))` → `Uint8Array(64)` (`c`||`s` LE) + /// + /// Return values may be Promises; they are always awaited. + #[wasm_bindgen(js_name = fromCallbacks)] + pub fn from_callbacks(callbacks: &JsValue) -> Result { + let obj = callbacks + .dyn_ref::() + .ok_or_else(|| JsValue::from_str("fromCallbacks: expected a plain object"))?; + let get_public_key = get_callback_fn(obj, &["getPublicKey", "get_public_key"])?; + let sign_digest = get_callback_fn(obj, &["sign", "signDigest", "sign_digest"])?; + Ok(Self { + backend: PrivateKeyBackend::Callback(CallbackPrivateKeyBackend { + get_public_key, + sign_digest, + }), + }) + } + + /// Return this key's public key as 97-byte uncompressed bytes (bytes backend only). + /// Callback keys return an empty array; use [`Self::public_key_async`]. #[wasm_bindgen(getter, js_name = publicKey)] - pub fn public_key(&self) -> Vec { + pub fn public_key_bytes(&self) -> Vec { match &self.backend { PrivateKeyBackend::Bytes(bytes_backend) => bytes_backend.public_key_bytes.to_vec(), + PrivateKeyBackend::Callback(_) => Vec::new(), + } + } + + /// Public key bytes for both backends; awaits JS for callback keys. + #[wasm_bindgen(js_name = publicKeyAsync)] + pub async fn public_key_async(&self) -> Result, JsValue> { + match &self.backend { + PrivateKeyBackend::Bytes(b) => Ok(b.public_key_bytes.to_vec()), + PrivateKeyBackend::Callback(cb) => { + let pk = callback_fetch_public_key(cb).await?; + Ok(pk.to_be_bytes().to_vec()) + } } } @@ -200,6 +358,7 @@ impl WasmPrivateKey { pub fn derivation_path(&self) -> Option { match &self.backend { PrivateKeyBackend::Bytes(_) => None, + PrivateKeyBackend::Callback(_) => None, } } @@ -208,14 +367,7 @@ impl WasmPrivateKey { pub fn backend_kind(&self) -> String { match &self.backend { PrivateKeyBackend::Bytes(_) => "bytes".to_string(), - } - } -} - -impl WasmPrivateKey { - fn signing_key(&self) -> &CryptoPrivateKey { - match &self.backend { - PrivateKeyBackend::Bytes(bytes_backend) => &bytes_backend.signing_key, + PrivateKeyBackend::Callback(_) => "callback".to_string(), } } } @@ -328,8 +480,14 @@ impl WasmTxBuilder { #[wasm_bindgen] pub async fn sign(&mut self, signing_key: &WasmPrivateKey) -> Result<(), JsValue> { - self.builder.sign(signing_key.signing_key()); - + match &signing_key.backend { + PrivateKeyBackend::Bytes(b) => { + self.builder.sign(&BytesSigningAdapter(&b.signing_key)); + } + PrivateKeyBackend::Callback(cb) => { + sign_tx_builder_with_callback(&mut self.builder, cb).await?; + } + } Ok(()) } @@ -434,7 +592,12 @@ impl WasmSpendBuilder { } pub async fn sign(&mut self, signing_key: &WasmPrivateKey) -> Result { - Ok(self.builder.sign(signing_key.signing_key())) + match &signing_key.backend { + PrivateKeyBackend::Bytes(b) => Ok(self.builder.sign(&BytesSigningAdapter(&b.signing_key))), + PrivateKeyBackend::Callback(cb) => { + sign_spend_builder_with_callback(&mut self.builder, cb).await + } + } } fn from_internal(internal: &SpendBuilder) -> Self { From 9bb48eea6c2adf242c30a1d3bf69a8bc749fa078 Mon Sep 17 00:00:00 2001 From: Justas Date: Wed, 27 May 2026 15:07:48 +0100 Subject: [PATCH 2/3] Apply ko1N's changes --- crates/iris-crypto/src/cheetah.rs | 56 +++-- .../src/tx_engine/builder.rs | 193 +++++++++++++----- crates/iris-wasm/README.md | 26 ++- crates/iris-wasm/examples/grpc-web-demo.html | 5 +- crates/iris-wasm/src/crypto.rs | 4 +- crates/iris-wasm/src/tx.rs | 189 ++++++++--------- 6 files changed, 289 insertions(+), 184 deletions(-) diff --git a/crates/iris-crypto/src/cheetah.rs b/crates/iris-crypto/src/cheetah.rs index 369ab05..8e9413d 100644 --- a/crates/iris-crypto/src/cheetah.rs +++ b/crates/iris-crypto/src/cheetah.rs @@ -1,11 +1,10 @@ +#[cfg(feature = "wasm")] +use alloc::{boxed::Box, format, string::ToString}; #[cfg(feature = "alloc")] -use alloc::{ - boxed::Box, - format, - string::{String, ToString}, - vec::Vec, -}; +use alloc::{string::String, vec::Vec}; use arrayvec::ArrayVec; +use core::convert::Infallible; +use core::future::{ready, Future}; use iris_ztd::{ crypto::cheetah::{ ch_add, ch_neg, ch_scal_big, trunc_g_order, CheetahPoint, F6lt, A_GEN, G_ORDER, @@ -220,6 +219,28 @@ pub struct Signature { pub s: U256, } +impl Signature { + pub const BYTE_LEN: usize = 64; + + pub fn to_bytes_le(&self) -> [u8; Self::BYTE_LEN] { + let mut out = [0u8; Self::BYTE_LEN]; + out[..32].copy_from_slice(&self.c.to_le_bytes()); + out[32..].copy_from_slice(&self.s.to_le_bytes()); + out + } + + pub fn from_bytes_le(bytes: &[u8]) -> Option { + if bytes.len() != Self::BYTE_LEN { + return None; + } + + Some(Self { + c: U256::from_le_slice(&bytes[..32]), + s: U256::from_le_slice(&bytes[32..]), + }) + } +} + // Aggregate signature of the same challenge impl core::iter::Sum for Option { fn sum>(mut iter: I) -> Self { @@ -280,20 +301,31 @@ impl Hashable for Signature { /// ECDSA signing for transaction witnesses. Implemented by [`PrivateKey`] and /// wasm-side key handles that delegate to local or external signers. pub trait SigningKey { - fn signing_public_key(&self) -> PublicKey; - fn sign_digest(&self, digest: &Digest) -> Signature; + type Error; + + fn public_key(&self) -> impl Future> + '_; + + fn sign_digest( + &self, + digest: Digest, + ) -> impl Future> + '_; } #[derive(Debug, Clone)] pub struct PrivateKey(pub U256); impl SigningKey for PrivateKey { - fn signing_public_key(&self) -> PublicKey { - PrivateKey::public_key(self) + type Error = Infallible; + + fn public_key(&self) -> impl Future> + '_ { + ready(Ok(PrivateKey::public_key(self))) } - fn sign_digest(&self, digest: &Digest) -> Signature { - PrivateKey::sign(self, digest) + fn sign_digest( + &self, + digest: Digest, + ) -> impl Future> + '_ { + ready(Ok(PrivateKey::sign(self, &digest))) } } diff --git a/crates/iris-nockchain-types/src/tx_engine/builder.rs b/crates/iris-nockchain-types/src/tx_engine/builder.rs index efa8992..d294e0b 100644 --- a/crates/iris-nockchain-types/src/tx_engine/builder.rs +++ b/crates/iris-nockchain-types/src/tx_engine/builder.rs @@ -367,20 +367,17 @@ impl SpendBuilder { } } - pub fn try_apply_signature( - &mut self, - public_key: PublicKey, - signature: Signature, - ) -> bool { + pub fn try_apply_signature(&mut self, public_key: PublicKey, signature: Signature) -> bool { match &mut self.spend { Spend::S1(spend) => { let pkpkh = public_key.hash(); for p in spend.witness.lock_merkle_proof.spend_condition().pkh() { if p.hashes.contains(&pkpkh) { - spend.witness.pkh_signature.0.insert( - public_key.hash(), - (public_key, signature), - ); + spend + .witness + .pkh_signature + .0 + .insert(public_key.hash(), (public_key, signature)); return true; } } @@ -398,14 +395,30 @@ impl SpendBuilder { false } - pub fn sign(&mut self, signing_key: &impl SigningKey) -> bool { - let pk = signing_key.signing_public_key(); + pub async fn sign(&mut self, signing_key: &K) -> Result> + where + K: SigningKey, + { + let pk = signing_key + .public_key() + .await + .map_err(SigningError::Signer)?; if !self.accepts_signing_pubkey(&pk) { - return false; + return Ok(false); } let digest = self.sig_hash(); - let sig = signing_key.sign_digest(&digest); - self.try_apply_signature(pk, sig) + let sig = signing_key + .sign_digest(digest) + .await + .map_err(SigningError::Signer)?; + if !pk.verify(&digest, &sig) { + return Err(SigningError::InvalidSignature); + } + if !self.try_apply_signature(pk, sig) { + return Err(SigningError::SignatureRejected); + } + + Ok(true) } fn missing_unlocks_fee(&self, settings: &TxEngineSettings) -> Nicks { @@ -574,16 +587,41 @@ impl TxBuilder { ret } - pub fn sign(&mut self, signing_key: &impl SigningKey) -> &mut Self { - for spend in self.spends.values_mut() { - spend.sign(signing_key); + pub async fn sign(&mut self, signing_key: &K) -> Result<&mut Self, SigningError> + where + K: SigningKey, + { + let pk = signing_key + .public_key() + .await + .map_err(SigningError::Signer)?; + let mut signatures = Vec::new(); + + for (name, spend) in &self.spends { + if spend.accepts_signing_pubkey(&pk) { + let digest = spend.sig_hash(); + let sig = signing_key + .sign_digest(digest) + .await + .map_err(SigningError::Signer)?; + if !pk.verify(&digest, &sig) { + return Err(SigningError::InvalidSignature); + } + signatures.push((*name, pk, sig)); + } } - self - } - /// Mutable spend map for embedders (e.g. async WASM signers) that apply signatures out of band. - pub fn spends_mut(&mut self) -> &mut BTreeMap { - &mut self.spends + for (name, pk, sig) in signatures { + let Some(spend) = self.spends.get_mut(&name) else { + debug_assert!(false, "signature staging used names from self.spends"); + return Err(SigningError::SignatureRejected); + }; + if !spend.try_apply_signature(pk, sig) { + return Err(SigningError::SignatureRejected); + } + } + + Ok(self) } pub fn validate(&mut self) -> Result<&mut Self, BuildError> { @@ -888,6 +926,25 @@ pub enum BuildError { MissingUnlocks(Vec), } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SigningError { + Signer(E), + InvalidSignature, + SignatureRejected, +} + +impl core::fmt::Display for SigningError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + SigningError::Signer(error) => write!(f, "signer failed: {error:?}"), + SigningError::InvalidSignature => { + write!(f, "signer returned a signature that does not verify") + } + SigningError::SignatureRejected => write!(f, "signature was rejected by spend builder"), + } + } +} + impl core::fmt::Display for BuildError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { @@ -932,6 +989,8 @@ impl core::fmt::Display for BuildError { #[cfg(test)] mod tests { + extern crate std; + use super::*; use crate::v1::{self, LockPrimitive, LockTim}; use alloc::{string::ToString, vec}; @@ -945,6 +1004,33 @@ mod tests { (ek.private_key.unwrap(), ek.public_key) } + fn block_on(future: F) -> F::Output { + use core::pin::pin; + use core::task::{Context, Poll}; + use std::sync::Arc; + use std::task::{Wake, Waker}; + + struct NoopWaker; + + impl Wake for NoopWaker { + fn wake(self: Arc) {} + } + + let waker = Waker::from(Arc::new(NoopWaker)); + let mut context = Context::from_waker(&waker); + let mut future = pin!(future); + loop { + match future.as_mut().poll(&mut context) { + Poll::Ready(output) => return output, + Poll::Pending => std::thread::yield_now(), + } + } + } + + fn sign_builder(builder: &mut TxBuilder, private_key: &PrivateKey) { + block_on(builder.sign(private_key)).unwrap(); + } + #[test] fn test_builder() { let (private_key, _) = keys(); @@ -994,7 +1080,8 @@ mod tests { "BzEPGBGvTfqo4saYmXLem7NgzmGzZdKNk2EUwWBfhgd1yzkdTeA8yQN", ), ] { - let tx = TxBuilder::new(settings) + let mut builder = TxBuilder::new(settings); + builder .simple_spend_base( vec![(note.clone(), Some(spend_condition.clone()))], recipient, @@ -1004,11 +1091,9 @@ mod tests { ) .unwrap() .set_fee_and_balance_refund(fee, false, true) - .unwrap() - .sign(&private_key) - .validate() - .unwrap() - .build(); + .unwrap(); + sign_builder(&mut builder, &private_key); + let tx = builder.validate().unwrap().build(); let InputDisplay::V1 { .. } = tx.display.inputs else { panic!("Expected V1 inputs"); @@ -1032,8 +1117,8 @@ mod tests { ) .unwrap() .set_fee_and_balance_refund(fee, false, true) - .unwrap() - .sign(&private_key); + .unwrap(); + sign_builder(&mut tx, &private_key); assert!(tx.validate().is_err()); @@ -1052,7 +1137,8 @@ mod tests { let fee1 = builder.calc_fee(); - let tx = builder.sign(&private_key).build(); + sign_builder(&mut builder, &private_key); + let tx = builder.build(); assert_eq!(tx.to_raw_tx().spends.fee(&settings), Nicks(2520000)); assert_eq!(fee1, Nicks(2520000)); @@ -1162,7 +1248,7 @@ mod tests { assert_eq!(builder.cur_fee(), Nicks(992)); // After signing, the fee shouldn't change. - builder.sign(&private_key); + sign_builder(&mut builder, &private_key); assert_eq!(builder.calc_fee(), Nicks(992)); assert_eq!(builder.cur_fee(), Nicks(992)); @@ -1242,7 +1328,8 @@ mod tests { let gift = Nicks(4294967296 * 3 - 65536 * 100); let refund_pkh = public_key.hash(); - let tx = TxBuilder::new(TxEngineSettings::v1_default()) + let mut builder = TxBuilder::new(TxEngineSettings::v1_default()); + builder .simple_spend_base( notes .into_iter() @@ -1270,11 +1357,9 @@ mod tests { ) .unwrap() .recalc_and_set_fee(false) - .unwrap() - .sign(&private_key) - .validate() - .unwrap() - .build(); + .unwrap(); + sign_builder(&mut builder, &private_key); + let tx = builder.validate().unwrap().build(); assert_eq!( tx.id.to_string(), @@ -1411,19 +1496,16 @@ mod tests { .try_into() .unwrap(), )), - LockPrimitive::Hax( - Hax { - preimages: [Digest([ - Belt(1730770831742798981), - Belt(2676322185709933211), - Belt(8329210750824781744), - Belt(16756092452590401876), - Belt(3547445316740171466), - ])] - .into(), - } + LockPrimitive::Hax(Hax { + preimages: [Digest([ + Belt(1730770831742798981), + Belt(2676322185709933211), + Belt(8329210750824781744), + Belt(16756092452590401876), + Belt(3547445316740171466), + ])] .into(), - ), + }), ] .into(), ) @@ -1499,7 +1581,8 @@ mod tests { .into(), 0, ); - let tx = TxBuilder::new(TxEngineSettings::v1_with_word_cost(Nicks(1))) + let mut builder = TxBuilder::new(TxEngineSettings::v1_with_word_cost(Nicks(1))); + builder .simple_spend_base( vec![(note.clone(), Some(spend_condition.clone()))], recipient, @@ -1509,11 +1592,9 @@ mod tests { ) .unwrap() .set_fee_and_balance_refund(fee, false, true) - .unwrap() - .sign(&private_key) - .validate() - .unwrap() - .build(); + .unwrap(); + sign_builder(&mut builder, &private_key); + let tx = builder.validate().unwrap().build(); assert_eq!( tx.id.to_string(), "3pmkA1knKhJzmd28t5TULP9DADK7GhWsHaNSTpPcGcN4nxzrWsDK2xe", diff --git a/crates/iris-wasm/README.md b/crates/iris-wasm/README.md index a78795a..a15f7b6 100644 --- a/crates/iris-wasm/README.md +++ b/crates/iris-wasm/README.md @@ -102,6 +102,7 @@ import init, { GrpcClient, PrivateKey, deriveMasterKeyFromMnemonic, + hashPublicKey, txEngineSettingsV1Default, TxBuilder, Note, @@ -137,12 +138,14 @@ console.log('Balance by name:', balanceByName); // Derive keys from mnemonic const mnemonic = "dice domain inspire horse time..."; const masterKey = deriveMasterKeyFromMnemonic(mnemonic, ""); +const privateKey = PrivateKey.fromBytes(masterKey.private_key); +const publicKey = await privateKey.publicKey(); // Use one available note from the balance query const note = Note.fromProtobuf(balance.notes[0].note); // Create spend condition -const pubkeyHash = new Digest("your_pubkey_hash_here"); +const pubkeyHash = hashPublicKey(publicKey); const spendCondition = new SpendCondition([ LockPrimitive.newPkh(Pkh.single(pubkeyHash)), LockPrimitive.newTim(LockTim.coinbase()) @@ -164,7 +167,6 @@ await builder.simpleSpend( ); // Sign and submit -const privateKey = PrivateKey.fromBytes(masterKey.private_key); await builder.sign(privateKey); const signedTx = builder.build(); const txProtobuf = signedTx.toProtobuf(); @@ -175,6 +177,26 @@ const accepted = await client.transactionAccepted(signedTx.id.value); console.log('Transaction accepted:', accepted); ``` +### Callback Signing + +Use callback-backed keys for Ledger, browser transport, or remote KMS integrations. The callbacks may return values directly or Promises. + +```javascript +const signingKey = PrivateKey.fromCallbacks({ + async getPublicKey() { + return ledgerPublicKeyBytes; // Uint8Array(97) + }, + + async signDigest(digest) { + // digest is Uint8Array(40). Return c || s, each 32-byte little-endian. + return ledgerSignatureBytes; // Uint8Array(64) + } +}); + +const publicKey = await signingKey.publicKey(); +await builder.sign(signingKey); +``` + ## API Reference ### `GrpcClient` diff --git a/crates/iris-wasm/examples/grpc-web-demo.html b/crates/iris-wasm/examples/grpc-web-demo.html index 03b2959..37d3b84 100644 --- a/crates/iris-wasm/examples/grpc-web-demo.html +++ b/crates/iris-wasm/examples/grpc-web-demo.html @@ -251,7 +251,9 @@

Output

log('Deriving private key from mnemonic...', 'loading'); const masterKey = deriveMasterKeyFromMnemonic(mnemonicInput, ""); const privateKeyBytes = masterKey.private_key; - const pubkeyHashString = hashPublicKey(masterKey.public_key); + const privateKey = PrivateKey.fromBytes(privateKeyBytes); + const publicKey = await privateKey.publicKey(); + const pubkeyHashString = hashPublicKey(publicKey); log('Fetching balance to get available notes...', 'loading'); client = new GrpcClient(endpoint); @@ -287,7 +289,6 @@

Output

feeAmount, refundDigest ); - const privateKey = PrivateKey.fromBytes(privateKeyBytes); await builder.sign(privateKey); const rawTx = builder.build(); log('Transaction built successfully!', 'success'); diff --git a/crates/iris-wasm/src/crypto.rs b/crates/iris-wasm/src/crypto.rs index cdae7c9..175a04c 100644 --- a/crates/iris-wasm/src/crypto.rs +++ b/crates/iris-wasm/src/crypto.rs @@ -156,9 +156,7 @@ pub fn sign_digest_bytes( let private_key = PrivateKey(U256::from_be_slice(private_key_bytes)); let digest = Digest::from_bytes(digest_bytes); let sig = private_key.sign(&digest); - let mut out = [0u8; 64]; - out[..32].copy_from_slice(&sig.c.to_le_bytes()); - out[32..].copy_from_slice(&sig.s.to_le_bytes()); + let out = sig.to_bytes_le(); Ok(js_sys::Uint8Array::from(out.as_slice())) } diff --git a/crates/iris-wasm/src/tx.rs b/crates/iris-wasm/src/tx.rs index 4199264..b65409e 100644 --- a/crates/iris-wasm/src/tx.rs +++ b/crates/iris-wasm/src/tx.rs @@ -10,7 +10,7 @@ use iris_nockchain_types::{ note::Note, tx::RawTx, v1::{Lock, LockRoot, NockchainTx, RawTxV1, SeedV1 as Seed, SpendCondition}, - Nicks, SpendBuilder, TxEngineSettings, + Nicks, SigningError, SpendBuilder, TxEngineSettings, }; use iris_ztd::{cue, Digest, U256}; use js_sys::{Function, Reflect, Uint8Array}; @@ -135,53 +135,34 @@ enum PrivateKeyBackend { struct BytesPrivateKeyBackend { signing_key: CryptoPrivateKey, - public_key_bytes: [u8; 97], } struct CallbackPrivateKeyBackend { + // Keep the original callbacks object so method-style callbacks receive it as `this`. + callbacks: js_sys::Object, get_public_key: Function, sign_digest: Function, } -/// Bridges in-memory keys to [`SigningKey`] for synchronous `TxBuilder::sign`. -struct BytesSigningAdapter<'a>(&'a CryptoPrivateKey); - -impl SigningKey for BytesSigningAdapter<'_> { - fn signing_public_key(&self) -> PublicKey { - self.0.public_key() - } - - fn sign_digest(&self, digest: &Digest) -> Signature { - self.0.sign(digest) +fn get_callback_fn(obj: &js_sys::Object, key: &str) -> Result { + let v = Reflect::get(obj, &JsValue::from_str(key)).map_err(|_| { + JsValue::from_str("fromCallbacks: failed to read property from callbacks object") + })?; + if let Ok(f) = v.dyn_into::() { + return Ok(f); } -} -fn get_callback_fn(obj: &js_sys::Object, keys: &[&str]) -> Result { - for key in keys { - let v = Reflect::get(obj, &JsValue::from_str(key)).map_err(|_| { - JsValue::from_str("fromCallbacks: failed to read property from callbacks object") - })?; - if v.is_undefined() || v.is_null() { - continue; - } - if let Ok(f) = v.dyn_into::() { - return Ok(f); - } - } Err(JsValue::from_str(&format!( - "fromCallbacks: expected a function at one of: {:?}", - keys + "fromCallbacks: expected a function at `{key}`" ))) } async fn await_resolved_promise(v: JsValue) -> Result { - JsFuture::from(js_sys::Promise::resolve(&v)) - .await - .map_err(|e| e) + JsFuture::from(js_sys::Promise::resolve(&v)).await } async fn callback_fetch_public_key(cb: &CallbackPrivateKeyBackend) -> Result { - let v = await_resolved_promise(cb.get_public_key.call0(&JsValue::NULL)?).await?; + let v = await_resolved_promise(cb.get_public_key.call0(&cb.callbacks)?).await?; let arr = v .dyn_into::() .map_err(|_| JsValue::from_str("getPublicKey must return Uint8Array of length 97"))?; @@ -199,47 +180,27 @@ async fn callback_sign_digest( ) -> Result { let digest_bytes = digest.to_bytes(); let arg = Uint8Array::from(digest_bytes.as_slice()); - let v = await_resolved_promise(cb.sign_digest.call1(&JsValue::NULL, &arg)?).await?; + let v = await_resolved_promise(cb.sign_digest.call1(&cb.callbacks, &arg)?).await?; let arr = v .dyn_into::() - .map_err(|_| JsValue::from_str("sign must return Uint8Array of length 64 (c||s LE)"))?; - if arr.length() as usize != 64 { - return Err(JsValue::from_str("sign: expected 64 bytes (32 c + 32 s LE)")); + .map_err(|_| JsValue::from_str("signDigest must return Uint8Array of length 64"))?; + if arr.length() as usize != Signature::BYTE_LEN { + return Err(JsValue::from_str( + "signDigest: expected 64 bytes (32 c + 32 s LE)", + )); } - let mut buf = [0u8; 64]; + let mut buf = [0u8; Signature::BYTE_LEN]; arr.copy_to(&mut buf); - Ok(Signature { - c: U256::from_le_slice(&buf[..32]), - s: U256::from_le_slice(&buf[32..]), - }) -} - -async fn sign_tx_builder_with_callback( - builder: &mut TxBuilder, - cb: &CallbackPrivateKeyBackend, -) -> Result<(), JsValue> { - let pk = callback_fetch_public_key(cb).await?; - for (_, spend) in builder.spends_mut() { - if spend.accepts_signing_pubkey(&pk) { - let digest = spend.sig_hash(); - let sig = callback_sign_digest(cb, &digest).await?; - spend.try_apply_signature(pk, sig); - } - } - Ok(()) + Signature::from_bytes_le(&buf) + .ok_or_else(|| JsValue::from_str("signDigest: expected 64 bytes (32 c + 32 s LE)")) } -async fn sign_spend_builder_with_callback( - spend: &mut SpendBuilder, - cb: &CallbackPrivateKeyBackend, -) -> Result { - let pk = callback_fetch_public_key(cb).await?; - if !spend.accepts_signing_pubkey(&pk) { - return Ok(false); +fn signing_error_to_js(error: SigningError) -> JsValue { + match error { + SigningError::Signer(error) => error, + SigningError::InvalidSignature => JsValue::from_str("signer returned an invalid signature"), + SigningError::SignatureRejected => JsValue::from_str("signature was rejected by spend"), } - let digest = spend.sig_hash(); - let sig = callback_sign_digest(cb, &digest).await?; - Ok(spend.try_apply_signature(pk, sig)) } /// Holds raw key bytes in WASM; [`Drop`] zeroizes the scalar so the secret does not linger @@ -249,6 +210,41 @@ pub struct WasmPrivateKey { backend: PrivateKeyBackend, } +impl SigningKey for WasmPrivateKey { + type Error = JsValue; + + fn public_key( + &self, + ) -> impl core::future::Future> + '_ { + async move { + match &self.backend { + PrivateKeyBackend::Bytes(bytes_backend) => { + Ok(bytes_backend.signing_key.public_key()) + } + PrivateKeyBackend::Callback(callback_backend) => { + callback_fetch_public_key(callback_backend).await + } + } + } + } + + fn sign_digest( + &self, + digest: Digest, + ) -> impl core::future::Future> + '_ { + async move { + match &self.backend { + PrivateKeyBackend::Bytes(bytes_backend) => { + Ok(bytes_backend.signing_key.sign(&digest)) + } + PrivateKeyBackend::Callback(callback_backend) => { + callback_sign_digest(callback_backend, &digest).await + } + } + } + } +} + impl Drop for WasmPrivateKey { fn drop(&mut self) { match &mut self.backend { @@ -299,19 +295,14 @@ impl WasmPrivateKey { } let signing_key = CryptoPrivateKey(U256::from_be_slice(signing_key_bytes)); - let public_key_bytes = signing_key.public_key().to_be_bytes(); - Ok(Self { - backend: PrivateKeyBackend::Bytes(BytesPrivateKeyBackend { - signing_key, - public_key_bytes, - }), + backend: PrivateKeyBackend::Bytes(BytesPrivateKeyBackend { signing_key }), }) } /// External signer: JavaScript object with async (or sync) functions: - /// - `getPublicKey` / `get_public_key` → `Uint8Array(97)` uncompressed pubkey - /// - `sign` / `signDigest` / `sign_digest` → `(digest: Uint8Array(40))` → `Uint8Array(64)` (`c`||`s` LE) + /// - `getPublicKey` -> `Uint8Array(97)` uncompressed pubkey + /// - `signDigest` -> `(digest: Uint8Array(40))` -> `Uint8Array(64)` (`c`||`s` LE) /// /// Return values may be Promises; they are always awaited. #[wasm_bindgen(js_name = fromCallbacks)] @@ -319,36 +310,22 @@ impl WasmPrivateKey { let obj = callbacks .dyn_ref::() .ok_or_else(|| JsValue::from_str("fromCallbacks: expected a plain object"))?; - let get_public_key = get_callback_fn(obj, &["getPublicKey", "get_public_key"])?; - let sign_digest = get_callback_fn(obj, &["sign", "signDigest", "sign_digest"])?; + let get_public_key = get_callback_fn(obj, "getPublicKey")?; + let sign_digest = get_callback_fn(obj, "signDigest")?; Ok(Self { backend: PrivateKeyBackend::Callback(CallbackPrivateKeyBackend { + callbacks: obj.clone(), get_public_key, sign_digest, }), }) } - /// Return this key's public key as 97-byte uncompressed bytes (bytes backend only). - /// Callback keys return an empty array; use [`Self::public_key_async`]. - #[wasm_bindgen(getter, js_name = publicKey)] - pub fn public_key_bytes(&self) -> Vec { - match &self.backend { - PrivateKeyBackend::Bytes(bytes_backend) => bytes_backend.public_key_bytes.to_vec(), - PrivateKeyBackend::Callback(_) => Vec::new(), - } - } - - /// Public key bytes for both backends; awaits JS for callback keys. - #[wasm_bindgen(js_name = publicKeyAsync)] - pub async fn public_key_async(&self) -> Result, JsValue> { - match &self.backend { - PrivateKeyBackend::Bytes(b) => Ok(b.public_key_bytes.to_vec()), - PrivateKeyBackend::Callback(cb) => { - let pk = callback_fetch_public_key(cb).await?; - Ok(pk.to_be_bytes().to_vec()) - } - } + /// Return this key's public key as 97-byte uncompressed bytes. + #[wasm_bindgen(js_name = publicKey)] + pub async fn public_key(&self) -> Result, JsValue> { + let public_key = ::public_key(self).await?; + Ok(public_key.to_be_bytes().to_vec()) } /// Return the derivation path for this key backend, if available. @@ -480,14 +457,10 @@ impl WasmTxBuilder { #[wasm_bindgen] pub async fn sign(&mut self, signing_key: &WasmPrivateKey) -> Result<(), JsValue> { - match &signing_key.backend { - PrivateKeyBackend::Bytes(b) => { - self.builder.sign(&BytesSigningAdapter(&b.signing_key)); - } - PrivateKeyBackend::Callback(cb) => { - sign_tx_builder_with_callback(&mut self.builder, cb).await?; - } - } + self.builder + .sign(signing_key) + .await + .map_err(signing_error_to_js)?; Ok(()) } @@ -592,12 +565,10 @@ impl WasmSpendBuilder { } pub async fn sign(&mut self, signing_key: &WasmPrivateKey) -> Result { - match &signing_key.backend { - PrivateKeyBackend::Bytes(b) => Ok(self.builder.sign(&BytesSigningAdapter(&b.signing_key))), - PrivateKeyBackend::Callback(cb) => { - sign_spend_builder_with_callback(&mut self.builder, cb).await - } - } + self.builder + .sign(signing_key) + .await + .map_err(signing_error_to_js) } fn from_internal(internal: &SpendBuilder) -> Self { From dd5221c6ace48f4ae338e81b4c81b72749ac0d7a Mon Sep 17 00:00:00 2001 From: Justas Date: Tue, 2 Jun 2026 13:46:50 +0300 Subject: [PATCH 3/3] implement sign_digests --- crates/iris-crypto/src/cheetah.rs | 21 ++--- .../src/tx_engine/builder.rs | 33 +++++--- crates/iris-wasm/README.md | 7 +- crates/iris-wasm/src/crypto.rs | 23 ------ crates/iris-wasm/src/tx.rs | 78 +++++++++++++------ 5 files changed, 90 insertions(+), 72 deletions(-) diff --git a/crates/iris-crypto/src/cheetah.rs b/crates/iris-crypto/src/cheetah.rs index 8e9413d..538a674 100644 --- a/crates/iris-crypto/src/cheetah.rs +++ b/crates/iris-crypto/src/cheetah.rs @@ -305,10 +305,10 @@ pub trait SigningKey { fn public_key(&self) -> impl Future> + '_; - fn sign_digest( - &self, - digest: Digest, - ) -> impl Future> + '_; + fn sign_digests<'a>( + &'a self, + digests: &'a [Digest], + ) -> impl Future, Self::Error>> + 'a; } #[derive(Debug, Clone)] @@ -321,11 +321,14 @@ impl SigningKey for PrivateKey { ready(Ok(PrivateKey::public_key(self))) } - fn sign_digest( - &self, - digest: Digest, - ) -> impl Future> + '_ { - ready(Ok(PrivateKey::sign(self, &digest))) + fn sign_digests<'a>( + &'a self, + digests: &'a [Digest], + ) -> impl Future, Self::Error>> + 'a { + ready(Ok(digests + .iter() + .map(|d| PrivateKey::sign(self, d)) + .collect())) } } diff --git a/crates/iris-nockchain-types/src/tx_engine/builder.rs b/crates/iris-nockchain-types/src/tx_engine/builder.rs index d294e0b..1a1fc4f 100644 --- a/crates/iris-nockchain-types/src/tx_engine/builder.rs +++ b/crates/iris-nockchain-types/src/tx_engine/builder.rs @@ -408,9 +408,11 @@ impl SpendBuilder { } let digest = self.sig_hash(); let sig = signing_key - .sign_digest(digest) + .sign_digests(&[digest]) .await - .map_err(SigningError::Signer)?; + .map_err(SigningError::Signer)? + .pop() + .ok_or(SigningError::SignatureRejected)?; if !pk.verify(&digest, &sig) { return Err(SigningError::InvalidSignature); } @@ -595,23 +597,30 @@ impl TxBuilder { .public_key() .await .map_err(SigningError::Signer)?; - let mut signatures = Vec::new(); + let mut name_and_digest = Vec::new(); + let mut digests = Vec::new(); for (name, spend) in &self.spends { if spend.accepts_signing_pubkey(&pk) { let digest = spend.sig_hash(); - let sig = signing_key - .sign_digest(digest) - .await - .map_err(SigningError::Signer)?; - if !pk.verify(&digest, &sig) { - return Err(SigningError::InvalidSignature); - } - signatures.push((*name, pk, sig)); + name_and_digest.push((*name, digest)); + digests.push(digest); } } - for (name, pk, sig) in signatures { + let signatures = signing_key + .sign_digests(&digests) + .await + .map_err(SigningError::Signer)?; + + if signatures.len() != name_and_digest.len() { + return Err(SigningError::SignatureRejected); + } + + for ((name, digest), sig) in name_and_digest.into_iter().zip(signatures.into_iter()) { + if !pk.verify(&digest, &sig) { + return Err(SigningError::InvalidSignature); + } let Some(spend) = self.spends.get_mut(&name) else { debug_assert!(false, "signature staging used names from self.spends"); return Err(SigningError::SignatureRejected); diff --git a/crates/iris-wasm/README.md b/crates/iris-wasm/README.md index a15f7b6..08dde03 100644 --- a/crates/iris-wasm/README.md +++ b/crates/iris-wasm/README.md @@ -187,9 +187,10 @@ const signingKey = PrivateKey.fromCallbacks({ return ledgerPublicKeyBytes; // Uint8Array(97) }, - async signDigest(digest) { - // digest is Uint8Array(40). Return c || s, each 32-byte little-endian. - return ledgerSignatureBytes; // Uint8Array(64) + async signDigests(digests) { + // digests is Uint8Array[]; each digest is Uint8Array(40). + // Return one signature per digest: Uint8Array(64) = c || s, each 32-byte little-endian. + return ledgerSignatureBytesArray; // Uint8Array(64)[] } }); diff --git a/crates/iris-wasm/src/crypto.rs b/crates/iris-wasm/src/crypto.rs index 175a04c..458b0d6 100644 --- a/crates/iris-wasm/src/crypto.rs +++ b/crates/iris-wasm/src/crypto.rs @@ -137,29 +137,6 @@ pub fn hash_noun(noun: &[u8]) -> Result { Ok(digest.to_string()) } -/// Sign a 40-byte digest (same encoding as [`iris_ztd::Digest::to_bytes`]) with a 32-byte -/// private key. Returns 64 bytes: `c` then `s`, each 32-byte little-endian scalars. -#[wasm_bindgen(js_name = signDigestBytes)] -pub fn sign_digest_bytes( - private_key_bytes: &[u8], - digest_bytes: &[u8], -) -> Result { - use iris_ztd::Digest; - if private_key_bytes.len() != 32 { - return Err(JsValue::from_str("Private key must be 32 bytes")); - } - if digest_bytes.len() != 40 { - return Err(JsValue::from_str( - "Digest must be 40 bytes (nockchain digest limb encoding)", - )); - } - let private_key = PrivateKey(U256::from_be_slice(private_key_bytes)); - let digest = Digest::from_bytes(digest_bytes); - let sig = private_key.sign(&digest); - let out = sig.to_bytes_le(); - Ok(js_sys::Uint8Array::from(out.as_slice())) -} - /// Sign a message string with a private key #[wasm_bindgen(js_name = signMessage)] pub fn sign_message(private_key_bytes: &[u8], message: &str) -> Result { diff --git a/crates/iris-wasm/src/tx.rs b/crates/iris-wasm/src/tx.rs index b65409e..fb40af6 100644 --- a/crates/iris-wasm/src/tx.rs +++ b/crates/iris-wasm/src/tx.rs @@ -13,7 +13,7 @@ use iris_nockchain_types::{ Nicks, SigningError, SpendBuilder, TxEngineSettings, }; use iris_ztd::{cue, Digest, U256}; -use js_sys::{Function, Reflect, Uint8Array}; +use js_sys::{Array, Function, Reflect, Uint8Array}; use serde::{Deserialize, Serialize}; use wasm_bindgen::prelude::*; use wasm_bindgen::JsCast; @@ -141,7 +141,7 @@ struct CallbackPrivateKeyBackend { // Keep the original callbacks object so method-style callbacks receive it as `this`. callbacks: js_sys::Object, get_public_key: Function, - sign_digest: Function, + sign_digests: Function, } fn get_callback_fn(obj: &js_sys::Object, key: &str) -> Result { @@ -174,25 +174,49 @@ async fn callback_fetch_public_key(cb: &CallbackPrivateKeyBackend) -> Result Result { - let digest_bytes = digest.to_bytes(); - let arg = Uint8Array::from(digest_bytes.as_slice()); - let v = await_resolved_promise(cb.sign_digest.call1(&cb.callbacks, &arg)?).await?; + digests: &[Digest], +) -> Result, JsValue> { + let args = Array::new(); + for digest in digests { + let digest_bytes = digest.to_bytes(); + args.push(&Uint8Array::from(digest_bytes.as_slice())); + } + + let v = await_resolved_promise(cb.sign_digests.call1(&cb.callbacks, &args)?).await?; let arr = v - .dyn_into::() - .map_err(|_| JsValue::from_str("signDigest must return Uint8Array of length 64"))?; - if arr.length() as usize != Signature::BYTE_LEN { + .dyn_into::() + .map_err(|_| JsValue::from_str("signDigests must return an Array"))?; + + if arr.length() as usize != digests.len() { return Err(JsValue::from_str( - "signDigest: expected 64 bytes (32 c + 32 s LE)", + "signDigests: expected one signature per digest", )); } - let mut buf = [0u8; Signature::BYTE_LEN]; - arr.copy_to(&mut buf); - Signature::from_bytes_le(&buf) - .ok_or_else(|| JsValue::from_str("signDigest: expected 64 bytes (32 c + 32 s LE)")) + + let mut out = Vec::with_capacity(arr.length() as usize); + for (i, item) in arr.iter().enumerate() { + let sig_bytes = item + .dyn_into::() + .map_err(|_| JsValue::from_str("signDigests must return an Array"))?; + + if sig_bytes.length() as usize != Signature::BYTE_LEN { + return Err(JsValue::from_str( + "signDigests: expected Uint8Array(64) for each signature", + )); + } + + let mut buf = [0u8; Signature::BYTE_LEN]; + sig_bytes.copy_to(&mut buf); + let sig = Signature::from_bytes_le(&buf) + .ok_or_else(|| JsValue::from_str("signDigests: expected 64 bytes (32 c + 32 s LE)"))?; + out.push(sig); + + debug_assert!(i < digests.len()); + } + + Ok(out) } fn signing_error_to_js(error: SigningError) -> JsValue { @@ -228,17 +252,21 @@ impl SigningKey for WasmPrivateKey { } } - fn sign_digest( - &self, - digest: Digest, - ) -> impl core::future::Future> + '_ { + fn sign_digests<'a>( + &'a self, + digests: &'a [Digest], + ) -> impl core::future::Future, Self::Error>> + 'a { async move { match &self.backend { PrivateKeyBackend::Bytes(bytes_backend) => { - Ok(bytes_backend.signing_key.sign(&digest)) + let mut out = Vec::with_capacity(digests.len()); + for digest in digests { + out.push(bytes_backend.signing_key.sign(digest)); + } + Ok(out) } PrivateKeyBackend::Callback(callback_backend) => { - callback_sign_digest(callback_backend, &digest).await + callback_sign_digests(callback_backend, digests).await } } } @@ -302,7 +330,7 @@ impl WasmPrivateKey { /// External signer: JavaScript object with async (or sync) functions: /// - `getPublicKey` -> `Uint8Array(97)` uncompressed pubkey - /// - `signDigest` -> `(digest: Uint8Array(40))` -> `Uint8Array(64)` (`c`||`s` LE) + /// - `signDigests` -> `(digests: Uint8Array[])` -> `Uint8Array(64)[]` (`c`||`s` LE) /// /// Return values may be Promises; they are always awaited. #[wasm_bindgen(js_name = fromCallbacks)] @@ -311,12 +339,12 @@ impl WasmPrivateKey { .dyn_ref::() .ok_or_else(|| JsValue::from_str("fromCallbacks: expected a plain object"))?; let get_public_key = get_callback_fn(obj, "getPublicKey")?; - let sign_digest = get_callback_fn(obj, "signDigest")?; + let sign_digests = get_callback_fn(obj, "signDigests")?; Ok(Self { backend: PrivateKeyBackend::Callback(CallbackPrivateKeyBackend { callbacks: obj.clone(), get_public_key, - sign_digest, + sign_digests, }), }) }