diff --git a/Cargo.lock b/Cargo.lock index 793cbd5..2515f7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1258,8 +1258,11 @@ version = "0.1.0" dependencies = [ "cbindgen", "criterion", + "hmac 0.12.1", + "k256 0.14.0-rc.8", "libc", "libtss", + "sha2 0.10.9", "zeroize", ] diff --git a/examples/tokio-tss/src/router.rs b/examples/tokio-tss/src/router.rs index 6877b38..a369689 100644 --- a/examples/tokio-tss/src/router.rs +++ b/examples/tokio-tss/src/router.rs @@ -153,8 +153,7 @@ impl EncryptedRouter { if let Some(target) = message.to { let (send_channel, _) = self.peer_channels.get_mut(&target).ok_or_else(|| { TssError::Protocol(format!( - "party {} has no encrypted channel for peer {}", - self_id, target + "party {self_id} has no encrypted channel for peer {target}" )) })?; @@ -211,8 +210,7 @@ pub fn create_encrypted_routers( if left_shared != right_shared { return Err(TssError::Encryption(format!( - "mismatched shared secret for parties {} and {}", - left, right + "mismatched shared secret for parties {left} and {right}" ))); } diff --git a/libtss-dotnet/src/Libtss/KeyShareHandle.cs b/libtss-dotnet/src/Libtss/KeyShareHandle.cs index 24a435d..58a8ff7 100644 --- a/libtss-dotnet/src/Libtss/KeyShareHandle.cs +++ b/libtss-dotnet/src/Libtss/KeyShareHandle.cs @@ -87,6 +87,37 @@ public unsafe byte[] GroupVerifyingKey } } + /// + /// The 32-byte BIP-32 chain code paired with . + /// + /// + /// + /// Only the BIP-32-capable ciphersuites carry a chain code: + /// , + /// and . Every other suite throws a + /// with . + /// + /// + /// Pair with to build a BIP-32 extended public key (xpub) + /// whose off-MPC child derivation matches . + /// + /// + public unsafe byte[] ChainCode + { + get + { + ThrowIfDisposed(); + var buf = new NativeTssBuffer(); + int status; + fixed (TssBuffer* p = &buf.Raw) + { + status = Native.tss_handle_chain_code(_handle, p); + } + TssException.ThrowIfError(status); + return buf.ToArray(); + } + } + /// /// The public verification key for this specific share as raw bytes. /// diff --git a/libtss-dotnet/src/Libtss/Native.cs b/libtss-dotnet/src/Libtss/Native.cs index d9dcb1c..63dc89a 100644 --- a/libtss-dotnet/src/Libtss/Native.cs +++ b/libtss-dotnet/src/Libtss/Native.cs @@ -150,6 +150,9 @@ public static extern int tss_refresh_next( [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] public static extern int tss_handle_group_key(ulong handle, TssBuffer* @out); + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern int tss_handle_chain_code(ulong handle, TssBuffer* @out); + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] public static extern int tss_handle_pubkey_package(ulong handle, TssBuffer* @out); diff --git a/libtss-dotnet/tests/Libtss.Tests/IntegrationTests.cs b/libtss-dotnet/tests/Libtss.Tests/IntegrationTests.cs index 90dbc66..6beac7a 100644 --- a/libtss-dotnet/tests/Libtss.Tests/IntegrationTests.cs +++ b/libtss-dotnet/tests/Libtss.Tests/IntegrationTests.cs @@ -134,6 +134,69 @@ public void SplitKey_ZerosInputSecretKey() Assert.All(secretKey, b => Assert.Equal(0, b)); } + [Fact] + public void ChainCode_Taproot_IsSha256OfGroupKey() + { + RequireNativeLibrary(); + Tss.Init(); + + var (handles, _) = FrostOperations.GenerateDealer( + Ciphersuite.Secp256k1Taproot, maxSigners: 3, minSigners: 2); + try + { + byte[] chainCode = handles[0].ChainCode; + Assert.Equal(32, chainCode.Length); + Assert.Equal(SHA256.HashData(handles[0].GroupVerifyingKey), chainCode); + } + finally + { + foreach (var h in handles) + h.Dispose(); + } + } + + [Fact] + public void ChainCode_ChangesAfterDeriveChild() + { + RequireNativeLibrary(); + Tss.Init(); + + var (handles, _) = FrostOperations.GenerateDealer( + Ciphersuite.Secp256k1Taproot, maxSigners: 3, minSigners: 2); + try + { + using var child = DeriveOperations.DeriveChild(handles[0], childNumber: 7); + byte[] childChainCode = child.ChainCode; + Assert.Equal(32, childChainCode.Length); + Assert.NotEqual(handles[0].ChainCode, childChainCode); + } + finally + { + foreach (var h in handles) + h.Dispose(); + } + } + + [Fact] + public void ChainCode_UnsupportedSuite_Throws() + { + RequireNativeLibrary(); + Tss.Init(); + + var (handles, _) = FrostOperations.GenerateDealer( + Ciphersuite.Ed25519, maxSigners: 3, minSigners: 2); + try + { + var ex = Assert.Throws(() => handles[0].ChainCode); + Assert.Equal(TssStatus.ProtoMismatch, ex.Code); + } + finally + { + foreach (var h in handles) + h.Dispose(); + } + } + [Fact] public void WipeBytes_ZerosArray() { diff --git a/libtss-ffi/Cargo.toml b/libtss-ffi/Cargo.toml index f3ca7c3..c076849 100644 --- a/libtss-ffi/Cargo.toml +++ b/libtss-ffi/Cargo.toml @@ -17,6 +17,9 @@ zeroize = { workspace = true } [dev-dependencies] criterion = { version = "0.5", features = ["html_reports"] } +hmac = "0.12" +sha2 = "0.10" +k256 = { workspace = true } [build-dependencies] cbindgen = "0.27" diff --git a/libtss-ffi/libtss.h b/libtss-ffi/libtss.h index 1d614bc..24a9d31 100644 --- a/libtss-ffi/libtss.h +++ b/libtss-ffi/libtss.h @@ -181,6 +181,14 @@ TssStatus tss_handle_verifying_share(TssHandle handle, struct TssBuffer *out); TssStatus tss_handle_group_key(TssHandle handle, struct TssBuffer *out); +// Return the 32-byte BIP-32 chain code paired with the group key. +// +// Supported for the BIP-32-capable ciphersuites only (Secp256k1ECDSA, +// Secp256r1ECDSA, Secp256k1Taproot); every other suite returns +// `TSS_ERR_PROTO_MISMATCH`. Pair with `tss_handle_group_key` to build an +// extended public key whose off-MPC child derivation matches `tss_derive_child`. +TssStatus tss_handle_chain_code(TssHandle handle, struct TssBuffer *out); + TssStatus tss_handle_pubkey_package(TssHandle handle, struct TssBuffer *out); uint8_t tss_handle_ciphersuite(TssHandle handle); @@ -235,6 +243,41 @@ TssStatus tss_message_build(struct TssBuffer *buf, const uint8_t *data, size_t data_len); +// Compute this helper's masked DKLs23 repair term for `target`. +// +// `helpers` lists every helper (this party included, the target excluded); +// all helpers must pass the identical list and `session_id`. `out_term` +// receives a 32-byte scalar to unicast to the target; free it with +// `tss_buffer_free`. A share missing a zero-share pairing with any helper +// (a hollow, not yet refreshed share) returns `TSS_ERR_INVALID_SHARE` +// rather than an unmasked term. +TssStatus tss_repair_helper_term(TssHandle key_share, + const uint16_t *helpers, + size_t helpers_len, + uint16_t target, + const uint8_t *session_id, + size_t session_id_len, + struct TssBuffer *out_term); + +// Assemble helpers' repair terms into a hollow DKLs23 key share for `target`. +// +// `pubkey_package` is the serialized group package and `chain_code` its +// 32-byte BIP-32 chain code, both as the helpers report them; `terms` holds +// at least `min_signers` outputs of `tss_repair_helper_term`. The sum of the +// terms is checked against the target's verifying share and a mismatch +// returns `TSS_ERR_INVALID_SHARE` without creating a handle. The returned +// share cannot sign until a `tss_refresh_new_dkls` refresh has run across the +// whole group. Free it with `tss_handle_free`. +TssStatus tss_repair_assemble(uint8_t suite, + struct TssSlice pubkey_package, + struct TssSlice chain_code, + uint16_t target, + const struct TssSlice *terms, + size_t terms_len, + const uint8_t *session_id, + size_t session_id_len, + TssHandle *out_key_share); + TssStatus tss_dkg_new(uint8_t suite, uint16_t self_id, uint16_t max_signers, @@ -272,6 +315,20 @@ TssStatus tss_refresh_new(TssHandle key_share, TssHandle *out_session, struct TssBuffer *out_messages); +// Create a DKLs23 complete refresh session for a secp256k1 key share. +// +// Every party of the group must run this under the same fresh `session_id`; +// the refreshed shares are salted with it and cannot co-sign with any other +// generation. Advance with `tss_refresh_next` three times. `key_share` stays +// valid; the completed session yields a new handle. Non-DKLs23 shares return +// `TSS_ERR_PROTO_MISMATCH`; an empty `session_id` returns +// `TSS_ERR_INVALID_CONFIG`. +TssStatus tss_refresh_new_dkls(TssHandle key_share, + const uint8_t *session_id, + size_t session_id_len, + TssHandle *out_session, + struct TssBuffer *out_messages); + TssStatus tss_refresh_receiver(TssHandle key_share, TssHandle *out_session); TssStatus tss_refresh_next(TssHandle session, diff --git a/libtss-ffi/src/handles.rs b/libtss-ffi/src/handles.rs index 5b67646..1d9cffe 100644 --- a/libtss-ffi/src/handles.rs +++ b/libtss-ffi/src/handles.rs @@ -45,6 +45,23 @@ pub extern "C" fn tss_handle_group_key(handle: TssHandle, out: *mut TssBuffer) - }) } +/// Return the 32-byte BIP-32 chain code paired with the group key. +/// +/// Supported for the BIP-32-capable ciphersuites only (Secp256k1ECDSA, +/// Secp256r1ECDSA, Secp256k1Taproot); every other suite returns +/// `TSS_ERR_PROTO_MISMATCH`. Pair with `tss_handle_group_key` to build an +/// extended public key whose off-MPC child derivation matches `tss_derive_child`. +#[no_mangle] +pub extern "C" fn tss_handle_chain_code(handle: TssHandle, out: *mut TssBuffer) -> TssStatus { + ffi_entry!({ + let out = unsafe { out.as_mut() } + .ok_or_else(|| libtss::TssError::InvalidConfig("out is null".into()))?; + let ks = borrow_key_share(handle)?; + *out = TssBuffer::from_vec(ks.chain_code()?); + Ok(()) + }) +} + #[no_mangle] pub extern "C" fn tss_handle_pubkey_package(handle: TssHandle, out: *mut TssBuffer) -> TssStatus { ffi_entry!({ diff --git a/libtss-ffi/src/lib.rs b/libtss-ffi/src/lib.rs index 498f1bd..a13ae60 100644 --- a/libtss-ffi/src/lib.rs +++ b/libtss-ffi/src/lib.rs @@ -9,6 +9,7 @@ pub mod init; pub mod memory; pub mod messages; pub mod registry; +pub mod repair; pub mod session; pub mod types; diff --git a/libtss-ffi/src/repair.rs b/libtss-ffi/src/repair.rs new file mode 100644 index 0000000..b6f89fb --- /dev/null +++ b/libtss-ffi/src/repair.rs @@ -0,0 +1,98 @@ +use crate::ffi_entry; +use crate::messages::checked_slice; +use crate::session::{borrow_key_share, checked_optional_slice}; +use crate::types::{TssBuffer, TssHandle, TssSlice, TssStatus}; + +/// Compute this helper's masked DKLs23 repair term for `target`. +/// +/// `helpers` lists every helper (this party included, the target excluded); +/// all helpers must pass the identical list and `session_id`. `out_term` +/// receives a 32-byte scalar to unicast to the target; free it with +/// `tss_buffer_free`. A share missing a zero-share pairing with any helper +/// (a hollow, not yet refreshed share) returns `TSS_ERR_INVALID_SHARE` +/// rather than an unmasked term. +#[no_mangle] +pub extern "C" fn tss_repair_helper_term( + key_share: TssHandle, + helpers: *const u16, + helpers_len: usize, + target: u16, + session_id: *const u8, + session_id_len: usize, + out_term: *mut TssBuffer, +) -> TssStatus { + ffi_entry!({ + let out_term = unsafe { out_term.as_mut() } + .ok_or_else(|| libtss::TssError::InvalidConfig("out_term is null".into()))?; + *out_term = TssBuffer::empty(); + + let helper_ids: Vec = if helpers.is_null() || helpers_len == 0 { + Vec::new() + } else { + let raw = unsafe { core::slice::from_raw_parts(helpers, helpers_len) }; + raw.iter() + .map(|&id| libtss::Identifier::new(id)) + .collect::, _>>()? + }; + let target = libtss::Identifier::new(target)?; + let sid = checked_optional_slice(session_id, session_id_len)?.ok_or_else(|| { + libtss::TssError::InvalidConfig("repair requires a session_id".into()) + })?; + + let handle = borrow_key_share(key_share)?; + let term = libtss::repair_helper_term(&handle, &helper_ids, target, sid)?; + *out_term = TssBuffer::from_vec(term.to_vec()); + Ok(()) + }) +} + +/// Assemble helpers' repair terms into a hollow DKLs23 key share for `target`. +/// +/// `pubkey_package` is the serialized group package and `chain_code` its +/// 32-byte BIP-32 chain code, both as the helpers report them; `terms` holds +/// at least `min_signers` outputs of `tss_repair_helper_term`. The sum of the +/// terms is checked against the target's verifying share and a mismatch +/// returns `TSS_ERR_INVALID_SHARE` without creating a handle. The returned +/// share cannot sign until a `tss_refresh_new_dkls` refresh has run across the +/// whole group. Free it with `tss_handle_free`. +#[allow(clippy::too_many_arguments)] +#[no_mangle] +pub extern "C" fn tss_repair_assemble( + suite: u8, + pubkey_package: TssSlice, + chain_code: TssSlice, + target: u16, + terms: *const TssSlice, + terms_len: usize, + session_id: *const u8, + session_id_len: usize, + out_key_share: *mut TssHandle, +) -> TssStatus { + ffi_entry!({ + let out_key_share = unsafe { out_key_share.as_mut() } + .ok_or_else(|| libtss::TssError::InvalidConfig("out_key_share is null".into()))?; + *out_key_share = 0; + + let suite = libtss::Ciphersuite::try_from(suite)?; + let package = libtss::PublicKeyPackage::deserialize(checked_slice(pubkey_package)?)?; + let chain_code = checked_slice(chain_code)?; + let target = libtss::Identifier::new(target)?; + let term_slices: Vec<&[u8]> = if terms.is_null() || terms_len == 0 { + Vec::new() + } else { + let raw = unsafe { core::slice::from_raw_parts(terms, terms_len) }; + raw.iter() + .map(|term| checked_slice(*term)) + .collect::, _>>()? + }; + let sid = checked_optional_slice(session_id, session_id_len)?.ok_or_else(|| { + libtss::TssError::InvalidConfig("repair requires a session_id".into()) + })?; + + let handle = + libtss::repair_assemble(suite, &package, chain_code, target, &term_slices, sid)?; + *out_key_share = handle.handle_id(); + std::mem::forget(handle); + Ok(()) + }) +} diff --git a/libtss-ffi/src/session.rs b/libtss-ffi/src/session.rs index dba085d..69463af 100644 --- a/libtss-ffi/src/session.rs +++ b/libtss-ffi/src/session.rs @@ -280,6 +280,42 @@ pub extern "C" fn tss_refresh_new( }) } +/// Create a DKLs23 complete refresh session for a secp256k1 key share. +/// +/// Every party of the group must run this under the same fresh `session_id`; +/// the refreshed shares are salted with it and cannot co-sign with any other +/// generation. Advance with `tss_refresh_next` three times. `key_share` stays +/// valid; the completed session yields a new handle. Non-DKLs23 shares return +/// `TSS_ERR_PROTO_MISMATCH`; an empty `session_id` returns +/// `TSS_ERR_INVALID_CONFIG`. +#[no_mangle] +pub extern "C" fn tss_refresh_new_dkls( + key_share: TssHandle, + session_id: *const u8, + session_id_len: usize, + out_session: *mut TssHandle, + out_messages: *mut TssBuffer, +) -> TssStatus { + ffi_entry!({ + let out_session = unsafe { out_session.as_mut() } + .ok_or_else(|| libtss::TssError::InvalidConfig("out_session is null".into()))?; + let out_messages = unsafe { out_messages.as_mut() } + .ok_or_else(|| libtss::TssError::InvalidConfig("out_messages is null".into()))?; + *out_session = 0; + *out_messages = TssBuffer::empty(); + + let handle = borrow_key_share(key_share)?; + let sid = checked_optional_slice(session_id, session_id_len)?.ok_or_else(|| { + libtss::TssError::InvalidConfig("DKLs23 refresh requires a session_id".into()) + })?; + let (session, messages) = libtss::RefreshSession::new_dkls(&handle, sid)?; + + *out_session = insert_session(SessionEntry::Refresh(session)); + *out_messages = TssBuffer::from_vec(serialize_messages(&messages)); + Ok(()) + }) +} + #[no_mangle] pub extern "C" fn tss_refresh_receiver( key_share: TssHandle, diff --git a/libtss-ffi/tests/common/mod.rs b/libtss-ffi/tests/common/mod.rs new file mode 100644 index 0000000..7cc3626 --- /dev/null +++ b/libtss-ffi/tests/common/mod.rs @@ -0,0 +1,349 @@ +//! Shared FFI-level protocol driver for integration tests: runs a complete DKG +//! (and, for DKLs23, a signing session) purely through the C ABI, routing the +//! TLV message blobs between parties the way a host application would. +#![allow(dead_code)] + +use std::ptr; + +use libtss_ffi::memory::tss_buffer_free; +use libtss_ffi::messages::{tss_message_at, tss_message_build, tss_message_count}; +use libtss_ffi::session::{ + tss_dkg_new, tss_dkg_next, tss_refresh_new_dkls, tss_refresh_next, tss_session_free, + tss_sign_new, tss_sign_next, +}; +use libtss_ffi::types::{TssBuffer, TssHandle, TssSlice, TSS_OK}; + +pub const SUITE_SECP256K1_TAPROOT: u8 = 0; +pub const SUITE_ED25519: u8 = 2; +pub const SUITE_SECP256K1_ECDSA: u8 = 6; + +pub fn slice(bytes: &[u8]) -> TssSlice { + TssSlice { + data: bytes.as_ptr(), + len: bytes.len(), + } +} + +pub fn take_buffer(buf: &mut TssBuffer) -> Vec { + if buf.data.is_null() { + return Vec::new(); + } + let out = unsafe { std::slice::from_raw_parts(buf.data, buf.len) }.to_vec(); + tss_buffer_free(buf as *mut TssBuffer); + out +} + +/// Route TLV-encoded messages: for each recipient, build a TLV blob containing +/// only messages addressed to them (direct or broadcast from other senders). +pub fn route_messages(per_party_msgs: &[Vec], participants: usize) -> Vec> { + let mut routed = vec![Vec::::new(); participants]; + + for (sender_idx, blob) in per_party_msgs.iter().enumerate() { + if blob.is_empty() { + continue; + } + let s = slice(blob); + let count = tss_message_count(s); + + for i in 0..count { + let mut from: u16 = 0; + let mut to: u16 = 0; + let mut data = TssSlice { + data: ptr::null(), + len: 0, + }; + assert_eq!(tss_message_at(s, i, &mut from, &mut to, &mut data), TSS_OK); + + let recipients: Vec = if to != 0 { + vec![(to - 1) as usize] + } else { + (0..participants).filter(|&r| r != sender_idx).collect() + }; + + for recip_idx in recipients { + let mut buf = if routed[recip_idx].is_empty() { + TssBuffer::empty() + } else { + TssBuffer::from_vec(std::mem::take(&mut routed[recip_idx])) + }; + assert_eq!( + tss_message_build(&mut buf, from, to, data.data, data.len), + TSS_OK + ); + routed[recip_idx] = take_buffer(&mut buf); + } + } + } + routed +} + +/// Run a full DKG through the FFI and return one key share handle per party. +pub fn run_dkg(suite: u8, participants: u16, min_signers: u16) -> Vec { + let n = participants as usize; + let mut sessions = Vec::new(); + let mut pending = vec![Vec::new(); n]; + let mut keys = vec![0u64; n]; + let session_id = b"ffi-test-session-id"; + + for self_id in 1..=participants { + let mut session = 0; + let mut messages = TssBuffer::empty(); + assert_eq!( + tss_dkg_new( + suite, + self_id, + participants, + min_signers, + session_id.as_ptr(), + session_id.len(), + &mut session, + &mut messages, + ), + TSS_OK, + ); + sessions.push(session); + pending[(self_id - 1) as usize] = take_buffer(&mut messages); + } + + for _ in 0..8 { + if keys.iter().all(|h| *h != 0) { + break; + } + + let routed = route_messages(&pending, n); + pending = vec![Vec::new(); n]; + + for (index, session) in sessions.iter().enumerate() { + if keys[index] != 0 { + continue; + } + + let mut key_share = 0; + let mut pubkey_package = TssBuffer::empty(); + let mut out_messages = TssBuffer::empty(); + let mut complete = false; + assert_eq!( + tss_dkg_next( + *session, + slice(&routed[index]), + &mut key_share, + &mut pubkey_package, + &mut out_messages, + &mut complete, + ), + TSS_OK, + ); + + let _ = take_buffer(&mut pubkey_package); + if complete { + keys[index] = key_share; + let _ = take_buffer(&mut out_messages); + } else { + pending[index] = take_buffer(&mut out_messages); + } + } + } + + assert!(keys.iter().all(|h| *h != 0), "DKG did not complete"); + for session in sessions { + tss_session_free(session); + } + keys +} + +/// Run a DKLs23 signing session over `message_hash` with the given key shares +/// (all of them sign; `keys[i]` must belong to party `i + 1`) and return the +/// 64-byte `r || s` signature. +pub fn run_dkls_sign(keys: &[TssHandle], message_hash: &[u8]) -> Vec { + let signers: Vec<(u16, TssHandle)> = keys + .iter() + .enumerate() + .map(|(i, key)| (i as u16 + 1, *key)) + .collect(); + run_dkls_sign_ids(&signers, message_hash) +} + +/// Route messages between an arbitrary set of party ids: `parties[i]` is the +/// id of the party whose blob is `per_party_msgs[i]`. +pub fn route_messages_ids(per_party_msgs: &[Vec], parties: &[u16]) -> Vec> { + let mut routed = vec![Vec::::new(); parties.len()]; + + for (sender_idx, blob) in per_party_msgs.iter().enumerate() { + if blob.is_empty() { + continue; + } + let s = slice(blob); + let count = tss_message_count(s); + + for i in 0..count { + let mut from: u16 = 0; + let mut to: u16 = 0; + let mut data = TssSlice { + data: ptr::null(), + len: 0, + }; + assert_eq!(tss_message_at(s, i, &mut from, &mut to, &mut data), TSS_OK); + + let recipients: Vec = if to != 0 { + parties.iter().position(|&p| p == to).into_iter().collect() + } else { + (0..parties.len()).filter(|&r| r != sender_idx).collect() + }; + + for recip_idx in recipients { + let mut buf = if routed[recip_idx].is_empty() { + TssBuffer::empty() + } else { + TssBuffer::from_vec(std::mem::take(&mut routed[recip_idx])) + }; + assert_eq!( + tss_message_build(&mut buf, from, to, data.data, data.len), + TSS_OK + ); + routed[recip_idx] = take_buffer(&mut buf); + } + } + } + routed +} + +/// Run a DKLs23 signing session with an explicit quorum: each entry pairs a +/// party id with that party's key share handle. +pub fn run_dkls_sign_ids(signers: &[(u16, TssHandle)], message_hash: &[u8]) -> Vec { + let n = signers.len(); + let ids: Vec = signers.iter().map(|(id, _)| *id).collect(); + let mut sessions = Vec::new(); + let mut pending = vec![Vec::new(); n]; + let sign_id = b"ffi-test-sign-id"; + + for (i, (self_id, key)) in signers.iter().enumerate() { + let counterparties: Vec = ids.iter().copied().filter(|id| id != self_id).collect(); + let mut session = 0; + let mut out_messages = TssBuffer::empty(); + assert_eq!( + tss_sign_new( + *key, + slice(message_hash), + counterparties.as_ptr(), + counterparties.len(), + sign_id.as_ptr(), + sign_id.len(), + &mut session, + &mut out_messages, + ), + TSS_OK, + ); + sessions.push(session); + pending[i] = take_buffer(&mut out_messages); + } + + for _ in 0..8 { + let routed = route_messages_ids(&pending, &ids); + pending = vec![Vec::new(); n]; + + for (i, session) in sessions.iter().enumerate() { + let mut signature = TssBuffer::empty(); + let mut next_messages = TssBuffer::empty(); + let mut complete = false; + assert_eq!( + tss_sign_next( + *session, + slice(&routed[i]), + &mut signature, + &mut next_messages, + &mut complete, + ), + TSS_OK, + ); + + if complete { + let sig = take_buffer(&mut signature); + let _ = take_buffer(&mut next_messages); + for s in &sessions { + tss_session_free(*s); + } + return sig; + } + + pending[i] = take_buffer(&mut next_messages); + let _ = take_buffer(&mut signature); + } + } + + panic!("signing did not complete"); +} + +/// Run a DKLs23 complete refresh through the FFI over every key in `keys` +/// (`keys[i]` belongs to party `i + 1`) and return the refreshed handles in +/// the same order. The input handles stay valid. +pub fn run_dkls_refresh(keys: &[TssHandle], session_id: &[u8]) -> Vec { + let n = keys.len(); + let mut sessions = Vec::new(); + let mut pending = vec![Vec::new(); n]; + let mut refreshed = vec![0u64; n]; + + for (i, key) in keys.iter().enumerate() { + let mut session = 0; + let mut messages = TssBuffer::empty(); + assert_eq!( + tss_refresh_new_dkls( + *key, + session_id.as_ptr(), + session_id.len(), + &mut session, + &mut messages, + ), + TSS_OK, + ); + sessions.push(session); + pending[i] = take_buffer(&mut messages); + } + + for _ in 0..8 { + if refreshed.iter().all(|h| *h != 0) { + break; + } + + let routed = route_messages(&pending, n); + pending = vec![Vec::new(); n]; + + for (index, session) in sessions.iter().enumerate() { + if refreshed[index] != 0 { + continue; + } + + let mut key_share = 0; + let mut pubkey_package = TssBuffer::empty(); + let mut out_messages = TssBuffer::empty(); + let mut complete = false; + assert_eq!( + tss_refresh_next( + *session, + slice(&routed[index]), + &mut key_share, + &mut pubkey_package, + &mut out_messages, + &mut complete, + ), + TSS_OK, + ); + + let _ = take_buffer(&mut pubkey_package); + if complete { + refreshed[index] = key_share; + let _ = take_buffer(&mut out_messages); + } else { + pending[index] = take_buffer(&mut out_messages); + } + } + } + + assert!( + refreshed.iter().all(|h| *h != 0), + "refresh did not complete" + ); + for session in sessions { + tss_session_free(session); + } + refreshed +} diff --git a/libtss-ffi/tests/derive_test.rs b/libtss-ffi/tests/derive_test.rs index 0c411e6..a589f47 100644 --- a/libtss-ffi/tests/derive_test.rs +++ b/libtss-ffi/tests/derive_test.rs @@ -1,6 +1,14 @@ -//! FFI-level tests for BIP-32 key derivation (tss_derive_child, tss_derive_path). +//! FFI-level tests for BIP-32 key derivation (tss_derive_child, tss_derive_path) +//! and the chain code accessor (tss_handle_chain_code). +mod common; + +use hmac::{Hmac, Mac}; +use k256::elliptic_curve::ops::Reduce; +use k256::elliptic_curve::sec1::ToSec1Point; +use k256::{FieldBytes, ProjectivePoint, PublicKey, Scalar}; use libtss_ffi::types::*; +use sha2::{Digest, Sha256, Sha512}; use std::ffi::CString; /// Helper: generate a FROST Taproot 2-of-3 dealer set and return the first handle. @@ -38,6 +46,59 @@ fn group_key(handle: TssHandle) -> Vec { bytes } +/// Helper: read the chain code for a handle. +fn chain_code(handle: TssHandle) -> Vec { + let mut buf = TssBuffer::empty(); + let status = libtss_ffi::handles::tss_handle_chain_code(handle, &mut buf as *mut TssBuffer); + assert_eq!(status, TSS_OK, "tss_handle_chain_code failed"); + common::take_buffer(&mut buf) +} + +/// Helper: generate an Ed25519 2-of-3 dealer set (no BIP-32 support) and return all handles. +fn ed25519_handles() -> Vec { + let mut handles = [0u64; 3]; + let mut count: usize = 0; + let mut pkg = TssBuffer::empty(); + + let status = libtss_ffi::frost_ops::tss_frost_generate_dealer( + common::SUITE_ED25519, + 3, + 2, + handles.as_mut_ptr(), + &mut count as *mut usize, + &mut pkg as *mut TssBuffer, + ); + assert_eq!(status, TSS_OK); + libtss_ffi::memory::tss_buffer_free(&mut pkg); + handles[..count].to_vec() +} + +/// BIP-32 CKDpub computed independently of libtss: returns the child public +/// key (compressed SEC1) and the child chain code (`IR`). +fn bip32_ckd_pub( + parent_key: &[u8], + parent_chain_code: &[u8], + child_number: u32, +) -> (Vec, Vec) { + let mut mac = Hmac::::new_from_slice(parent_chain_code).unwrap(); + mac.update(parent_key); + mac.update(&child_number.to_be_bytes()); + let output = mac.finalize().into_bytes(); + let (il, ir) = output.split_at(32); + + let il_bytes: &FieldBytes = il.try_into().unwrap(); + let tweak = >::reduce(il_bytes); + let parent_point = PublicKey::from_sec1_bytes(parent_key) + .unwrap() + .to_projective(); + let child_point = (parent_point + ProjectivePoint::GENERATOR * tweak).to_affine(); + + ( + child_point.to_sec1_point(true).as_bytes().to_vec(), + ir.to_vec(), + ) +} + #[test] fn derive_child_taproot_produces_new_key() { let parent = taproot_handle(); @@ -163,3 +224,74 @@ fn derive_null_pointer_rejected() { libtss_ffi::memory::tss_handle_free(parent); } + +#[test] +fn handle_chain_code_taproot_is_sha256_of_group_key_and_changes_on_derive() { + let parent = taproot_handle(); + let parent_chain_code = chain_code(parent); + assert_eq!(parent_chain_code.len(), 32); + assert_eq!( + parent_chain_code, + Sha256::digest(group_key(parent)).to_vec() + ); + + let mut child: TssHandle = 0; + let status = libtss_ffi::derive::tss_derive_child(parent, 7, &mut child as *mut TssHandle); + assert_eq!(status, TSS_OK); + assert_ne!(chain_code(child), parent_chain_code); + + libtss_ffi::memory::tss_handle_free(child); + libtss_ffi::memory::tss_handle_free(parent); +} + +#[test] +fn handle_chain_code_unsupported_suite_rejected() { + let handles = ed25519_handles(); + + let mut buf = TssBuffer::empty(); + let status = libtss_ffi::handles::tss_handle_chain_code(handles[0], &mut buf as *mut TssBuffer); + assert_eq!(status, TSS_ERR_PROTO_MISMATCH); + assert!(buf.data.is_null(), "no buffer must be allocated on failure"); + + for h in handles { + libtss_ffi::memory::tss_handle_free(h); + } +} + +#[test] +fn handle_chain_code_null_out_rejected() { + let parent = taproot_handle(); + let status = libtss_ffi::handles::tss_handle_chain_code(parent, std::ptr::null_mut()); + assert_eq!(status, TSS_ERR_INVALID_CONFIG); + libtss_ffi::memory::tss_handle_free(parent); +} + +#[test] +fn handle_chain_code_invalid_handle_rejected() { + let mut buf = TssBuffer::empty(); + let status = libtss_ffi::handles::tss_handle_chain_code(u64::MAX, &mut buf as *mut TssBuffer); + assert_eq!(status, TSS_ERR_HANDLE_INVALID); +} + +#[test] +fn handle_chain_code_dkls_equal_across_parties_and_matches_bip32() { + let keys = common::run_dkg(common::SUITE_SECP256K1_ECDSA, 2, 2); + + let parent_chain_code = chain_code(keys[0]); + assert_eq!(parent_chain_code.len(), 32); + assert_eq!(parent_chain_code, chain_code(keys[1])); + + let mut child: TssHandle = 0; + let status = libtss_ffi::derive::tss_derive_child(keys[0], 3, &mut child as *mut TssHandle); + assert_eq!(status, TSS_OK); + + let (expected_child_key, expected_child_chain_code) = + bip32_ckd_pub(&group_key(keys[0]), &parent_chain_code, 3); + assert_eq!(group_key(child), expected_child_key); + assert_eq!(chain_code(child), expected_child_chain_code); + + libtss_ffi::memory::tss_handle_free(child); + for key in keys { + libtss_ffi::memory::tss_handle_free(key); + } +} diff --git a/libtss-ffi/tests/memory_hardening_ffi.rs b/libtss-ffi/tests/memory_hardening_ffi.rs index fc5e05f..16c93a2 100644 --- a/libtss-ffi/tests/memory_hardening_ffi.rs +++ b/libtss-ffi/tests/memory_hardening_ffi.rs @@ -114,8 +114,7 @@ fn init_with_mlock_succeeds_or_fails_gracefully() { let err_len = libtss_ffi::error::tss_last_error_len(); assert!( err_len > 0, - "tss_init failed with status {} but set no error message", - status + "tss_init failed with status {status} but set no error message" ); let ptr = libtss_ffi::error::tss_last_error(); let msg = unsafe { std::ffi::CStr::from_ptr(ptr) } diff --git a/libtss-ffi/tests/refresh_repair_test.rs b/libtss-ffi/tests/refresh_repair_test.rs new file mode 100644 index 0000000..62c52c1 --- /dev/null +++ b/libtss-ffi/tests/refresh_repair_test.rs @@ -0,0 +1,287 @@ +//! FFI-level tests for DKLs23 share repair (tss_repair_helper_term, +//! tss_repair_assemble) and complete refresh (tss_refresh_new_dkls). + +mod common; + +use libtss_ffi::handles::{ + tss_handle_chain_code, tss_handle_group_key, tss_handle_identifier, tss_handle_pubkey_package, + tss_handle_verifying_share, +}; +use libtss_ffi::memory::tss_handle_free; +use libtss_ffi::repair::{tss_repair_assemble, tss_repair_helper_term}; +use libtss_ffi::session::{tss_refresh_new, tss_refresh_new_dkls, tss_sign_new}; +use libtss_ffi::tss_verify; +use libtss_ffi::types::*; +use sha2::{Digest, Sha256}; + +const REPAIR_SID: &[u8] = b"ffi-repair-sid"; +const REFRESH_SID: &[u8] = b"ffi-refresh-sid"; + +fn buffer_of(f: impl FnOnce(*mut TssBuffer) -> TssStatus) -> Vec { + let mut buf = TssBuffer::empty(); + assert_eq!(f(&mut buf as *mut TssBuffer), TSS_OK); + common::take_buffer(&mut buf) +} + +fn group_key(handle: TssHandle) -> Vec { + buffer_of(|out| tss_handle_group_key(handle, out)) +} + +fn verifying_share(handle: TssHandle) -> Vec { + buffer_of(|out| tss_handle_verifying_share(handle, out)) +} + +fn chain_code(handle: TssHandle) -> Vec { + buffer_of(|out| tss_handle_chain_code(handle, out)) +} + +fn pubkey_package(handle: TssHandle) -> Vec { + buffer_of(|out| tss_handle_pubkey_package(handle, out)) +} + +fn identifier(handle: TssHandle) -> u16 { + let mut id = 0u16; + assert_eq!(tss_handle_identifier(handle, &mut id), TSS_OK); + id +} + +fn helper_term(key: TssHandle, helpers: &[u16], target: u16, sid: &[u8]) -> (TssStatus, Vec) { + let mut out = TssBuffer::empty(); + let status = tss_repair_helper_term( + key, + helpers.as_ptr(), + helpers.len(), + target, + sid.as_ptr(), + sid.len(), + &mut out, + ); + (status, common::take_buffer(&mut out)) +} + +fn assemble( + package: &[u8], + chain: &[u8], + target: u16, + terms: &[Vec], + sid: &[u8], +) -> (TssStatus, TssHandle) { + let slices: Vec = terms.iter().map(|t| common::slice(t)).collect(); + let mut out: TssHandle = 0; + let status = tss_repair_assemble( + common::SUITE_SECP256K1_ECDSA, + common::slice(package), + common::slice(chain), + target, + slices.as_ptr(), + slices.len(), + sid.as_ptr(), + sid.len(), + &mut out, + ); + (status, out) +} + +fn repair_terms(keys: &[TssHandle], helpers: &[u16], target: u16, sid: &[u8]) -> Vec> { + helpers + .iter() + .map(|helper| { + let (status, term) = helper_term(keys[usize::from(*helper) - 1], helpers, target, sid); + assert_eq!(status, TSS_OK, "helper {helper}"); + assert_eq!(term.len(), 32); + term + }) + .collect() +} + +#[test] +fn repair_then_refresh_signs_via_ffi() { + let keys = common::run_dkg(common::SUITE_SECP256K1_ECDSA, 5, 3); + let lost = keys[1]; + let expected_share = verifying_share(lost); + let expected_group_key = group_key(lost); + let expected_chain_code = chain_code(lost); + tss_handle_free(lost); + + let helpers = [1u16, 3, 4]; + let terms = repair_terms(&keys, &helpers, 2, REPAIR_SID); + let (status, hollow) = assemble( + &pubkey_package(keys[0]), + &expected_chain_code, + 2, + &terms, + REPAIR_SID, + ); + assert_eq!(status, TSS_OK); + assert_eq!(identifier(hollow), 2); + assert_eq!(verifying_share(hollow), expected_share); + assert_eq!(group_key(hollow), expected_group_key); + assert_eq!(chain_code(hollow), expected_chain_code); + + let hash = Sha256::digest(b"hollow").to_vec(); + let counterparties = [1u16, 3]; + let mut session = 0; + let mut messages = TssBuffer::empty(); + assert_eq!( + tss_sign_new( + hollow, + common::slice(&hash), + counterparties.as_ptr(), + counterparties.len(), + REPAIR_SID.as_ptr(), + REPAIR_SID.len(), + &mut session, + &mut messages, + ), + TSS_ERR_INVALID_SHARE + ); + + let mut group = keys.clone(); + group[1] = hollow; + let refreshed = common::run_dkls_refresh(&group, REFRESH_SID); + + for (index, key) in refreshed.iter().enumerate() { + assert_eq!(identifier(*key), index as u16 + 1); + assert_eq!(group_key(*key), expected_group_key); + assert_eq!(chain_code(*key), expected_chain_code); + assert_ne!(verifying_share(*key), verifying_share(group[index])); + } + + let message = b"after refresh"; + let hash = Sha256::digest(message).to_vec(); + for quorum in [[1u16, 2, 3], [2, 4, 5]] { + let signers: Vec<(u16, TssHandle)> = quorum + .iter() + .map(|id| (*id, refreshed[usize::from(*id) - 1])) + .collect(); + let signature = common::run_dkls_sign_ids(&signers, &hash); + assert!(tss_verify( + common::SUITE_SECP256K1_ECDSA, + common::slice(message), + common::slice(&signature), + common::slice(&expected_group_key), + )); + } + + for key in group.iter().chain(refreshed.iter()) { + tss_handle_free(*key); + } +} + +#[test] +fn repair_helper_term_rejects_bad_inputs() { + let keys = common::run_dkg(common::SUITE_SECP256K1_ECDSA, 3, 2); + + let (status, _) = helper_term(keys[0], &[1, 3], 3, REPAIR_SID); + assert_eq!(status, TSS_ERR_INVALID_CONFIG, "helpers contain target"); + + let (status, _) = helper_term(keys[0], &[1], 2, REPAIR_SID); + assert_eq!(status, TSS_ERR_INVALID_CONFIG, "too few helpers"); + + let (status, _) = helper_term(keys[0], &[1, 3], 2, b""); + assert_eq!(status, TSS_ERR_INVALID_CONFIG, "empty session id"); + + let (status, _) = helper_term(keys[0], &[1, 4], 2, REPAIR_SID); + assert_eq!(status, TSS_ERR_INVALID_ID, "helper outside the group"); + + let (status, _) = helper_term(keys[0], &[0, 3], 2, REPAIR_SID); + assert_eq!(status, TSS_ERR_INVALID_ID, "zero identifier"); + + let (status, _) = helper_term(0, &[1, 3], 2, REPAIR_SID); + assert_eq!(status, TSS_ERR_HANDLE_INVALID); + + let frost = common::run_dkg(common::SUITE_ED25519, 3, 2); + let (status, _) = helper_term(frost[0], &[1, 3], 2, REPAIR_SID); + assert_eq!(status, TSS_ERR_PROTO_MISMATCH); + + for key in keys.iter().chain(frost.iter()) { + tss_handle_free(*key); + } +} + +#[test] +fn repair_assemble_fails_closed() { + let keys = common::run_dkg(common::SUITE_SECP256K1_ECDSA, 3, 2); + let package = pubkey_package(keys[0]); + let chain = chain_code(keys[0]); + let helpers = [1u16, 3]; + let terms = repair_terms(&keys, &helpers, 2, REPAIR_SID); + + let mut tampered = terms.clone(); + tampered[0][0] ^= 0x01; + let (status, handle) = assemble(&package, &chain, 2, &tampered, REPAIR_SID); + assert_eq!(status, TSS_ERR_INVALID_SHARE); + assert_eq!(handle, 0); + + let (status, _) = assemble(&package, &chain, 2, &terms[..1], REPAIR_SID); + assert_eq!(status, TSS_ERR_INVALID_CONFIG, "too few terms"); + + let (status, _) = assemble(&package, &chain[..31], 2, &terms, REPAIR_SID); + assert_eq!(status, TSS_ERR_INVALID_CONFIG, "short chain code"); + + let (status, _) = assemble(&package, &chain, 2, &terms, b""); + assert_eq!(status, TSS_ERR_INVALID_CONFIG, "empty session id"); + + let (status, _) = assemble(&package, &chain, 3, &terms, REPAIR_SID); + assert_eq!(status, TSS_ERR_INVALID_SHARE, "terms for another target"); + + let (status, _) = assemble(&package[..package.len() - 1], &chain, 2, &terms, REPAIR_SID); + assert_eq!(status, TSS_ERR_DESERIALIZE, "truncated package"); + + let frost = common::run_dkg(common::SUITE_ED25519, 3, 2); + let (status, _) = assemble(&pubkey_package(frost[0]), &chain, 2, &terms, REPAIR_SID); + assert_eq!(status, TSS_ERR_PROTO_MISMATCH); + + let (status, hollow) = assemble(&package, &chain, 2, &terms, REPAIR_SID); + assert_eq!(status, TSS_OK); + let (status, _) = helper_term(hollow, &[2, 3], 1, REPAIR_SID); + assert_eq!(status, TSS_ERR_INVALID_SHARE, "hollow share cannot help"); + + tss_handle_free(hollow); + for key in keys.iter().chain(frost.iter()) { + tss_handle_free(*key); + } +} + +#[test] +fn refresh_new_dkls_rejects_wrong_inputs() { + let keys = common::run_dkg(common::SUITE_SECP256K1_ECDSA, 2, 2); + let mut session = 0; + let mut messages = TssBuffer::empty(); + + assert_eq!( + tss_refresh_new_dkls(keys[0], std::ptr::null(), 0, &mut session, &mut messages), + TSS_ERR_INVALID_CONFIG + ); + assert_eq!( + tss_refresh_new_dkls( + 0, + REFRESH_SID.as_ptr(), + REFRESH_SID.len(), + &mut session, + &mut messages + ), + TSS_ERR_HANDLE_INVALID + ); + assert_eq!( + tss_refresh_new(keys[0], std::ptr::null(), 0, &mut session, &mut messages), + TSS_ERR_PROTO_MISMATCH, + "the FROST refresh entry point still refuses DKLs23 shares" + ); + + let frost = common::run_dkg(common::SUITE_ED25519, 3, 2); + assert_eq!( + tss_refresh_new_dkls( + frost[0], + REFRESH_SID.as_ptr(), + REFRESH_SID.len(), + &mut session, + &mut messages + ), + TSS_ERR_PROTO_MISMATCH + ); + + for key in keys.iter().chain(frost.iter()) { + tss_handle_free(*key); + } +} diff --git a/libtss-go/tss/handle.go b/libtss-go/tss/handle.go index de864df..4dd276b 100644 --- a/libtss-go/tss/handle.go +++ b/libtss-go/tss/handle.go @@ -118,18 +118,51 @@ func (h *KeyShareHandle) GroupKey() ([]byte, error) { return bufferToBytes(&out), nil } -func (h *KeyShareHandle) PublicKeyPackage() (PublicKeyPackage, error) { +// ChainCode returns the 32-byte BIP-32 chain code this share derives children +// with. Pair it with GroupKey to build a BIP-32 xpub whose off-MPC pubkey +// derivation matches the child shares each party derives via DeriveChild / +// DerivePath. +// +// Supported for the BIP-32-capable ciphersuites only (Secp256k1ECDSA, +// Secp256r1ECDSA, Secp256k1Taproot); every other suite returns a *TssError +// with the protocol-mismatch code. +func (h *KeyShareHandle) ChainCode() ([]byte, error) { handle, err := h.native.value() + if err != nil { + return nil, err + } + var out C.struct_TssBuffer + if err := callStatus(func() C.TssStatus { + return C.tss_handle_chain_code(handle, &out) + }); err != nil { + return nil, err + } + return bufferToBytes(&out), nil +} + +func (h *KeyShareHandle) PublicKeyPackage() (PublicKeyPackage, error) { + raw, err := h.PublicKeyPackageBytes() if err != nil { return PublicKeyPackage{}, err } + return decodePublicKeyPackage(raw) +} + +// PublicKeyPackageBytes returns the group's public key package in libtss's +// serialized form, the encoding RepairAssemble and FrostRepairPart3 accept. +// Use PublicKeyPackage for the decoded view. +func (h *KeyShareHandle) PublicKeyPackageBytes() ([]byte, error) { + handle, err := h.native.value() + if err != nil { + return nil, err + } var out C.struct_TssBuffer if err := callStatus(func() C.TssStatus { return C.tss_handle_pubkey_package(handle, &out) }); err != nil { - return PublicKeyPackage{}, err + return nil, err } - return decodePublicKeyPackage(bufferToBytes(&out)) + return bufferToBytes(&out), nil } func (h *KeyShareHandle) Ciphersuite() (Ciphersuite, error) { diff --git a/libtss-go/tss/repair.go b/libtss-go/tss/repair.go new file mode 100644 index 0000000..381c317 --- /dev/null +++ b/libtss-go/tss/repair.go @@ -0,0 +1,93 @@ +package tss + +/* +#include "tss_ffi.h" +*/ +import "C" + +import ( + "fmt" + "unsafe" +) + +// RepairHelperTerm computes this party's masked repair term for the lost +// share of target. +// +// helpers is the full helper set (this party included, target excluded) and +// every helper must pass the identical set and sessionID, or the pairwise +// masks do not cancel and RepairAssemble fails closed. The returned 32-byte +// scalar is meant for the target only; no proper subset of the helpers' terms +// reveals anything about the lost share. A hollow share (repaired but not +// yet refreshed) is refused with the invalid-share code rather than emitting +// an unmasked term. +func RepairHelperTerm(keyShare *KeyShareHandle, helpers []Identifier, target Identifier, sessionID []byte) ([]byte, error) { + if keyShare == nil { + return nil, fmt.Errorf("keyShare is nil") + } + if len(helpers) == 0 { + return nil, fmt.Errorf("helpers must not be empty") + } + if len(sessionID) == 0 { + return nil, fmt.Errorf("sessionID must not be empty") + } + handle, err := keyShare.native.value() + if err != nil { + return nil, err + } + helpersPtr := (*C.uint16_t)(unsafe.Pointer(&helpers[0])) + sidPtr := (*C.uint8_t)(unsafe.Pointer(&sessionID[0])) + var out C.struct_TssBuffer + if err := callStatus(func() C.TssStatus { + return C.tss_repair_helper_term( + handle, + helpersPtr, + C.uintptr_t(len(helpers)), + C.uint16_t(target), + sidPtr, + C.uintptr_t(len(sessionID)), + &out, + ) + }); err != nil { + return nil, err + } + return bufferToBytes(&out), nil +} + +// RepairAssemble rebuilds the lost share of target from the helpers' terms +// as a hollow key share. +// +// pubkeyPackage is the serialized group package (KeyShareHandle.PublicKeyPackageBytes) +// and chainCode the 32-byte BIP-32 chain code, as every helper reports them; +// terms holds at least the threshold number of RepairHelperTerm outputs. The +// sum of the terms is checked against the target's verifying share and a +// mismatch fails with the invalid-share code without creating a handle. The +// result derives addresses and refreshes but cannot sign until a complete +// refresh (NewRefreshSessionDKLs) has run across the whole group. +func RepairAssemble(suite Ciphersuite, pubkeyPackage, chainCode []byte, target Identifier, terms [][]byte, sessionID []byte) (*KeyShareHandle, error) { + if len(terms) == 0 { + return nil, fmt.Errorf("terms must not be empty") + } + if len(sessionID) == 0 { + return nil, fmt.Errorf("sessionID must not be empty") + } + cSlices, cleanup := buildCSliceArray(terms) + defer cleanup() + sidPtr := (*C.uint8_t)(unsafe.Pointer(&sessionID[0])) + var out C.TssHandle + if err := callStatus(func() C.TssStatus { + return C.tss_repair_assemble( + C.uint8_t(suite), + bytesToSlice(pubkeyPackage), + bytesToSlice(chainCode), + C.uint16_t(target), + cSlices, + C.uintptr_t(len(terms)), + sidPtr, + C.uintptr_t(len(sessionID)), + &out, + ) + }); err != nil { + return nil, err + } + return newKeyShareHandle(out), nil +} diff --git a/libtss-go/tss/repair_test.go b/libtss-go/tss/repair_test.go new file mode 100644 index 0000000..c989d08 --- /dev/null +++ b/libtss-go/tss/repair_test.go @@ -0,0 +1,211 @@ +package tss + +import ( + "bytes" + "crypto/sha256" + "errors" + "testing" +) + +// Mirrors of the C status codes; cgo constants are not reachable from _test.go files. +const ( + tssErrInvalidConfigCode = 1 + tssErrInvalidIDCode = 2 + tssErrInvalidShareCode = 3 + tssErrDeserializeCode = 9 +) + +var ( + repairSessionID = []byte("go-repair-sid") + refreshSessionID = []byte("go-refresh-sid") +) + +func repairTerms(t *testing.T, shares []*KeyShareHandle, helpers []Identifier, target Identifier, sessionID []byte) [][]byte { + t.Helper() + terms := make([][]byte, len(helpers)) + for i, helper := range helpers { + term, err := RepairHelperTerm(shares[helper-1], helpers, target, sessionID) + if err != nil { + t.Fatalf("RepairHelperTerm(helper %d): %v", helper, err) + } + if len(term) != 32 { + t.Fatalf("term from helper %d is %d bytes, want 32", helper, len(term)) + } + terms[i] = term + } + return terms +} + +func assertTssCode(t *testing.T, err error, code int32, context string) { + t.Helper() + var tssErr *TssError + if !errors.As(err, &tssErr) { + t.Fatalf("%s: expected *TssError, got %T (%v)", context, err, err) + } + if tssErr.Code != code { + t.Fatalf("%s: code %d (%s), want %d", context, tssErr.Code, tssErr.Message, code) + } +} + +func TestDKLs23RepairAndRefresh(t *testing.T) { + shares, pubkeys := runDKG(t, CiphersuiteSecp256k1ECDSA, 5, 3, []byte("repair-dkg")) + const target Identifier = 2 + + lostShare, err := shares[target-1].VerifyingShare() + if err != nil { + t.Fatalf("VerifyingShare: %v", err) + } + chainCode, err := shares[0].ChainCode() + if err != nil { + t.Fatalf("ChainCode: %v", err) + } + packageBytes, err := shares[0].PublicKeyPackageBytes() + if err != nil { + t.Fatalf("PublicKeyPackageBytes: %v", err) + } + shares[target-1].Free() + + helpers := []Identifier{1, 3, 4} + terms := repairTerms(t, shares, helpers, target, repairSessionID) + hollow, err := RepairAssemble(CiphersuiteSecp256k1ECDSA, packageBytes, chainCode, target, terms, repairSessionID) + if err != nil { + t.Fatalf("RepairAssemble: %v", err) + } + defer hollow.Free() + + if id, err := hollow.Identifier(); err != nil || id != target { + t.Fatalf("hollow identifier = %d, %v; want %d", id, err, target) + } + if got, err := hollow.VerifyingShare(); err != nil || !bytes.Equal(got, lostShare) { + t.Fatalf("hollow verifying share mismatch (err %v)", err) + } + if got, err := hollow.GroupKey(); err != nil || !bytes.Equal(got, pubkeys.VerifyingKey) { + t.Fatalf("hollow group key mismatch (err %v)", err) + } + if got, err := hollow.ChainCode(); err != nil || !bytes.Equal(got, chainCode) { + t.Fatalf("hollow chain code mismatch (err %v)", err) + } + + hash := sha256.Sum256([]byte("hollow")) + _, _, err = NewSignSession(hollow, hash[:], []Identifier{1, 3}, []byte("sign")) + assertTssCode(t, err, tssErrInvalidShareCode, "signing with a hollow share") + + _, err = RepairHelperTerm(hollow, []Identifier{2, 3, 4}, 1, repairSessionID) + assertTssCode(t, err, tssErrInvalidShareCode, "helping with a hollow share") + + group := append([]*KeyShareHandle(nil), shares...) + group[target-1] = hollow + refreshed := runDKLsRefresh(t, group, refreshSessionID) + for i, share := range refreshed { + defer share.Free() + id, err := share.Identifier() + if err != nil || id != Identifier(i+1) { + t.Fatalf("refreshed identifier %d = %d, %v", i+1, id, err) + } + groupKey, err := share.GroupKey() + if err != nil || !bytes.Equal(groupKey, pubkeys.VerifyingKey) { + t.Fatalf("refreshed share %d changed the group key (err %v)", i+1, err) + } + got, err := share.ChainCode() + if err != nil || !bytes.Equal(got, chainCode) { + t.Fatalf("refreshed share %d changed the chain code (err %v)", i+1, err) + } + before, err := group[i].VerifyingShare() + if err != nil { + t.Fatalf("VerifyingShare(before %d): %v", i+1, err) + } + after, err := share.VerifyingShare() + if err != nil { + t.Fatalf("VerifyingShare(after %d): %v", i+1, err) + } + if bytes.Equal(before, after) { + t.Fatalf("refresh did not move share %d", i+1) + } + } + + msg := []byte("after repair and refresh") + digest := sha256.Sum256(msg) + for _, quorum := range [][]Identifier{{1, 2, 3}, {2, 4, 5}} { + signers := make([]*KeyShareHandle, len(quorum)) + for i, id := range quorum { + signers[i] = refreshed[id-1] + } + sig := runSignRound(t, CiphersuiteSecp256k1ECDSA, signers, digest[:]) + valid, err := Verify(CiphersuiteSecp256k1ECDSA, msg, sig.Data, pubkeys.VerifyingKey) + if err != nil || !valid { + t.Fatalf("quorum %v: signature invalid (err %v)", quorum, err) + } + } +} + +func TestDKLs23RepairRejectsBadInputs(t *testing.T) { + shares, _ := runDKG(t, CiphersuiteSecp256k1ECDSA, 3, 2, []byte("repair-negatives")) + for _, share := range shares { + defer share.Free() + } + chainCode, err := shares[0].ChainCode() + if err != nil { + t.Fatalf("ChainCode: %v", err) + } + packageBytes, err := shares[0].PublicKeyPackageBytes() + if err != nil { + t.Fatalf("PublicKeyPackageBytes: %v", err) + } + helpers := []Identifier{1, 3} + terms := repairTerms(t, shares, helpers, 2, repairSessionID) + + _, err = RepairHelperTerm(shares[0], []Identifier{1, 2}, 2, repairSessionID) + assertTssCode(t, err, tssErrInvalidConfigCode, "helpers containing the target") + _, err = RepairHelperTerm(shares[0], []Identifier{1}, 2, repairSessionID) + assertTssCode(t, err, tssErrInvalidConfigCode, "too few helpers") + _, err = RepairHelperTerm(shares[0], []Identifier{1, 4}, 2, repairSessionID) + assertTssCode(t, err, tssErrInvalidIDCode, "helper outside the group") + if _, err := RepairHelperTerm(shares[0], helpers, 2, nil); err == nil { + t.Fatal("RepairHelperTerm accepted an empty session id") + } + if _, err := RepairHelperTerm(nil, helpers, 2, repairSessionID); err == nil { + t.Fatal("RepairHelperTerm accepted a nil share") + } + + tampered := [][]byte{append([]byte(nil), terms[0]...), terms[1]} + tampered[0][31] ^= 1 + _, err = RepairAssemble(CiphersuiteSecp256k1ECDSA, packageBytes, chainCode, 2, tampered, repairSessionID) + assertTssCode(t, err, tssErrInvalidShareCode, "tampered term") + + _, err = RepairAssemble(CiphersuiteSecp256k1ECDSA, packageBytes, chainCode, 2, terms[:1], repairSessionID) + assertTssCode(t, err, tssErrInvalidConfigCode, "too few terms") + + _, err = RepairAssemble(CiphersuiteSecp256k1ECDSA, packageBytes, chainCode[:31], 2, terms, repairSessionID) + assertTssCode(t, err, tssErrInvalidConfigCode, "short chain code") + + _, err = RepairAssemble(CiphersuiteSecp256k1ECDSA, packageBytes, chainCode, 3, terms, repairSessionID) + assertTssCode(t, err, tssErrInvalidShareCode, "terms for another target") + + _, err = RepairAssemble(CiphersuiteSecp256k1ECDSA, packageBytes[:len(packageBytes)-1], chainCode, 2, terms, repairSessionID) + assertTssCode(t, err, tssErrDeserializeCode, "truncated package") + + otherSID := repairTerms(t, shares, helpers, 2, []byte("other-sid")) + mixed := [][]byte{terms[0], otherSID[1]} + _, err = RepairAssemble(CiphersuiteSecp256k1ECDSA, packageBytes, chainCode, 2, mixed, repairSessionID) + assertTssCode(t, err, tssErrInvalidShareCode, "terms from different repair sessions") + + if _, err := RepairAssemble(CiphersuiteSecp256k1ECDSA, packageBytes, chainCode, 2, terms, nil); err == nil { + t.Fatal("RepairAssemble accepted an empty session id") + } + if _, err := RepairAssemble(CiphersuiteSecp256k1ECDSA, packageBytes, chainCode, 2, nil, repairSessionID); err == nil { + t.Fatal("RepairAssemble accepted no terms") + } + + frost, _ := runDKG(t, CiphersuiteEd25519, 3, 2, nil) + for _, share := range frost { + defer share.Free() + } + _, err = RepairHelperTerm(frost[0], helpers, 2, repairSessionID) + assertTssCode(t, err, tssErrProtoMismatchCode, "FROST share as helper") + frostPackage, err := frost[0].PublicKeyPackageBytes() + if err != nil { + t.Fatalf("PublicKeyPackageBytes(frost): %v", err) + } + _, err = RepairAssemble(CiphersuiteSecp256k1ECDSA, frostPackage, chainCode, 2, terms, repairSessionID) + assertTssCode(t, err, tssErrProtoMismatchCode, "FROST package") +} diff --git a/libtss-go/tss/session.go b/libtss-go/tss/session.go index 3ded7e9..2bc50da 100644 --- a/libtss-go/tss/session.go +++ b/libtss-go/tss/session.go @@ -6,6 +6,7 @@ package tss import "C" import ( + "fmt" "runtime" "unsafe" ) @@ -259,6 +260,48 @@ func NewRefreshSession(keyShare *KeyShareHandle, participants []Identifier) (*Re return newRefreshSession(native), msgs, nil } +// NewRefreshSessionDKLs starts a DKLs23 complete refresh for a secp256k1 key +// share. +// +// Every party of the group must refresh together under the same fresh +// sessionID: the refreshed shares are salted with it and cannot co-sign with +// shares from any other generation. Next is called three times; the input +// handle stays valid and the completed step carries a new one. The refresh +// rebuilds the OT and zero-share state from scratch, which is what turns a +// hollow share from RepairAssemble into a signing one. +func NewRefreshSessionDKLs(keyShare *KeyShareHandle, sessionID []byte) (*RefreshSession, []Message, error) { + if keyShare == nil { + return nil, nil, fmt.Errorf("keyShare is nil") + } + if len(sessionID) == 0 { + return nil, nil, fmt.Errorf("sessionID must not be empty") + } + handle, err := keyShare.native.value() + if err != nil { + return nil, nil, err + } + var native C.TssHandle + var out C.struct_TssBuffer + sidPtr := (*C.uint8_t)(unsafe.Pointer(&sessionID[0])) + if err := callStatus(func() C.TssStatus { + return C.tss_refresh_new_dkls( + handle, + sidPtr, + C.uintptr_t(len(sessionID)), + &native, + &out, + ) + }); err != nil { + return nil, nil, err + } + msgs, err := ParseMessages(bufferToBytes(&out)) + if err != nil { + C.tss_session_free(native) + return nil, nil, err + } + return newRefreshSession(native), msgs, nil +} + func NewRefreshReceiver(keyShare *KeyShareHandle) (*RefreshSession, error) { if err := checkNonNil("keyShare", keyShare); err != nil { return nil, err diff --git a/libtss-go/tss/tss_test.go b/libtss-go/tss/tss_test.go index 5bf01fa..722e6be 100644 --- a/libtss-go/tss/tss_test.go +++ b/libtss-go/tss/tss_test.go @@ -1,7 +1,11 @@ package tss import ( + "bytes" + "crypto/hmac" "crypto/sha256" + "crypto/sha512" + "encoding/binary" "errors" "testing" ) @@ -240,6 +244,116 @@ func TestFROSTRefreshAndSign(t *testing.T) { } } +func runDKLsRefresh(t *testing.T, shares []*KeyShareHandle, sessionID []byte) []*KeyShareHandle { + t.Helper() + sessions := make([]*RefreshSession, len(shares)) + round := make([][]Message, len(shares)) + ids := make([]Identifier, len(shares)) + for i, share := range shares { + id, err := share.Identifier() + if err != nil { + t.Fatalf("Identifier(%d): %v", i, err) + } + ids[i] = id + session, messages, err := NewRefreshSessionDKLs(share, sessionID) + if err != nil { + t.Fatalf("NewRefreshSessionDKLs(%d): %v", id, err) + } + sessions[i] = session + round[i] = messages + defer session.Free() + } + + refreshed := make([]*KeyShareHandle, len(shares)) + for { + nextRound := make([][]Message, len(shares)) + completed := 0 + for i := range sessions { + step, err := sessions[i].Next(collectPeerMessages(round, i, ids[i])) + if err != nil { + t.Fatalf("Refresh Next(%d): %v", ids[i], err) + } + if step.Complete { + refreshed[i] = step.KeyShare + completed++ + continue + } + nextRound[i] = step.Messages + } + if completed == len(shares) { + return refreshed + } + round = nextRound + } +} + +func TestDKLs23RefreshAndSign(t *testing.T) { + shares, pubkeys := runDKG(t, CiphersuiteSecp256k1ECDSA, 3, 2, []byte("refresh-dkg")) + for _, share := range shares { + defer share.Free() + } + + refreshed := runDKLsRefresh(t, shares, []byte("refresh-sid")) + for i, share := range refreshed { + defer share.Free() + groupKey, err := share.GroupKey() + if err != nil || !bytes.Equal(groupKey, pubkeys.VerifyingKey) { + t.Fatalf("refreshed share %d changed the group key (err %v)", i+1, err) + } + before, err := shares[i].VerifyingShare() + if err != nil { + t.Fatalf("VerifyingShare(before %d): %v", i+1, err) + } + after, err := share.VerifyingShare() + if err != nil { + t.Fatalf("VerifyingShare(after %d): %v", i+1, err) + } + if bytes.Equal(before, after) { + t.Fatalf("refresh did not move share %d", i+1) + } + } + + msg := []byte("dkls refreshed") + digest := sha256.Sum256(msg) + sig := runSignRound(t, CiphersuiteSecp256k1ECDSA, refreshed[1:], digest[:]) + valid, err := Verify(CiphersuiteSecp256k1ECDSA, msg, sig.Data, pubkeys.VerifyingKey) + if err != nil || !valid { + t.Fatalf("refreshed shares produced an invalid signature (err %v)", err) + } + + if _, _, err := NewRefreshSessionDKLs(shares[0], nil); err == nil { + t.Fatal("NewRefreshSessionDKLs accepted an empty session id") + } + frost, _ := runDKG(t, CiphersuiteEd25519, 2, 2, nil) + for _, share := range frost { + defer share.Free() + } + _, _, err = NewRefreshSessionDKLs(frost[0], []byte("refresh-sid")) + var tssErr *TssError + if !errors.As(err, &tssErr) || tssErr.Code != tssErrProtoMismatchCode { + t.Fatalf("NewRefreshSessionDKLs(frost) = %v, want protocol mismatch", err) + } +} + +func TestPublicKeyPackageBytesRoundTrip(t *testing.T) { + shares, pubkeys := runDKG(t, CiphersuiteSecp256k1ECDSA, 2, 2, []byte("package-bytes")) + for _, share := range shares { + defer share.Free() + } + + raw, err := shares[0].PublicKeyPackageBytes() + if err != nil { + t.Fatalf("PublicKeyPackageBytes: %v", err) + } + decoded, err := decodePublicKeyPackage(raw) + if err != nil { + t.Fatalf("decodePublicKeyPackage: %v", err) + } + if !bytes.Equal(decoded.VerifyingKey, pubkeys.VerifyingKey) || decoded.MinSigners != pubkeys.MinSigners { + t.Fatal("serialized package does not match the DKG output") + } +} + func TestNonceReuseAndFreedHandle(t *testing.T) { shares, _ := runDKG(t, CiphersuiteEd25519, 2, 2, nil) for _, share := range shares { @@ -389,3 +503,68 @@ func TestAbortCulpritAndBan(t *testing.T) { roundD = stepC.Messages } } + +// tssErrProtoMismatch mirrors TSS_ERR_PROTO_MISMATCH; cgo constants are not +// reachable from _test.go files. +const tssErrProtoMismatch = 8 + +func TestDKLs23ChainCode(t *testing.T) { + shares, _ := runDKG(t, CiphersuiteSecp256k1ECDSA, 2, 2, []byte("chain-code-session")) + for _, share := range shares { + defer share.Free() + } + + parentChainCode, err := shares[0].ChainCode() + if err != nil { + t.Fatalf("ChainCode: %v", err) + } + if len(parentChainCode) != 32 { + t.Fatalf("chain code length = %d, want 32", len(parentChainCode)) + } + otherChainCode, err := shares[1].ChainCode() + if err != nil { + t.Fatalf("ChainCode (party 2): %v", err) + } + if !bytes.Equal(parentChainCode, otherChainCode) { + t.Fatal("chain code differs across parties") + } + + child, err := DeriveChild(shares[0], 5) + if err != nil { + t.Fatalf("DeriveChild: %v", err) + } + defer child.Free() + + parentKey, err := shares[0].GroupKey() + if err != nil { + t.Fatalf("GroupKey: %v", err) + } + mac := hmac.New(sha512.New, parentChainCode) + mac.Write(parentKey) + mac.Write(binary.BigEndian.AppendUint32(nil, 5)) + expectedChildChainCode := mac.Sum(nil)[32:] + + childChainCode, err := child.ChainCode() + if err != nil { + t.Fatalf("ChainCode (child): %v", err) + } + if !bytes.Equal(childChainCode, expectedChildChainCode) { + t.Fatal("child chain code is not the BIP-32 IR of its parent") + } +} + +func TestChainCodeUnsupportedSuite(t *testing.T) { + shares, _ := runDKG(t, CiphersuiteEd25519, 3, 2, nil) + for _, share := range shares { + defer share.Free() + } + + _, err := shares[0].ChainCode() + var tssErr *TssError + if !errors.As(err, &tssErr) { + t.Fatalf("ChainCode error = %v, want *TssError", err) + } + if tssErr.Code != tssErrProtoMismatch { + t.Fatalf("ChainCode error code = %d, want %d", tssErr.Code, tssErrProtoMismatch) + } +} diff --git a/libtss-node/README.md b/libtss-node/README.md index eb11224..1e43a01 100644 --- a/libtss-node/README.md +++ b/libtss-node/README.md @@ -115,7 +115,10 @@ const [s3, r1_3] = DkgSession.create(config, 3); - **`KeyShareHandle`** — wraps an opaque handle to a participant's key share - `identifier()`, `ciphersuite()`, `protocol()` - - `verifyingShare()`, `groupVerifyingKey()`, `publicKeyPackage()` + - `verifyingShare()`, `groupVerifyingKey()`, `chainCode()`, `publicKeyPackage()` + (`chainCode()` is the 32-byte BIP-32 chain code paired with the group key — + Secp256k1ECDSA, Secp256r1ECDSA and Secp256k1Taproot only; other suites throw + a `TssError` with `TssStatusCode.PROTO_MISMATCH`) - `export()`, `exportSecure()`, `free()` - **`SecureBuffer`** — zeroing buffer allocated outside V8's pooled slab - `bytes()`, `destroy()`, `[Symbol.dispose]()`, `length`, `isDestroyed` @@ -196,10 +199,10 @@ matters. Use **libtss-wasm** for browser or portable environments. ## Testing ```sh -# Unit tests (message codec, error types — no native library needed) -npm test - -# Integration tests require the built native library +# Build the native library first — the KeyShareHandle tests drive a real DKG through it cargo build --release -p libtss-ffi -npm test + +# From libtss-node/, point the binding at the build output (.so on Linux) +cd libtss-node +LIBTSS_LIB="$PWD/../target/release/liblibtss_ffi.dylib" npm test ``` diff --git a/libtss-node/jest.config.js b/libtss-node/jest.config.js index 8d0bcb6..00abcac 100644 --- a/libtss-node/jest.config.js +++ b/libtss-node/jest.config.js @@ -1,4 +1,5 @@ module.exports = { preset: "ts-jest", testEnvironment: "node", + testMatch: ["/src/__tests__/**/*.test.ts"], }; diff --git a/libtss-node/src/__tests__/handle.test.ts b/libtss-node/src/__tests__/handle.test.ts new file mode 100644 index 0000000..d00a7b4 --- /dev/null +++ b/libtss-node/src/__tests__/handle.test.ts @@ -0,0 +1,106 @@ +import { createHmac } from "node:crypto"; +import { TssError } from "../error"; +import { deriveChild, frostSplitKey } from "../index"; +import { Ciphersuite, TssStatusCode } from "../types"; +import { freeAll, runDkg } from "./helpers"; + +const CHAIN_CODE_BYTES = 32; +const CHILD_NUMBER = 7; +const CHILD_NUMBER_BYTES = 4; +const HMAC_SHA512_HALF_BYTES = 32; +const ED25519_SCALAR_BYTES = 32; +// A little-endian scalar whose top byte is below 0x10 is < 2^252 and therefore +// a canonical, non-zero Ed25519 secret. frostSplitKey rejects non-canonical +// scalars, which a random 32-byte string is roughly 15 times out of 16. +const CANONICAL_SCALAR_BYTE = 0x07; + +const TWO_OF_TWO_ECDSA = { + suite: Ciphersuite.Secp256k1ECDSA, + maxSigners: 2, + minSigners: 2, +}; +const TWO_OF_THREE_ED25519 = { + suite: Ciphersuite.Ed25519, + maxSigners: 3, + minSigners: 2, +}; +const SESSION_ID = new TextEncoder().encode("chain-code-session"); + +function bip32ChildChainCode( + parentKey: Uint8Array, + parentChainCode: Uint8Array, + childNumber: number, +): Uint8Array { + const index = new Uint8Array(CHILD_NUMBER_BYTES); + new DataView(index.buffer).setUint32(0, childNumber, false); + const digest = createHmac("sha512", parentChainCode) + .update(parentKey) + .update(index) + .digest(); + return new Uint8Array(digest.subarray(HMAC_SHA512_HALF_BYTES)); +} + +function thrownBy(fn: () => unknown): unknown { + try { + fn(); + } catch (error) { + return error; + } + return undefined; +} + +describe("KeyShareHandle.chainCode", () => { + it("returns the same 32-byte chain code to every DKLs23 party", () => { + const { keyShares } = runDkg(TWO_OF_TWO_ECDSA, SESSION_ID); + try { + const [first, second] = keyShares.map((share) => share.chainCode()); + + expect(first).toHaveLength(CHAIN_CODE_BYTES); + expect(second).toEqual(first); + } finally { + freeAll(keyShares); + } + }); + + it("re-derives the chain code of a child share per BIP-32", () => { + const { keyShares } = runDkg(TWO_OF_TWO_ECDSA, SESSION_ID); + const parent = keyShares[0]; + const child = deriveChild(parent, CHILD_NUMBER); + try { + const parentChainCode = parent.chainCode(); + const childChainCode = child.chainCode(); + + expect(childChainCode).toHaveLength(CHAIN_CODE_BYTES); + expect(childChainCode).not.toEqual(parentChainCode); + expect(childChainCode).toEqual( + bip32ChildChainCode( + parent.groupVerifyingKey(), + parentChainCode, + CHILD_NUMBER, + ), + ); + } finally { + freeAll([child, ...keyShares]); + } + }); + + it("rejects ciphersuites without BIP-32 derivation", () => { + const secretKey = new Uint8Array(ED25519_SCALAR_BYTES).fill( + CANONICAL_SCALAR_BYTE, + ); + const { keyShares } = frostSplitKey( + TWO_OF_THREE_ED25519.suite, + secretKey, + TWO_OF_THREE_ED25519.maxSigners, + TWO_OF_THREE_ED25519.minSigners, + ); + try { + const error = thrownBy(() => keyShares[0].chainCode()); + + expect(error).toBeInstanceOf(TssError); + expect((error as TssError).code).toBe(TssStatusCode.PROTO_MISMATCH); + } finally { + freeAll(keyShares); + } + }); +}); diff --git a/libtss-node/src/__tests__/helpers.ts b/libtss-node/src/__tests__/helpers.ts new file mode 100644 index 0000000..80d5ad7 --- /dev/null +++ b/libtss-node/src/__tests__/helpers.ts @@ -0,0 +1,83 @@ +import type { KeyShareHandle } from "../handle"; +import { DkgSession } from "../session"; +import type { Message, ThresholdConfig } from "../types"; + +const MAX_DKG_ROUNDS = 10; + +export interface DkgResult { + keyShares: KeyShareHandle[]; + publicKeys: Uint8Array; +} + +function collectFor( + participantId: number, + outgoing: Map, +): Message[] { + const inbound: Message[] = []; + for (const [senderId, messages] of outgoing) { + if (senderId === participantId) { + continue; + } + for (const message of messages) { + if (message.to === null || message.to === participantId) { + inbound.push(message); + } + } + } + return inbound; +} + +/** + * Drives a complete DKG for every party in-process, routing each round's + * broadcast and point-to-point messages between the sessions the way a host + * application would. Key shares are returned sorted by party identifier. + */ +export function runDkg( + config: ThresholdConfig, + sessionId?: Uint8Array, +): DkgResult { + const sessions = new Map(); + let outgoing = new Map(); + for (let partyId = 1; partyId <= config.maxSigners; partyId++) { + const [session, messages] = DkgSession.create(config, partyId, sessionId); + sessions.set(partyId, session); + outgoing.set(partyId, messages); + } + + const keyShares = new Map(); + let publicKeys: Uint8Array = new Uint8Array(0); + for (let round = 0; round < MAX_DKG_ROUNDS && sessions.size > 0; round++) { + const nextOutgoing = new Map(); + for (const [partyId, session] of [...sessions]) { + const output = session.next(collectFor(partyId, outgoing)); + if (output.complete) { + keyShares.set(partyId, output.keyShare); + publicKeys = output.publicKeys; + session.free(); + sessions.delete(partyId); + } else { + nextOutgoing.set(partyId, output.messages); + } + } + outgoing = nextOutgoing; + } + + if (keyShares.size !== config.maxSigners) { + throw new Error( + `DKG incomplete: ${keyShares.size}/${config.maxSigners} shares`, + ); + } + + return { + keyShares: [...keyShares.entries()] + .sort(([a], [b]) => a - b) + .map(([, share]) => share), + publicKeys, + }; +} + +export function freeAll(handles: KeyShareHandle[]): void { + for (const handle of handles) { + handle.free(); + } +} diff --git a/libtss-node/src/ffi.ts b/libtss-node/src/ffi.ts index 9438f8b..0eb5786 100644 --- a/libtss-node/src/ffi.ts +++ b/libtss-node/src/ffi.ts @@ -107,6 +107,9 @@ export const lib: Record any> = { tss_handle_group_key: native.func( "int32_t tss_handle_group_key(uint64_t handle, _Out_ TssBuffer *out)", ), + tss_handle_chain_code: native.func( + "int32_t tss_handle_chain_code(uint64_t handle, _Out_ TssBuffer *out)", + ), tss_handle_pubkey_package: native.func( "int32_t tss_handle_pubkey_package(uint64_t handle, _Out_ TssBuffer *out)", ), diff --git a/libtss-node/src/handle.ts b/libtss-node/src/handle.ts index d3a66ee..4485fbf 100644 --- a/libtss-node/src/handle.ts +++ b/libtss-node/src/handle.ts @@ -52,6 +52,23 @@ export class KeyShareHandle { return readBuffer(buf); } + /** + * Returns the 32-byte BIP-32 chain code paired with the group verifying key. + * + * Only the BIP-32-capable ciphersuites carry one: Secp256k1ECDSA, + * Secp256r1ECDSA and Secp256k1Taproot. Every other suite throws a + * {@link TssError} whose `code` is `TssStatusCode.PROTO_MISMATCH`. + * + * Pair it with {@link groupVerifyingKey} to build a BIP-32 extended public + * key (xpub) whose off-MPC child derivation matches the shares each party + * derives with `deriveChild` / `derivePath`. + */ + chainCode(): Uint8Array { + const buf = allocBuffer(); + checkStatus(lib.tss_handle_chain_code(this._handle, buf)); + return readBuffer(buf); + } + publicKeyPackage(): Uint8Array { const buf = allocBuffer(); checkStatus(lib.tss_handle_pubkey_package(this._handle, buf)); diff --git a/libtss-python/libtss/_ffi.py b/libtss-python/libtss/_ffi.py index 72737e9..29e4cc5 100644 --- a/libtss-python/libtss/_ffi.py +++ b/libtss-python/libtss/_ffi.py @@ -218,6 +218,9 @@ def _declare_signatures(lib: ctypes.CDLL) -> None: lib.tss_handle_group_key.restype = TssStatus lib.tss_handle_group_key.argtypes = [c_uint64, POINTER(TssBuffer)] + lib.tss_handle_chain_code.restype = TssStatus + lib.tss_handle_chain_code.argtypes = [c_uint64, POINTER(TssBuffer)] + lib.tss_handle_pubkey_package.restype = TssStatus lib.tss_handle_pubkey_package.argtypes = [c_uint64, POINTER(TssBuffer)] diff --git a/libtss-python/libtss/handle.py b/libtss-python/libtss/handle.py index 44d166d..99c8d56 100644 --- a/libtss-python/libtss/handle.py +++ b/libtss-python/libtss/handle.py @@ -77,6 +77,25 @@ def group_verifying_key(self) -> bytes: check_status(status) return buffer_to_bytes(buf) + @property + def chain_code(self) -> bytes: + """32-byte BIP-32 chain code paired with :attr:`group_verifying_key`. + + Only the BIP-32-capable ciphersuites carry a chain code: + ``SECP256K1_ECDSA``, ``SECP256R1_ECDSA`` and ``SECP256K1_TAPROOT``. + For every other suite (e.g. ``ED25519``) this raises + :class:`~libtss.TssError` with ``code == 8`` (protocol mismatch). + + Together with :attr:`group_verifying_key` this is enough to build a + BIP-32 extended public key (xpub) whose off-MPC non-hardened child + derivation matches :func:`libtss.derive.derive_child`. + """ + self._check() + buf = TssBuffer() + status = get_lib().tss_handle_chain_code(self._handle, ctypes.byref(buf)) + check_status(status) + return buffer_to_bytes(buf) + @property def verifying_share(self) -> bytes: """Public verification key for this specific share.""" diff --git a/libtss-python/tests/test_libtss.py b/libtss-python/tests/test_libtss.py index d0aef9c..7d5f1ba 100644 --- a/libtss-python/tests/test_libtss.py +++ b/libtss-python/tests/test_libtss.py @@ -6,6 +6,8 @@ from __future__ import annotations +import hashlib +import hmac import os import sys @@ -28,6 +30,7 @@ version, ) from libtss import frost +from libtss.derive import derive_child # ── Helpers ────────────────────────────────────────────────────────────────── @@ -287,6 +290,39 @@ def test_double_close_is_safe(self): for s in shares[1:]: s.close() + def test_chain_code_dkls_matches_bip32(self): + shares, _ = run_dkg( + Ciphersuite.SECP256K1_ECDSA, 2, 2, + session_id=b"chain-code-test-session", + ) + parent_chain_code = shares[0].chain_code + assert len(parent_chain_code) == 32 + assert shares[1].chain_code == parent_chain_code + + child_number = 7 + parent_group_key = shares[0].group_verifying_key + expected_child_chain_code = hmac.new( + parent_chain_code, + parent_group_key + child_number.to_bytes(4, "big"), + hashlib.sha512, + ).digest()[32:] + + children = [derive_child(ks, child_number) for ks in shares] + for child in children: + assert child.chain_code == expected_child_chain_code + + for ks in children + shares: + ks.close() + + def test_chain_code_unsupported_suite_raises(self): + handles, _ = frost.generate_dealer(Ciphersuite.ED25519, 3, 2) + with pytest.raises(TssError) as excinfo: + _ = handles[0].chain_code + assert excinfo.value.code == 8 # TSS_ERR_PROTO_MISMATCH + + for h in handles: + h.close() + class TestDealerKeygen: def test_generate_dealer(self): diff --git a/libtss-rn/android/src/main/cpp/libtss-jni.cpp b/libtss-rn/android/src/main/cpp/libtss-jni.cpp index 26ae396..f3be21a 100644 --- a/libtss-rn/android/src/main/cpp/libtss-jni.cpp +++ b/libtss-rn/android/src/main/cpp/libtss-jni.cpp @@ -39,6 +39,7 @@ void tss_handle_free(TssHandle handle); TssStatus tss_handle_identifier(TssHandle handle, uint16_t* out_id); TssStatus tss_handle_verifying_share(TssHandle handle, TssBuffer* out); TssStatus tss_handle_group_key(TssHandle handle, TssBuffer* out); +TssStatus tss_handle_chain_code(TssHandle handle, TssBuffer* out); TssStatus tss_handle_pubkey_package(TssHandle handle, TssBuffer* out); uint8_t tss_handle_ciphersuite(TssHandle handle); TssStatus tss_handle_export(TssHandle handle, TssBuffer* out); @@ -730,6 +731,22 @@ Java_com_libtss_LibtssModule_nativeHandleGroupKey(JNIEnv* env, jclass, jstring h return take_base64(env, out); } +extern "C" JNIEXPORT jstring JNICALL +Java_com_libtss_LibtssModule_nativeHandleChainCode(JNIEnv* env, jclass, jstring handle) { + TssHandle parsed = 0; + if (!parse_handle(env, handle, &parsed)) { + return nullptr; + } + + TssBuffer out{nullptr, 0}; + TssStatus status = tss_handle_chain_code(parsed, &out); + if (!ensure_status(env, status)) { + return nullptr; + } + + return take_base64(env, out); +} + extern "C" JNIEXPORT jstring JNICALL Java_com_libtss_LibtssModule_nativeHandlePubkeyPackage(JNIEnv* env, jclass, jstring handle) { TssHandle parsed = 0; diff --git a/libtss-rn/android/src/main/java/com/libtss/LibtssModule.java b/libtss-rn/android/src/main/java/com/libtss/LibtssModule.java index cebc3be..9c64ae3 100644 --- a/libtss-rn/android/src/main/java/com/libtss/LibtssModule.java +++ b/libtss-rn/android/src/main/java/com/libtss/LibtssModule.java @@ -178,6 +178,11 @@ public void handleGroupKey(String handle, Promise promise) { resolve(promise, () -> nativeHandleGroupKey(handle)); } + @ReactMethod + public void handleChainCode(String handle, Promise promise) { + resolve(promise, () -> nativeHandleChainCode(handle)); + } + @ReactMethod public void handlePubkeyPackage(String handle, Promise promise) { resolve(promise, () -> nativeHandlePubkeyPackage(handle)); @@ -310,6 +315,7 @@ public void importKeyShareSecure(double suite, String keychainId, Promise promis private static native double nativeHandleIdentifier(String handle); private static native String nativeHandleVerifyingShare(String handle); private static native String nativeHandleGroupKey(String handle); + private static native String nativeHandleChainCode(String handle); private static native String nativeHandlePubkeyPackage(String handle); private static native double nativeHandleCiphersuite(String handle); private static native String nativeExportKeyShare(String handle); diff --git a/libtss-rn/ios/Libtss.mm b/libtss-rn/ios/Libtss.mm index 68b9781..f841e37 100644 --- a/libtss-rn/ios/Libtss.mm +++ b/libtss-rn/ios/Libtss.mm @@ -39,6 +39,7 @@ TssStatus tss_handle_identifier(TssHandle handle, uint16_t *out_id); TssStatus tss_handle_verifying_share(TssHandle handle, TssBuffer *out); TssStatus tss_handle_group_key(TssHandle handle, TssBuffer *out); +TssStatus tss_handle_chain_code(TssHandle handle, TssBuffer *out); TssStatus tss_handle_pubkey_package(TssHandle handle, TssBuffer *out); uint8_t tss_handle_ciphersuite(TssHandle handle); TssStatus tss_handle_export(TssHandle handle, TssBuffer *out); @@ -581,6 +582,25 @@ @implementation Libtss resolve(TakeBase64(out)); } +RCT_EXPORT_METHOD(handleChainCode:(NSString *)handle + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + TssHandle parsed = 0; + if (!ParseHandle(handle, &parsed, reject)) { + return; + } + + TssBuffer out{nullptr, 0}; + TssStatus status = tss_handle_chain_code(parsed, &out); + if (status != TSS_OK) { + RejectStatus(reject, status); + return; + } + + resolve(TakeBase64(out)); +} + RCT_EXPORT_METHOD(handlePubkeyPackage:(NSString *)handle resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) diff --git a/libtss-rn/src/NativeLibtss.ts b/libtss-rn/src/NativeLibtss.ts index 0352ac8..80796cf 100644 --- a/libtss-rn/src/NativeLibtss.ts +++ b/libtss-rn/src/NativeLibtss.ts @@ -47,6 +47,7 @@ export interface NativeLibtssModule { handleIdentifier(handle: string): Promise; handleVerifyingShare(handle: string): Promise; handleGroupKey(handle: string): Promise; + handleChainCode(handle: string): Promise; handlePubkeyPackage(handle: string): Promise; handleCiphersuite(handle: string): Promise; exportKeyShare(handle: string): Promise; diff --git a/libtss-rn/src/__tests__/__mocks__/NativeLibtss.ts b/libtss-rn/src/__tests__/__mocks__/NativeLibtss.ts index cc6cc79..e9aee2e 100644 --- a/libtss-rn/src/__tests__/__mocks__/NativeLibtss.ts +++ b/libtss-rn/src/__tests__/__mocks__/NativeLibtss.ts @@ -1,5 +1,8 @@ const MOCK_BYTE_11 = 11; const MOCK_BYTE_12 = 12; +const MOCK_BYTE_13 = 13; +const MOCK_BYTE_14 = 14; +const MOCK_BYTE_15 = 15; import { fromByteArray } from "base64-js"; @@ -25,6 +28,7 @@ type HandleState = { suite: Ciphersuite; verifyingShare: Uint8Array; groupKey: Uint8Array; + chainCode: Uint8Array; pubkeyPackage: Uint8Array; exported: Uint8Array; }; @@ -136,6 +140,13 @@ const NativeLibtss = { } return base64(state.groupKey); }), + handleChainCode: jest.fn(async (handle: string) => { + const state = handles.get(handle); + if (!state) { + throw new Error(`unknown handle ${handle}`); + } + return base64(state.chainCode); + }), handlePubkeyPackage: jest.fn(async (handle: string) => { const state = handles.get(handle); if (!state) { @@ -165,6 +176,7 @@ const NativeLibtss = { suite: suite as Ciphersuite, verifyingShare: Uint8Array.of(1), groupKey: Uint8Array.of(2), + chainCode: Uint8Array.of(5), pubkeyPackage: Uint8Array.of(3), exported: Uint8Array.of(4), }); @@ -193,6 +205,7 @@ const NativeLibtss = { suite: suite as Ciphersuite, verifyingShare: Uint8Array.of(1), groupKey: Uint8Array.of(2), + chainCode: Uint8Array.of(5), pubkeyPackage: Uint8Array.of(3), exported: Uint8Array.of(4), }); @@ -238,6 +251,9 @@ export const __mock = { suite: state.suite, verifyingShare: state.verifyingShare ?? Uint8Array.of(1, 2, 3), groupKey: state.groupKey ?? Uint8Array.of(4, 5, 6), + chainCode: + state.chainCode ?? + Uint8Array.of(MOCK_BYTE_13, MOCK_BYTE_14, MOCK_BYTE_15), pubkeyPackage: state.pubkeyPackage ?? Uint8Array.of(7, 8, 9), exported: state.exported ?? Uint8Array.of(10, MOCK_BYTE_11, MOCK_BYTE_12), }); diff --git a/libtss-rn/src/__tests__/handle.test.ts b/libtss-rn/src/__tests__/handle.test.ts new file mode 100644 index 0000000..ec54fd6 --- /dev/null +++ b/libtss-rn/src/__tests__/handle.test.ts @@ -0,0 +1,47 @@ +const CHAIN_CODE_LEN = 32; + +import { TssError } from "../error"; +import { KeyShareHandle } from "../handle"; +import { Ciphersuite, TssStatusCode } from "../types"; +import NativeLibtss, { __mock } from "./__mocks__/NativeLibtss"; + +describe("KeyShareHandle", () => { + beforeEach(() => { + __mock.reset(); + }); + + it("chainCode() decodes the native base64 value", async () => { + const chainCode = Uint8Array.from( + { length: CHAIN_CODE_LEN }, + (_, index) => index, + ); + __mock.registerHandle("51", { + suite: Ciphersuite.Secp256k1ECDSA, + chainCode, + }); + const handle = new KeyShareHandle("51"); + + const result = await handle.chainCode(); + + expect(NativeLibtss.handleChainCode).toHaveBeenCalledWith("51"); + expect(result).toBeInstanceOf(Uint8Array); + expect(result).toHaveLength(CHAIN_CODE_LEN); + expect(result).toEqual(chainCode); + }); + + it("chainCode() maps a native rejection to TssError", async () => { + NativeLibtss.handleChainCode.mockRejectedValueOnce({ + code: TssStatusCode.PROTO_MISMATCH, + message: "chain code is not available for this ciphersuite", + }); + const handle = new KeyShareHandle("61"); + + const rejection = handle.chainCode(); + + await expect(rejection).rejects.toThrow(TssError); + await expect(rejection).rejects.toMatchObject({ + code: TssStatusCode.PROTO_MISMATCH, + }); + expect(NativeLibtss.handleChainCode).toHaveBeenCalledWith("61"); + }); +}); diff --git a/libtss-rn/src/handle.ts b/libtss-rn/src/handle.ts index d322100..7423aba 100644 --- a/libtss-rn/src/handle.ts +++ b/libtss-rn/src/handle.ts @@ -65,6 +65,22 @@ export class KeyShareHandle { ); } + /** + * Returns the 32-byte BIP-32 chain code paired with the group verifying key. + * + * Supported only for the BIP-32-capable ciphersuites: `Secp256k1ECDSA`, + * `Secp256r1ECDSA` and `Secp256k1Taproot`. Every other suite rejects with a + * {@link TssError} whose code is `TssStatusCode.PROTO_MISMATCH`. + * + * Pair with {@link groupVerifyingKey} to build a BIP-32 extended public key + * (xpub) whose off-MPC child derivation matches libtss's own derivation. + */ + async chainCode(): Promise { + return decodeBase64( + await callNative(() => NativeLibtss.handleChainCode(this._handle)), + ); + } + async publicKeyPackage(): Promise { return decodeBase64( await callNative(() => NativeLibtss.handlePubkeyPackage(this._handle)), diff --git a/libtss-wasm/README.md b/libtss-wasm/README.md index 6a9b1ca..f12a1a8 100644 --- a/libtss-wasm/README.md +++ b/libtss-wasm/README.md @@ -224,7 +224,7 @@ console.log("Ed25519 signature (hex):", Buffer.from(sig1.signature).toString("he ### Classes -- **`WasmKeyShareHandle`** — holds a participant's key share in the in-memory registry +- **`WasmKeyShareHandle`** — holds a participant's key share in the in-memory registry; `groupVerifyingKey()` and `chainCode()` (32-byte BIP-32 chain code, suites 0/6/7 only) together form the xpub for watch-only derivation - **`SecureKeyShare`** — opaque wrapper keeping key share plaintext in WASM linear memory (zeroed on drop) - **`WasmDkgSession`** — drives DKG protocol rounds; use `WasmDkgSession.create()` - **`WasmSignSession`** — drives signing protocol rounds; use `WasmSignSession.newFrost()` / `newDkls()` / `newDklsR1()` diff --git a/libtss-wasm/src/handle.rs b/libtss-wasm/src/handle.rs index 0436a6c..1cb1a13 100644 --- a/libtss-wasm/src/handle.rs +++ b/libtss-wasm/src/handle.rs @@ -62,6 +62,22 @@ impl WasmKeyShareHandle { Uint8Array::from(bytes.as_slice()) } + /// Returns the 32-byte BIP-32 chain code of the group key. + /// + /// Pairs with `groupVerifyingKey()` to assemble a BIP-32 extended public + /// key (xpub), so a watch-only consumer can derive non-hardened children + /// without any share material. + /// + /// Supported for suites 6 (Secp256k1ECDSA) and 7 (Secp256r1ECDSA), whose + /// DKLs23 DKG establishes the chain code, and suite 0 (Secp256k1Taproot), + /// where it is `SHA-256(group key)`. Every other suite rejects with an + /// error whose `code` is `"ProtocolMismatch"`. + #[wasm_bindgen(js_name = "chainCode")] + pub fn chain_code(&self) -> Result { + let bytes = map_err(self.inner.chain_code())?; + Ok(Uint8Array::from(bytes.as_slice())) + } + /// Returns the serialized `PublicKeyPackage` containing all verifying shares /// and the group key. Suitable for storage or transmission to other participants. #[wasm_bindgen(js_name = "publicKeyPackage")] diff --git a/libtss-wasm/tests/handle.rs b/libtss-wasm/tests/handle.rs new file mode 100644 index 0000000..a873c4f --- /dev/null +++ b/libtss-wasm/tests/handle.rs @@ -0,0 +1,155 @@ +//! WASM tests for `WasmKeyShareHandle`. +//! +//! Run with: +//! ```sh +//! wasm-pack test --node --test handle +//! ``` +//! +//! These tests drive the protocols through the crate's own Rust API and only +//! cross the JS boundary at the `WasmKeyShareHandle` surface, so they need no +//! browser and run under Node.js. + +use std::collections::BTreeMap; + +use libtss::frost::frost_generate_with_dealer; +use libtss::keyshare::from_frost_dkg; +use libtss::{ + Ciphersuite, DkgOutput, DkgSession, Identifier, KeyShareHandle, Message, ThresholdConfig, +}; +use libtss_wasm::WasmKeyShareHandle; +use wasm_bindgen::JsValue; +use wasm_bindgen_test::*; + +fn id(value: u16) -> Identifier { + Identifier::new(value).unwrap() +} + +fn route( + outputs: &BTreeMap>, + recipients: &[Identifier], +) -> BTreeMap> { + let mut routed: BTreeMap> = + recipients.iter().map(|&r| (r, Vec::new())).collect(); + for (&sender, messages) in outputs { + for msg in messages { + match msg.to { + Some(to) => routed.get_mut(&to).unwrap().push(msg.clone()), + None => { + for &r in recipients { + if r != sender { + routed.get_mut(&r).unwrap().push(msg.clone()); + } + } + } + } + } + } + routed +} + +fn run_dkls_dkg(min_signers: u16, max_signers: u16) -> BTreeMap { + let config = ThresholdConfig { + min_signers, + max_signers, + suite: Ciphersuite::Secp256k1ECDSA, + }; + let participants: Vec = (1..=max_signers).map(id).collect(); + let session_id = b"wasm-handle-chain-code"; + + let mut sessions = BTreeMap::new(); + let mut phase1 = BTreeMap::new(); + for &pid in &participants { + let (session, msgs) = DkgSession::new(&config, pid, Some(session_id)).unwrap(); + sessions.insert(pid, session); + phase1.insert(pid, msgs); + } + + // DKLs23 DKG: three Continue rounds followed by Complete. + let mut prev = route(&phase1, &participants); + for _ in 0..2 { + let mut out = BTreeMap::new(); + for &pid in &participants { + match sessions + .get_mut(&pid) + .unwrap() + .next(prev.get(&pid).unwrap()) + .unwrap() + { + DkgOutput::Continue(msgs) => { + out.insert(pid, msgs); + } + DkgOutput::Complete { .. } => panic!("expected Continue"), + } + } + prev = route(&out, &participants); + } + + let mut results = BTreeMap::new(); + for &pid in &participants { + match sessions + .get_mut(&pid) + .unwrap() + .next(prev.get(&pid).unwrap()) + .unwrap() + { + DkgOutput::Complete { key_share, .. } => { + results.insert(pid, key_share); + } + DkgOutput::Continue(_) => panic!("expected Complete"), + } + } + results +} + +fn js_error_code(err: &JsValue) -> String { + js_sys::Reflect::get(err, &JsValue::from_str("code")) + .expect("error object has a `code` property") + .as_string() + .expect("`code` is a string") +} + +// ─── chainCode ────────────────────────────────────────────────────────────── + +#[wasm_bindgen_test] +fn chain_code_is_32_bytes_and_shared_across_dkls_parties() { + let handles = run_dkls_dkg(2, 2); + assert_eq!(handles.len(), 2); + + let chain_codes: Vec> = handles + .into_values() + .map(|handle| { + WasmKeyShareHandle::from(handle) + .chain_code() + .expect("Secp256k1ECDSA exposes a chain code") + .to_vec() + }) + .collect(); + + assert_eq!(chain_codes[0].len(), 32, "chain code must be 32 bytes"); + assert_eq!( + chain_codes[0], chain_codes[1], + "both DKG parties must agree on the chain code" + ); + assert!( + chain_codes[0].iter().any(|&b| b != 0), + "chain code must not be all zeros" + ); +} + +#[wasm_bindgen_test] +fn chain_code_rejects_ed25519_with_protocol_mismatch() { + let config = ThresholdConfig { + min_signers: 2, + max_signers: 3, + suite: Ciphersuite::Ed25519, + }; + let (shares, pubkey) = frost_generate_with_dealer(&config).unwrap(); + let frost_handle = shares.into_iter().next().unwrap(); + let handle = WasmKeyShareHandle::from(from_frost_dkg(frost_handle, &pubkey).unwrap()); + + let err = handle + .chain_code() + .expect_err("Ed25519 has no BIP-32 chain code"); + + assert_eq!(js_error_code(&err), "ProtocolMismatch"); +} diff --git a/libtss/src/derive.rs b/libtss/src/derive.rs index 657c072..7b6c3eb 100644 --- a/libtss/src/derive.rs +++ b/libtss/src/derive.rs @@ -139,18 +139,8 @@ fn derive_frost_taproot_child( // from_frost_taproot_derived calls REGISTRY.insert which would deadlock). let (key_package, public_key_package) = keyshare::clone_frost_taproot(key_share)?; - // For FROST Taproot BIP-32, we need a chain code. Use the group verifying key - // as a deterministic chain code source (hash of the group key). let parent_pubkey = key_package.verifying_key().serialize().map_err(frost_err)?; - - // Derive chain code from the verifying key (deterministic) - let chain_code = { - use sha2::Digest; - let hash = sha2::Sha256::digest(&parent_pubkey); - let mut cc = [0u8; 32]; - cc.copy_from_slice(&hash); - cc - }; + let chain_code = taproot_chain_code(&parent_pubkey); let mut mac = HmacSha512::new_from_slice(&chain_code) .map_err(|e| TssError::InvalidConfig(e.to_string()))?; @@ -248,6 +238,17 @@ fn frost_err(err: E) -> TssError { TssError::DeserializeFailed(err.to_string()) } +/// Chain code used for FROST Taproot BIP-32 derivation: `SHA-256(group key)`. +/// +/// FROST DKG establishes no chain code, so one is derived deterministically +/// from the parent's compressed group verifying key. Shared between +/// `derive_child` and `KeyShareHandle::chain_code` so the value a caller reads +/// is always the value derivation actually mixes into the HMAC. +pub(crate) fn taproot_chain_code(group_key: &[u8]) -> [u8; 32] { + use sha2::Digest; + sha2::Sha256::digest(group_key).into() +} + #[cfg(test)] mod tests { use super::*; @@ -380,4 +381,94 @@ mod tests { Err(TssError::ProtocolMismatch) )); } + + fn dkls_r1_handle() -> KeyShareHandle { + let parameters = dkls23_secp256r1::protocols::Parameters::new(2, 3).unwrap(); + let (parties, pubkeys) = dkls23_secp256r1::protocols::re_key::re_key( + ¶meters, + b"session-r1", + &p256::Scalar::from(7u64), + Some([2u8; 32]), + dkls23_secp256r1::compute_neo3_address, + ); + + keyshare::from_dkls_r1_dkg(parties[0].clone(), pubkeys).unwrap() + } + + fn ed25519_handle() -> KeyShareHandle { + let config = crate::types::ThresholdConfig { + min_signers: 2, + max_signers: 3, + suite: Ciphersuite::Ed25519, + }; + let (shares, pubkey) = crate::frost::frost_generate_with_dealer(&config).unwrap(); + keyshare::from_frost_dkg(shares.into_iter().next().unwrap(), &pubkey).unwrap() + } + + #[test] + fn chain_code_dkls_exposes_dkg_value() { + let handle = dkls_handle(); + assert_eq!(handle.chain_code().unwrap(), vec![1u8; 32]); + } + + #[test] + fn chain_code_dkls_r1_exposes_dkg_value() { + let handle = dkls_r1_handle(); + assert_eq!(handle.chain_code().unwrap(), vec![2u8; 32]); + } + + #[test] + fn chain_code_taproot_is_sha256_of_group_key() { + let handle = frost_handle(); + let expected = taproot_chain_code(&handle.group_verifying_key()).to_vec(); + assert_eq!(handle.chain_code().unwrap(), expected); + } + + #[test] + fn chain_code_unsupported_suite_rejected() { + let handle = ed25519_handle(); + assert!(matches!( + handle.chain_code(), + Err(TssError::ProtocolMismatch) + )); + } + + #[test] + fn chain_code_dkls_child_matches_bip32_ckd_pub() { + use k256::elliptic_curve::ops::Reduce; + + let parent = dkls_handle(); + let child = derive_child(&parent, 7).unwrap(); + + let mut mac = HmacSha512::new_from_slice(&parent.chain_code().unwrap()).unwrap(); + mac.update(&parent.group_verifying_key()); + mac.update(&7u32.to_be_bytes()); + let output = mac.finalize().into_bytes(); + let (il, ir) = output.split_at(32); + + let il_bytes: &k256::FieldBytes = il.try_into().unwrap(); + let tweak = >::reduce(il_bytes); + let parent_point = k256::PublicKey::from_sec1_bytes(&parent.group_verifying_key()) + .unwrap() + .to_projective(); + let expected_child = (parent_point + k256::ProjectivePoint::GENERATOR * tweak).to_affine(); + + assert_eq!( + child.group_verifying_key(), + expected_child.to_sec1_point(true).as_bytes() + ); + assert_eq!(child.chain_code().unwrap(), ir); + } + + #[test] + fn chain_code_taproot_child_is_rederived_from_child_key() { + let parent = frost_handle(); + let child = derive_child(&parent, 1).unwrap(); + + assert_ne!(child.chain_code().unwrap(), parent.chain_code().unwrap()); + assert_eq!( + child.chain_code().unwrap(), + taproot_chain_code(&child.group_verifying_key()).to_vec() + ); + } } diff --git a/libtss/src/dkls.rs b/libtss/src/dkls.rs index 15945e9..03d2906 100644 --- a/libtss/src/dkls.rs +++ b/libtss/src/dkls.rs @@ -1,7 +1,8 @@ use std::collections::BTreeMap; use dkls23_secp256k1::protocols::dkg::{ - BroadcastDerivationPhase2to4, BroadcastDerivationPhase3to4, TransmitInitZeroSharePhase2to4, + BroadcastDerivationPhase2to4, BroadcastDerivationPhase3to4, KeepInitMulPhase3to4, + KeepInitZeroSharePhase2to3, KeepInitZeroSharePhase3to4, TransmitInitZeroSharePhase2to4, TransmitInitZeroSharePhase3to4, }; use dkls23_secp256k1::protocols::messages::{MessageTag, PhaseInput, PhaseOutput}; @@ -11,7 +12,8 @@ use dkls23_secp256k1::protocols::{Abort, AbortKind, Parameters, PartyIndex}; use dkls23_secp256k1::{Party, PublicKeyPackage as DklsPublicKeyPackage}; use k256::elliptic_curve::sec1::ToSec1Point; use k256::elliptic_curve::PrimeField; -use k256::Scalar; +use k256::{AffinePoint, ProjectivePoint, Scalar}; +use zeroize::Zeroize; type Secp256k1 = k256::Secp256k1; type DkgSession = dkls23_secp256k1::DkgSession; @@ -46,12 +48,65 @@ pub struct DklsSignState<'a> { pub(crate) self_broadcast: Option, } -fn identifier_to_party_index(id: Identifier) -> Result { +/// Per-party state of a DKLs23 complete refresh, carried between the three +/// message rounds until `dkls_refresh_phase4` consumes it. +/// +/// The refresh re-runs DKG with a zero-constant polynomial, so it keeps the +/// same zero-share and multiplication initialisation material DKG does, plus +/// the original party and public key package the correction is applied to. +pub struct DklsRefreshState { + pub(crate) party: Party, + pub(crate) pubkey: DklsPublicKeyPackage, + pub(crate) refresh_sid: Vec, + pub(crate) self_id: Identifier, + pub(crate) share_count: u8, + pub(crate) self_fragment: Option, + pub(crate) correction_value: Option, + pub(crate) self_proof_commitment: Option, + pub(crate) zero_kept_2to3: Option>, + pub(crate) zero_kept_3to4: Option>, + pub(crate) mul_kept_3to4: Option>>, + pub(crate) phase2_input: Option, +} + +impl Drop for DklsRefreshState { + fn drop(&mut self) { + self.refresh_sid.zeroize(); + if let Some(fragment) = self.self_fragment.as_mut() { + fragment.zeroize(); + } + if let Some(correction) = self.correction_value.as_mut() { + correction.zeroize(); + } + if let Some(kept) = self.zero_kept_2to3.as_mut() { + for value in kept.values_mut() { + value.seed.zeroize(); + value.salt.zeroize(); + } + } + if let Some(kept) = self.zero_kept_3to4.as_mut() { + for value in kept.values_mut() { + value.seed.zeroize(); + } + } + if let Some(kept) = self.mul_kept_3to4.as_mut() { + for value in kept.values_mut() { + value.ot_sender.s.zeroize(); + value.ot_receiver.seed.zeroize(); + value.nonce.zeroize(); + value.vec_r.zeroize(); + value.correlation.zeroize(); + } + } + } +} + +pub(crate) fn identifier_to_party_index(id: Identifier) -> Result { let value = u8::try_from(id.as_u16()).map_err(|_| TssError::InvalidIdentifier)?; PartyIndex::new(value).map_err(|_| TssError::InvalidIdentifier) } -fn identifier_to_session_party_index( +pub(crate) fn identifier_to_session_party_index( id: Identifier, share_count: u8, ) -> Result { @@ -62,7 +117,7 @@ fn identifier_to_session_party_index( Ok(index) } -fn party_index_to_identifier(index: PartyIndex) -> Result { +pub(crate) fn party_index_to_identifier(index: PartyIndex) -> Result { Identifier::new(u16::from(index.as_u8())) } @@ -151,7 +206,7 @@ fn phase_input_p2p(input: &PhaseInput, sender: u8) -> Result Result { +pub(crate) fn scalar_from_message(data: &[u8]) -> Result { let bytes: [u8; 32] = data .try_into() .map_err(|_| TssError::DeserializeFailed("invalid scalar fragment length".into()))?; @@ -213,10 +268,31 @@ pub fn dkls_dkg_phase1( let parameters = Parameters::new(threshold, share_count) .map_err(|_| TssError::InvalidConfig("invalid DKLs23 parameters".into()))?; let session = DkgSession::new(parameters, self_index, session_id); - let fragments = session.phase1(); + let (self_fragment, messages) = fragment_messages(session.phase1(), self_id, self_index)?; + + Ok(( + DklsDkgState { + session, + self_id, + share_count, + self_fragment: Some(self_fragment), + self_proof_commitment: None, + self_bip_phase2: None, + self_bip_phase3: None, + phase2_input: None, + }, + messages, + )) +} + +fn fragment_messages( + fragments: Vec, + self_id: Identifier, + self_index: PartyIndex, +) -> Result<(Scalar, Vec), TssError> { let self_position = usize::from(self_index.as_u8() - 1); let self_fragment: Scalar = fragments[self_position]; - let mut messages = Vec::with_capacity(usize::from(share_count.saturating_sub(1))); + let mut messages = Vec::with_capacity(fragments.len().saturating_sub(1)); for (index, fragment) in fragments.into_iter().enumerate() { let receiver = u16::try_from(index + 1) @@ -232,44 +308,29 @@ pub fn dkls_dkg_phase1( }); } - Ok(( - DklsDkgState { - session, - self_id, - share_count, - self_fragment: Some(self_fragment), - self_proof_commitment: None, - self_bip_phase2: None, - self_bip_phase3: None, - phase2_input: None, - }, - messages, - )) + Ok((self_fragment, messages)) } -pub fn dkls_dkg_phase2( - state: &mut DklsDkgState, +fn collect_fragments( + self_id: Identifier, + share_count: u8, + self_fragment: Scalar, received: &[Message], -) -> Result, TssError> { - let self_index = identifier_to_session_party_index(state.self_id, state.share_count)?; +) -> Result, TssError> { + let self_index = identifier_to_session_party_index(self_id, share_count)?; let self_position = usize::from(self_index.as_u8() - 1); - let self_fragment = state - .self_fragment - .take() - .ok_or(TssError::SessionComplete)?; - let mut fragments: Vec = vec![Scalar::ZERO; usize::from(state.share_count)]; + let mut fragments: Vec = vec![Scalar::ZERO; usize::from(share_count)]; fragments[self_position] = self_fragment; - let mut seen = vec![false; usize::from(state.share_count)]; + let mut seen = vec![false; usize::from(share_count)]; seen[self_position] = true; for message in received { - if message.to != Some(state.self_id) { + if message.to != Some(self_id) { continue; } - let sender = usize::from( - identifier_to_session_party_index(message.from, state.share_count)?.as_u8() - 1, - ); + let sender = + usize::from(identifier_to_session_party_index(message.from, share_count)?.as_u8() - 1); if seen[sender] { return Err(TssError::DeserializeFailed( "duplicate DKG fragment from sender".into(), @@ -285,6 +346,19 @@ pub fn dkls_dkg_phase2( )); } + Ok(fragments) +} + +pub fn dkls_dkg_phase2( + state: &mut DklsDkgState, + received: &[Message], +) -> Result, TssError> { + let self_fragment = state + .self_fragment + .take() + .ok_or(TssError::SessionComplete)?; + let fragments = collect_fragments(state.self_id, state.share_count, self_fragment, received)?; + let (proof_commitment, zero_transmit, bip_broadcast) = state.session.phase2(&fragments).map_err(abort_to_error)?; let mut output = PhaseOutput::new(); @@ -412,6 +486,248 @@ pub fn dkls_dkg_phase4( Ok((party, package, unified)) } +/// Start a DKLs23 complete refresh for `party`, producing the round-1 fragments. +/// +/// `refresh_sid` becomes the refreshed party's session id, so every party of +/// the group must refresh under the same fresh id: shares from different +/// refresh generations salt signing differently and can never co-sign. +pub fn dkls_refresh_phase1( + party: Party, + pubkey: DklsPublicKeyPackage, + refresh_sid: &[u8], +) -> Result<(DklsRefreshState, Vec), TssError> { + if refresh_sid.is_empty() { + return Err(TssError::InvalidConfig( + "refresh session_id must not be empty".into(), + )); + } + if party.parameters.share_count != pubkey.share_count() + || party.parameters.threshold != pubkey.threshold() + { + return Err(TssError::InvalidConfig( + "party parameters do not match the public key package".into(), + )); + } + + let self_id = party_index_to_identifier(party.party_index)?; + let share_count = party.parameters.share_count; + let (self_fragment, messages) = + fragment_messages(party.refresh_complete_phase1(), self_id, party.party_index)?; + + Ok(( + DklsRefreshState { + party, + pubkey, + refresh_sid: refresh_sid.to_vec(), + self_id, + share_count, + self_fragment: Some(self_fragment), + correction_value: None, + self_proof_commitment: None, + zero_kept_2to3: None, + zero_kept_3to4: None, + mul_kept_3to4: None, + phase2_input: None, + }, + messages, + )) +} + +pub fn dkls_refresh_phase2( + state: &mut DklsRefreshState, + received: &[Message], +) -> Result, TssError> { + let self_fragment = state + .self_fragment + .take() + .ok_or(TssError::SessionComplete)?; + let fragments = collect_fragments(state.self_id, state.share_count, self_fragment, received)?; + + let (correction_value, proof_commitment, zero_kept, zero_transmit) = state + .party + .refresh_complete_phase2(&state.refresh_sid, &fragments); + let mut output = PhaseOutput::new(); + output + .add_broadcast(&proof_commitment) + .map_err(message_error)?; + for message in &zero_transmit { + output + .add_p2p(message.parties.receiver.as_u8(), message) + .map_err(message_error)?; + } + + state.correction_value = Some(correction_value); + state.self_proof_commitment = Some(proof_commitment); + state.zero_kept_2to3 = Some(zero_kept); + + phase_output_to_messages(&output, state.self_id) +} + +pub fn dkls_refresh_phase3( + state: &mut DklsRefreshState, + received: &[Message], +) -> Result, TssError> { + let phase2_input = messages_to_phase_input(received, state.self_id)?; + let mut zero_kept_2to3 = state + .zero_kept_2to3 + .take() + .ok_or(TssError::SessionComplete)?; + let (zero_kept_3to4, zero_transmit, mul_kept, mul_transmit) = state + .party + .refresh_complete_phase3(&state.refresh_sid, &zero_kept_2to3); + for kept in zero_kept_2to3.values_mut() { + kept.seed.zeroize(); + kept.salt.zeroize(); + } + + let mut output = PhaseOutput::new(); + for message in &zero_transmit { + output + .add_p2p(message.parties.receiver.as_u8(), message) + .map_err(message_error)?; + } + for message in &mul_transmit { + output + .add_p2p(message.parties.receiver.as_u8(), message) + .map_err(message_error)?; + } + + state.phase2_input = Some(phase2_input); + state.zero_kept_3to4 = Some(zero_kept_3to4); + state.mul_kept_3to4 = Some(mul_kept); + + phase_output_to_messages(&output, state.self_id) +} + +/// Finish a DKLs23 complete refresh: rebuild the OT state, apply the share +/// correction and derive the refreshed public key package. +/// +/// dkls23's `refresh_complete_phase4` returns only the refreshed `Party`; the +/// package is recomputed here from the broadcast proof commitments, because +/// every verifying share moves by its party's correction point while the group +/// key stays put. The result is self-checked against the new party before it +/// is returned, so a package that disagrees with the share fails closed. +pub fn dkls_refresh_phase4( + mut state: DklsRefreshState, + received: &[Message], +) -> Result<(Party, DklsPublicKeyPackage, UnifiedPublicKeyPackage), TssError> { + let phase2_input = state.phase2_input.take().ok_or(TssError::SessionComplete)?; + let phase3_input = messages_to_phase_input(received, state.self_id)?; + let self_index = identifier_to_party_index(state.self_id)?; + let correction_value = state + .correction_value + .take() + .ok_or(TssError::SessionComplete)?; + let zero_kept = state + .zero_kept_3to4 + .take() + .ok_or(TssError::SessionComplete)?; + let mul_kept = state + .mul_kept_3to4 + .take() + .ok_or(TssError::SessionComplete)?; + + let mut proofs_commitments = vec![state + .self_proof_commitment + .take() + .ok_or(TssError::SessionComplete)?]; + let mut zero_received_phase2 = Vec::new(); + let mut zero_received_phase3 = Vec::new(); + let mut mul_received = Vec::new(); + + for sender in 1..=state.share_count { + if sender == self_index.as_u8() { + continue; + } + proofs_commitments.push(phase_input_broadcast::( + &phase2_input, + sender, + )?); + zero_received_phase2.push(phase_input_p2p::( + &phase2_input, + sender, + )?); + zero_received_phase3.push(phase_input_p2p::( + &phase3_input, + sender, + )?); + mul_received.push(phase_input_p2p::( + &phase3_input, + sender, + )?); + } + + let party = state + .party + .refresh_complete_phase4( + &state.refresh_sid, + &correction_value, + &proofs_commitments, + &zero_kept, + &zero_received_phase2, + &zero_received_phase3, + &mul_kept, + &mul_received, + ) + .map_err(abort_to_error)?; + + let package = refresh_public_key_package(&state.pubkey, &proofs_commitments)?; + let expected_share = (ProjectivePoint::GENERATOR * party.poly_point).to_affine(); + if package.verifying_share(party.party_index) != Some(&expected_share) + || party.pk != *package.verifying_key() + { + return Err(TssError::InvalidShare); + } + + let unified = convert_public_key_package(&package)?; + Ok((party, package, unified)) +} + +/// Apply the refresh corrections to a public key package. +/// +/// Each party's proof commitment carries `G * correction_j`; the refreshed +/// verifying share is the old one moved by that point. The group key is +/// unchanged because the corrections are shares of zero, which +/// `refresh_complete_phase4` has already verified. +pub(crate) fn refresh_public_key_package( + old: &DklsPublicKeyPackage, + proofs_commitments: &[ProofCommitment], +) -> Result { + let share_count = old.share_count(); + if proofs_commitments.len() != usize::from(share_count) { + return Err(TssError::InvalidConfig( + "refresh needs one proof commitment per party".into(), + )); + } + + let mut verifying_shares: BTreeMap = BTreeMap::new(); + for proof_commitment in proofs_commitments { + let index = proof_commitment.index; + if index.as_u8() > share_count { + return Err(TssError::InvalidIdentifier); + } + let old_share = old + .verifying_share(index) + .ok_or_else(|| TssError::DeserializeFailed("missing verifying share".into()))?; + let refreshed = (ProjectivePoint::from(*old_share) + + ProjectivePoint::from(proof_commitment.proof.point)) + .to_affine(); + if verifying_shares.insert(index, refreshed).is_some() { + return Err(TssError::InvalidConfig( + "duplicate proof commitment for party".into(), + )); + } + } + + let parameters = Parameters::new(old.threshold(), share_count) + .map_err(|_| TssError::InvalidConfig("invalid DKLs23 parameters".into()))?; + Ok(DklsPublicKeyPackage::new( + *old.verifying_key(), + verifying_shares, + parameters, + )) +} + pub fn dkls_sign_new<'a>( party: &'a Party, sign_id: Vec, @@ -728,6 +1044,133 @@ mod tests { } } + fn synthetic_package(shares: &[Scalar]) -> DklsPublicKeyPackage { + let mut verifying_shares = BTreeMap::new(); + let mut secret = Scalar::ZERO; + for (position, share) in shares.iter().enumerate() { + let index = PartyIndex::new(u8::try_from(position + 1).unwrap()).unwrap(); + verifying_shares.insert(index, (ProjectivePoint::GENERATOR * share).to_affine()); + secret += share; + } + DklsPublicKeyPackage::new( + (ProjectivePoint::GENERATOR * secret).to_affine(), + verifying_shares, + Parameters::new(2, u8::try_from(shares.len()).unwrap()).unwrap(), + ) + } + + fn synthetic_proof(index: u8, correction: Scalar) -> ProofCommitment { + ProofCommitment { + index: PartyIndex::new(index).unwrap(), + proof: dkls23_secp256k1::utilities::proofs::DLogProof { + point: (ProjectivePoint::GENERATOR * correction).to_affine(), + rand_commitments: Vec::new(), + proofs: Vec::new(), + }, + commitment: [0u8; 32], + } + } + + #[test] + fn refresh_public_key_package_adds_correction_points() { + let shares = [Scalar::from(5u64), Scalar::from(8u64), Scalar::from(13u64)]; + let corrections = [Scalar::from(3u64), Scalar::from(21u64), Scalar::from(34u64)]; + let old = synthetic_package(&shares); + let proofs: Vec = corrections + .iter() + .enumerate() + .map(|(position, correction)| { + synthetic_proof(u8::try_from(position + 1).unwrap(), *correction) + }) + .collect(); + + let refreshed = refresh_public_key_package(&old, &proofs).unwrap(); + + assert_eq!(refreshed.verifying_key(), old.verifying_key()); + assert_eq!(refreshed.threshold(), old.threshold()); + assert_eq!(refreshed.share_count(), old.share_count()); + for (position, (share, correction)) in shares.iter().zip(&corrections).enumerate() { + let index = PartyIndex::new(u8::try_from(position + 1).unwrap()).unwrap(); + assert_eq!( + refreshed.verifying_share(index).unwrap(), + &(ProjectivePoint::GENERATOR * (share + correction)).to_affine() + ); + } + } + + #[test] + fn refresh_public_key_package_rejects_wrong_proof_sets() { + let old = synthetic_package(&[Scalar::from(5u64), Scalar::from(8u64)]); + + assert!(matches!( + refresh_public_key_package(&old, &[synthetic_proof(1, Scalar::ONE)]), + Err(TssError::InvalidConfig(_)) + )); + assert!(matches!( + refresh_public_key_package( + &old, + &[ + synthetic_proof(1, Scalar::ONE), + synthetic_proof(1, Scalar::ONE) + ] + ), + Err(TssError::InvalidConfig(_)) + )); + assert_eq!( + refresh_public_key_package( + &old, + &[ + synthetic_proof(1, Scalar::ONE), + synthetic_proof(3, Scalar::ONE) + ] + ) + .unwrap_err(), + TssError::InvalidIdentifier + ); + } + + #[test] + fn test_refresh_phase1_rejects_empty_session_id_and_shape_mismatch() { + let threshold = 2; + let share_count = 2; + let session_id = vec![7u8; 32]; + let participants = [id(1), id(2)]; + + let mut states = BTreeMap::new(); + let mut outputs = BTreeMap::new(); + for self_id in participants { + let (state, messages) = + dkls_dkg_phase1(threshold, share_count, self_id, session_id.clone()).unwrap(); + states.insert(self_id, state); + outputs.insert(self_id, messages); + } + for phase in [dkls_dkg_phase2, dkls_dkg_phase3] { + let inputs = route_messages(&outputs, &participants); + outputs = BTreeMap::new(); + for self_id in participants { + let output = phase( + states.get_mut(&self_id).unwrap(), + inputs.get(&self_id).unwrap(), + ) + .unwrap(); + outputs.insert(self_id, output); + } + } + let inputs = route_messages(&outputs, &participants); + let (party, package, _) = + dkls_dkg_phase4(states.remove(&id(1)).unwrap(), inputs.get(&id(1)).unwrap()).unwrap(); + + assert!(matches!( + dkls_refresh_phase1(party.clone(), package.clone(), b""), + Err(TssError::InvalidConfig(_)) + )); + let other_shape = synthetic_package(&[Scalar::ONE, Scalar::ONE, Scalar::ONE]); + assert!(matches!( + dkls_refresh_phase1(party, other_shape, b"sid"), + Err(TssError::InvalidConfig(_)) + )); + } + #[test] fn test_dkg_phase1_rejects_self_id_outside_share_count() { assert!(matches!( diff --git a/libtss/src/keyshare.rs b/libtss/src/keyshare.rs index 50b501c..ed83d43 100644 --- a/libtss/src/keyshare.rs +++ b/libtss/src/keyshare.rs @@ -101,6 +101,43 @@ impl KeyShareHandle { self.public_key_package.verifying_key().to_vec() } + /// Return the 32-byte BIP-32 chain code this share derives children with. + /// + /// Pair it with [`group_verifying_key`](Self::group_verifying_key) to build + /// a BIP-32 extended public key whose off-MPC child derivation matches the + /// child shares each party derives via [`derive_child`](crate::derive_child). + /// The chain code is public material. + /// + /// Supported for exactly the ciphersuites `derive_child` supports: + /// - `Secp256k1ECDSA` / `Secp256r1ECDSA` (DKLs23): the chain code established + /// at DKG time. Children carry the BIP-32 `IR` chain code, so an external + /// CKDpub agrees with `derive_child` at any depth. + /// - `Secp256k1Taproot` (FROST): `SHA-256(group verifying key)`. Because the + /// chain code is re-derived from each child's key rather than carried as + /// `IR`, an external CKDpub agrees with `derive_child` at depth 1 only. + /// + /// Every other ciphersuite has no BIP-32 derivation and returns + /// [`TssError::ProtocolMismatch`]. + pub fn chain_code(&self) -> Result, TssError> { + match self.suite { + Ciphersuite::Secp256k1ECDSA | Ciphersuite::Secp256r1ECDSA => REGISTRY + .with::(self.id, |inner| match inner { + KeyShareInner::DKLs23(party, _) => { + Ok(party.derivation_data.chain_code.to_vec()) + } + KeyShareInner::DKLs23Secp256r1(party, _) => { + Ok(party.derivation_data.chain_code.to_vec()) + } + _ => Err(TssError::ProtocolMismatch), + })?, + Ciphersuite::Secp256k1Taproot => Ok(crate::derive::taproot_chain_code( + self.public_key_package.verifying_key(), + ) + .to_vec()), + _ => Err(TssError::ProtocolMismatch), + } + } + pub fn ciphersuite(&self) -> Ciphersuite { self.suite } @@ -847,6 +884,7 @@ mod tests { assert!(handle.identifier().as_u16() > 0); assert!(!handle.verifying_share().is_empty()); assert!(!handle.group_verifying_key().is_empty()); + assert_eq!(handle.chain_code().unwrap().len(), 32); assert_eq!( handle .public_key_package() @@ -864,6 +902,7 @@ mod tests { assert!(handle.identifier().as_u16() > 0); assert!(!handle.verifying_share().is_empty()); assert!(!handle.group_verifying_key().is_empty()); + assert_eq!(handle.chain_code().unwrap().len(), 32); assert_eq!( handle .public_key_package() @@ -897,6 +936,7 @@ mod tests { assert_eq!(imported.verifying_share(), handle.verifying_share()); assert_eq!(imported.group_verifying_key(), handle.group_verifying_key()); assert_eq!(imported.ciphersuite(), handle.ciphersuite()); + assert_eq!(imported.chain_code().unwrap(), handle.chain_code().unwrap()); } #[test] diff --git a/libtss/src/lib.rs b/libtss/src/lib.rs index 29cb8bc..f0e4290 100644 --- a/libtss/src/lib.rs +++ b/libtss/src/lib.rs @@ -8,6 +8,7 @@ pub mod frost; pub mod handle; pub mod keyshare; pub mod message; +pub mod repair; pub mod session; pub mod types; pub mod verify; @@ -24,6 +25,7 @@ pub use handle::{ }; pub use keyshare::{import_key_share, KeyShareHandle}; pub use message::{deserialize_messages, filter_for_participant, serialize_messages, Message}; +pub use repair::{repair_assemble, repair_helper_term}; pub use session::dkg::{DkgOutput, DkgSession}; pub use session::refresh::{RefreshOutput, RefreshSession}; pub use session::sign::{SignOutput, SignSession}; diff --git a/libtss/src/repair.rs b/libtss/src/repair.rs new file mode 100644 index 0000000..b751805 --- /dev/null +++ b/libtss/src/repair.rs @@ -0,0 +1,631 @@ +//! Share repair for DKLs23 secp256k1 key shares. +//! +//! A threshold of surviving parties (the *helpers*) rebuilds the share of a +//! party whose key material is lost (the *target*) without any of them +//! learning it. Each helper `i` contributes one term +//! +//! ```text +//! term_i = poly_point_i · λ_i(target) + mask_i +//! ``` +//! +//! where `λ_i(target)` is the Lagrange coefficient interpolating the group's +//! secret polynomial at the target's index over the helper set, and the masks +//! are additive shares of zero drawn from the pairwise zero-share seeds the +//! helpers already hold from DKG ([`ZeroShare::compute`]). Only the sum of all +//! terms — which the target alone sees — equals `f(target)`; any proper subset +//! is uniformly random. +//! +//! The target assembles the terms into a *hollow* party: its secret share and +//! derivation data are correct, but it holds no zero-share seeds and no +//! two-party multiplication state, so it cannot sign +//! ([`SignSession::new_dkls`](crate::SignSession::new_dkls) refuses it). A +//! DKLs23 complete refresh across all `n` parties +//! ([`RefreshSession::new_dkls`](crate::RefreshSession::new_dkls)) rebuilds +//! that state and re-randomises every share while keeping the group key. +//! +//! Every gate fails closed: the assembled share must satisfy +//! `G · f(target) == verifying_share(target)` before a handle is created, so a +//! wrong helper set, a corrupted term or a mismatched session id yields +//! [`TssError::InvalidShare`] rather than an unusable share. + +use std::collections::BTreeMap; + +use dkls23_secp256k1::protocols::derivation::DerivData; +use dkls23_secp256k1::protocols::{ + Parameters, Party, PartyIndex, PublicKeyPackage as DklsPublicKeyPackage, +}; +use dkls23_secp256k1::utilities::zero_shares::ZeroShare; +use k256::{AffinePoint, ProjectivePoint, Scalar}; +use zeroize::Zeroizing; + +use crate::dkls::{identifier_to_party_index, scalar_from_message}; +use crate::keyshare::{self, KeyShareHandle}; +use crate::types::{Ciphersuite, Identifier, PublicKeyPackage}; +use crate::TssError; + +/// Domain separator prepended to the repair session id when deriving the +/// zero-share masks, keeping them disjoint from signing's zero shares even if +/// a repair id ever collided with a sign id. +pub const REPAIR_MASK_DOMAIN: &[u8] = b"libtss/dkls23-repair/v1"; + +const CHAIN_CODE_LEN: usize = 32; +const SCALAR_LEN: usize = 32; + +/// Compute this helper's masked repair term for `target`. +/// +/// `helpers` is the full helper set (including the caller, excluding the +/// target); every helper must pass the identical set and `repair_sid`, or the +/// masks do not cancel and assembly fails closed. Returns the 32-byte +/// big-endian scalar `poly_point · λ_self(target) + mask`. +/// +/// Fails with [`TssError::InvalidShare`] if the caller lacks a zero-share seed +/// pair with any helper counterparty — a hollow (freshly repaired, not yet +/// refreshed) share, or one whose group membership does not cover the helper +/// set. `ZeroShare::compute` would silently skip such a counterparty and the +/// term would leave `poly_point · λ` unmasked, so it is refused instead. +pub fn repair_helper_term( + key_share: &KeyShareHandle, + helpers: &[Identifier], + target: Identifier, + repair_sid: &[u8], +) -> Result>, TssError> { + if key_share.ciphersuite() != Ciphersuite::Secp256k1ECDSA { + return Err(TssError::ProtocolMismatch); + } + if repair_sid.is_empty() { + return Err(TssError::InvalidConfig( + "repair session_id must not be empty".into(), + )); + } + + let party = keyshare::clone_dkls_party(key_share)?; + let share_count = party.parameters.share_count; + let self_id = key_share.identifier(); + let helper_indices = validate_helpers(helpers, target, self_id, &party.parameters)?; + + let counterparties: Vec = helper_indices + .iter() + .copied() + .filter(|index| *index != party.party_index) + .collect(); + for counterparty in &counterparties { + let paired = party + .zero_share + .seeds + .iter() + .any(|pair| pair.index_counterparty == *counterparty); + if !paired { + return Err(TssError::InvalidShare); + } + } + + let target_index = identifier_to_party_index(target)?; + if target_index.as_u8() > share_count { + return Err(TssError::InvalidIdentifier); + } + + let xs: Vec = helper_indices.iter().map(index_scalar).collect(); + let self_position = helper_indices + .iter() + .position(|index| *index == party.party_index) + .ok_or_else(|| TssError::InvalidConfig("helpers must include this party".into()))?; + let lambda = lagrange_coefficient(&xs, self_position, index_scalar(&target_index))?; + + let mask_sid = [REPAIR_MASK_DOMAIN, repair_sid].concat(); + let mask = party + .zero_share + .compute::(&counterparties, &mask_sid); + let term = Zeroizing::new(party.poly_point * lambda + mask); + + Ok(Zeroizing::new(term.to_bytes().to_vec())) +} + +/// Assemble the helpers' terms into a hollow key share for `target`. +/// +/// `pubkey_package` and `chain_code` are the group's public material as every +/// helper reports them; `terms` are the outputs of +/// [`repair_helper_term`] from at least `min_signers` helpers. The sum of the +/// terms must satisfy `G · sum == verifying_share(target)` or the call fails +/// with [`TssError::InvalidShare`] — no handle is created for a share that +/// does not match the group. +/// +/// The returned share is hollow: it derives addresses and refreshes, but +/// signing with it is refused until a complete refresh has rebuilt its OT +/// state. `session_id` is recorded as the hollow party's session id and is +/// replaced by the refresh id on refresh. +pub fn repair_assemble( + suite: Ciphersuite, + pubkey_package: &PublicKeyPackage, + chain_code: &[u8], + target: Identifier, + terms: &[&[u8]], + session_id: &[u8], +) -> Result { + if suite != Ciphersuite::Secp256k1ECDSA || pubkey_package.suite() != suite { + return Err(TssError::ProtocolMismatch); + } + if session_id.is_empty() { + return Err(TssError::InvalidConfig( + "repair session_id must not be empty".into(), + )); + } + let chain_code: [u8; CHAIN_CODE_LEN] = chain_code + .try_into() + .map_err(|_| TssError::InvalidConfig("chain code must be 32 bytes".into()))?; + + let (parameters, verifying_shares) = dkls_shares_from_package(pubkey_package)?; + let target_index = identifier_to_party_index(target)?; + let target_share = *verifying_shares + .get(&target_index) + .ok_or(TssError::InvalidIdentifier)?; + + if terms.len() < usize::from(parameters.threshold) { + return Err(TssError::InvalidConfig( + "repair needs at least threshold terms".into(), + )); + } + let mut poly_point = Zeroizing::new(Scalar::ZERO); + for term in terms { + if term.len() != SCALAR_LEN { + return Err(TssError::DeserializeFailed( + "repair term must be a 32-byte scalar".into(), + )); + } + *poly_point += scalar_from_message(term)?; + } + + if (ProjectivePoint::GENERATOR * *poly_point).to_affine() != target_share { + return Err(TssError::InvalidShare); + } + + let verifying_key = point_from_bytes(pubkey_package.verifying_key())?; + let package = DklsPublicKeyPackage::new(verifying_key, verifying_shares, parameters.clone()); + let party = Party { + parameters, + party_index: target_index, + session_id: session_id.to_vec(), + poly_point: *poly_point, + pk: verifying_key, + zero_share: ZeroShare::initialize(Vec::new()), + mul_senders: BTreeMap::new(), + mul_receivers: BTreeMap::new(), + derivation_data: DerivData { + depth: 0, + child_number: 0, + parent_fingerprint: [0; 4], + poly_point: *poly_point, + pk: verifying_key, + chain_code, + }, + address: dkls23_secp256k1::compute_eth_address(&verifying_key), + }; + + keyshare::from_dkls_dkg(party, package) +} + +fn validate_helpers( + helpers: &[Identifier], + target: Identifier, + self_id: Identifier, + parameters: &Parameters, +) -> Result, TssError> { + if helpers.len() < usize::from(parameters.threshold) { + return Err(TssError::InvalidConfig( + "repair needs at least threshold helpers".into(), + )); + } + if !helpers.contains(&self_id) { + return Err(TssError::InvalidConfig( + "helpers must include this party".into(), + )); + } + if helpers.contains(&target) { + return Err(TssError::InvalidConfig( + "helpers must not include the target".into(), + )); + } + + let mut indices = Vec::with_capacity(helpers.len()); + for helper in helpers { + let index = identifier_to_party_index(*helper)?; + if index.as_u8() > parameters.share_count { + return Err(TssError::InvalidIdentifier); + } + if indices.contains(&index) { + return Err(TssError::InvalidConfig("helpers must be distinct".into())); + } + indices.push(index); + } + + Ok(indices) +} + +fn index_scalar(index: &PartyIndex) -> Scalar { + Scalar::from(u64::from(index.as_u8())) +} + +/// Lagrange coefficient of the node at position `i` of `xs` when +/// interpolating at `x_target` over all of `xs`. +pub(crate) fn lagrange_coefficient( + xs: &[Scalar], + i: usize, + x_target: Scalar, +) -> Result { + let x_i = xs + .get(i) + .ok_or_else(|| TssError::InvalidConfig("node position out of range".into()))?; + let mut numerator = Scalar::ONE; + let mut denominator = Scalar::ONE; + for (j, x_j) in xs.iter().enumerate() { + if j == i { + continue; + } + numerator *= x_target - x_j; + denominator *= x_i - x_j; + } + let inverse = Option::::from(denominator.invert()) + .ok_or_else(|| TssError::InvalidConfig("helpers must be distinct".into()))?; + + Ok(numerator * inverse) +} + +fn dkls_shares_from_package( + package: &PublicKeyPackage, +) -> Result<(Parameters, BTreeMap), TssError> { + let share_count = u8::try_from(package.verifying_shares().len()).map_err(|_| { + TssError::InvalidConfig("public key package exceeds DKLs23 share limit".into()) + })?; + let threshold = u8::try_from(package.min_signers()) + .map_err(|_| TssError::InvalidConfig("threshold exceeds DKLs23 limits".into()))?; + let parameters = Parameters::new(threshold, share_count) + .map_err(|_| TssError::InvalidConfig("invalid DKLs23 parameters".into()))?; + + let mut verifying_shares = BTreeMap::new(); + for index in 1..=share_count { + let identifier = Identifier::new(u16::from(index))?; + let share = package.verifying_share(identifier).ok_or_else(|| { + TssError::InvalidConfig("verifying shares must cover every party 1..=n".into()) + })?; + let party_index = PartyIndex::new(index).map_err(|_| TssError::InvalidIdentifier)?; + verifying_shares.insert(party_index, point_from_bytes(share)?); + } + + Ok((parameters, verifying_shares)) +} + +fn point_from_bytes(bytes: &[u8]) -> Result { + k256::PublicKey::from_sec1_bytes(bytes) + .map(|public_key| *public_key.as_affine()) + .map_err(|_| TssError::DeserializeFailed("invalid secp256k1 point encoding".into())) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + use crate::message::Message; + use crate::session::dkg::{DkgOutput, DkgSession}; + use crate::session::sign::SignSession; + use crate::types::ThresholdConfig; + + fn id(value: u16) -> Identifier { + Identifier::new(value).unwrap() + } + + fn route( + outputs: &BTreeMap>, + recipients: &[Identifier], + ) -> BTreeMap> { + let mut routed: BTreeMap> = + recipients.iter().map(|&r| (r, Vec::new())).collect(); + for (&sender, messages) in outputs { + for msg in messages { + match msg.to { + Some(to) => routed.get_mut(&to).unwrap().push(msg.clone()), + None => { + for &r in recipients { + if r != sender { + routed.get_mut(&r).unwrap().push(msg.clone()); + } + } + } + } + } + } + routed + } + + fn run_dkg(threshold: u16, share_count: u16) -> BTreeMap { + let config = ThresholdConfig { + min_signers: threshold, + max_signers: share_count, + suite: Ciphersuite::Secp256k1ECDSA, + }; + let participants: Vec = (1..=share_count).map(id).collect(); + let mut sessions = BTreeMap::new(); + let mut outputs = BTreeMap::new(); + for &pid in &participants { + let (session, messages) = DkgSession::new(&config, pid, Some(b"repair-dkg")).unwrap(); + sessions.insert(pid, session); + outputs.insert(pid, messages); + } + + let mut shares = BTreeMap::new(); + while shares.len() < participants.len() { + let inputs = route(&outputs, &participants); + outputs = BTreeMap::new(); + for &pid in &participants { + match sessions + .get_mut(&pid) + .unwrap() + .next(inputs.get(&pid).unwrap()) + .unwrap() + { + DkgOutput::Continue(messages) => { + outputs.insert(pid, messages); + } + DkgOutput::Complete { key_share, .. } => { + shares.insert(pid, key_share); + } + } + } + } + shares + } + + fn helper_terms( + shares: &BTreeMap, + helpers: &[Identifier], + target: Identifier, + sid: &[u8], + ) -> Vec>> { + helpers + .iter() + .map(|helper| repair_helper_term(&shares[helper], helpers, target, sid).unwrap()) + .collect() + } + + fn assemble( + shares: &BTreeMap, + target: Identifier, + terms: &[Zeroizing>], + chain_code: &[u8], + sid: &[u8], + ) -> Result { + let reference = shares.values().next().unwrap(); + let term_slices: Vec<&[u8]> = terms.iter().map(|term| term.as_slice()).collect(); + repair_assemble( + Ciphersuite::Secp256k1ECDSA, + reference.public_key_package(), + chain_code, + target, + &term_slices, + sid, + ) + } + + #[test] + fn lagrange_coefficient_matches_direct_interpolation() { + let coefficients = [Scalar::from(7u64), Scalar::from(3u64), Scalar::from(11u64)]; + let evaluate = |x: Scalar| coefficients[0] + coefficients[1] * x + coefficients[2] * x * x; + let xs: Vec = [1u64, 3, 4].into_iter().map(Scalar::from).collect(); + let target = Scalar::from(2u64); + + let mut interpolated = Scalar::ZERO; + for (i, &x_i) in xs.iter().enumerate() { + interpolated += evaluate(x_i) * lagrange_coefficient(&xs, i, target).unwrap(); + } + + assert_eq!(interpolated, evaluate(target)); + } + + #[test] + fn lagrange_coefficient_rejects_duplicate_nodes_and_bad_position() { + let xs = [Scalar::from(1u64), Scalar::from(1u64)]; + assert!(matches!( + lagrange_coefficient(&xs, 0, Scalar::from(2u64)), + Err(TssError::InvalidConfig(_)) + )); + assert!(matches!( + lagrange_coefficient(&xs, 2, Scalar::from(2u64)), + Err(TssError::InvalidConfig(_)) + )); + } + + #[test] + fn helper_term_validation() { + let shares = run_dkg(3, 5); + let share = &shares[&id(1)]; + let sid = b"repair-sid"; + + let cases: Vec<(&str, Vec, Identifier, &[u8])> = vec![ + ("too few helpers", vec![id(1), id(3)], id(2), sid), + ("missing self", vec![id(3), id(4), id(5)], id(2), sid), + ("contains target", vec![id(1), id(2), id(3)], id(2), sid), + ("duplicate helper", vec![id(1), id(1), id(3)], id(2), sid), + ("empty sid", vec![id(1), id(3), id(4)], id(2), b""), + ]; + for (name, helpers, target, sid) in cases { + assert!( + matches!( + repair_helper_term(share, &helpers, target, sid), + Err(TssError::InvalidConfig(_)) + ), + "{name}" + ); + } + + assert_eq!( + repair_helper_term(share, &[id(1), id(3), id(6)], id(2), sid), + Err(TssError::InvalidIdentifier) + ); + assert_eq!( + repair_helper_term(share, &[id(1), id(3), id(4)], id(6), sid), + Err(TssError::InvalidIdentifier) + ); + } + + #[test] + fn helper_term_rejects_non_dkls_share() { + let config = ThresholdConfig { + min_signers: 2, + max_signers: 3, + suite: Ciphersuite::Ed25519, + }; + let (frost_shares, pubkey) = crate::frost::frost_generate_with_dealer(&config).unwrap(); + let share = + keyshare::from_frost_dkg(frost_shares.into_iter().next().unwrap(), &pubkey).unwrap(); + + assert_eq!( + repair_helper_term(&share, &[id(1), id(2)], id(3), b"sid"), + Err(TssError::ProtocolMismatch) + ); + } + + #[test] + fn terms_are_masked_and_sum_to_share() { + let shares = run_dkg(3, 5); + let helpers = [id(1), id(3), id(4)]; + let target = id(2); + let sid = b"repair-sid"; + let terms = helper_terms(&shares, &helpers, target, sid); + + let xs: Vec = helpers + .iter() + .map(|helper| Scalar::from(u64::from(helper.as_u16()))) + .collect(); + let target_x = Scalar::from(u64::from(target.as_u16())); + let mut sum = Scalar::ZERO; + for (position, (helper, term)) in helpers.iter().zip(&terms).enumerate() { + let scalar = scalar_from_message(term).unwrap(); + let party = keyshare::clone_dkls_party(&shares[helper]).unwrap(); + let unmasked = + party.poly_point * lagrange_coefficient(&xs, position, target_x).unwrap(); + assert_ne!(scalar, unmasked, "term from {helper} is not masked"); + sum += scalar; + } + + let lost = keyshare::clone_dkls_party(&shares[&target]).unwrap(); + assert_eq!(sum, lost.poly_point); + } + + #[test] + fn assemble_rebuilds_the_lost_share() { + let shares = run_dkg(3, 5); + let helpers = [id(1), id(3), id(4)]; + let target = id(2); + let sid = b"repair-sid"; + let chain_code = shares[&id(1)].chain_code().unwrap(); + let terms = helper_terms(&shares, &helpers, target, sid); + + let repaired = assemble(&shares, target, &terms, &chain_code, sid).unwrap(); + + assert_eq!(repaired.identifier(), target); + assert_eq!( + repaired.verifying_share(), + shares[&target].verifying_share() + ); + assert_eq!( + repaired.group_verifying_key(), + shares[&target].group_verifying_key() + ); + assert_eq!(repaired.chain_code().unwrap(), chain_code); + let party = keyshare::clone_dkls_party(&repaired).unwrap(); + assert!(party.mul_senders.is_empty()); + assert!(party.zero_share.seeds.is_empty()); + assert_eq!(party.session_id, sid.to_vec()); + } + + #[test] + fn assemble_rejects_bad_inputs() { + let shares = run_dkg(3, 5); + let helpers = [id(1), id(3), id(4)]; + let target = id(2); + let sid = b"repair-sid"; + let chain_code = shares[&id(1)].chain_code().unwrap(); + let terms = helper_terms(&shares, &helpers, target, sid); + + let mut tampered = terms.clone(); + tampered[0][31] ^= 1; + assert_eq!( + assemble(&shares, target, &tampered, &chain_code, sid).unwrap_err(), + TssError::InvalidShare + ); + + assert!(matches!( + assemble(&shares, target, &terms[..2], &chain_code, sid), + Err(TssError::InvalidConfig(_)) + )); + + assert!(matches!( + assemble(&shares, target, &terms, &chain_code[..31], sid), + Err(TssError::InvalidConfig(_)) + )); + + assert!(matches!( + assemble(&shares, target, &terms, &chain_code, b""), + Err(TssError::InvalidConfig(_)) + )); + + let mut mixed = terms.clone(); + mixed[2] = repair_helper_term(&shares[&id(4)], &helpers, target, b"other-sid").unwrap(); + assert_eq!( + assemble(&shares, target, &mixed, &chain_code, sid).unwrap_err(), + TssError::InvalidShare + ); + + assert_eq!( + assemble(&shares, id(3), &terms, &chain_code, sid).unwrap_err(), + TssError::InvalidShare + ); + + let mut wrong_set = helper_terms(&shares, &[id(1), id(3), id(5)], target, sid); + wrong_set[2] = terms[2].clone(); + assert_eq!( + assemble(&shares, target, &wrong_set, &chain_code, sid).unwrap_err(), + TssError::InvalidShare + ); + } + + #[test] + fn hollow_share_cannot_sign_or_help() { + let shares = run_dkg(3, 5); + let helpers = [id(1), id(3), id(4)]; + let target = id(2); + let sid = b"repair-sid"; + let chain_code = shares[&id(1)].chain_code().unwrap(); + let terms = helper_terms(&shares, &helpers, target, sid); + let hollow = assemble(&shares, target, &terms, &chain_code, sid).unwrap(); + + assert_eq!( + SignSession::new_dkls(&hollow, b"sign".to_vec(), &[id(1), id(3)], [9u8; 32]) + .map(|_| ()) + .unwrap_err(), + TssError::InvalidShare + ); + assert_eq!( + repair_helper_term(&hollow, &[id(2), id(3), id(4)], id(1), sid) + .map(|_| ()) + .unwrap_err(), + TssError::InvalidShare + ); + } + + #[test] + fn hollow_share_survives_export_import() { + let shares = run_dkg(2, 3); + let helpers = [id(1), id(3)]; + let target = id(2); + let sid = b"repair-sid"; + let chain_code = shares[&id(1)].chain_code().unwrap(); + let terms = helper_terms(&shares, &helpers, target, sid); + let hollow = assemble(&shares, target, &terms, &chain_code, sid).unwrap(); + + let exported = hollow.export().unwrap(); + let imported = keyshare::import_key_share(&exported, Ciphersuite::Secp256k1ECDSA).unwrap(); + + assert_eq!(imported.identifier(), target); + assert_eq!(imported.verifying_share(), hollow.verifying_share()); + } +} diff --git a/libtss/src/session/refresh.rs b/libtss/src/session/refresh.rs index 4cfe23b..6abc9df 100644 --- a/libtss/src/session/refresh.rs +++ b/libtss/src/session/refresh.rs @@ -1,7 +1,11 @@ +use crate::dkls::{ + dkls_refresh_phase1, dkls_refresh_phase2, dkls_refresh_phase3, dkls_refresh_phase4, + DklsRefreshState, +}; use crate::frost::{frost_apply_refresh, frost_refresh_with_dealer}; use crate::keyshare::{self, KeyShareHandle}; use crate::message::Message; -use crate::types::{Identifier, Protocol, PublicKeyPackage}; +use crate::types::{Ciphersuite, Identifier, Protocol, PublicKeyPackage}; use crate::TssError; pub enum RefreshOutput { @@ -16,6 +20,7 @@ enum RefreshInner { FrostApply { key_share: crate::frost::KeyShareHandle, }, + Dkls(Box>), Complete, } @@ -92,12 +97,83 @@ impl RefreshSession { }) } - /// Apply received refresh data. Called by each participant after receiving - /// the refresh share from the dealer. + /// Create a DKLs23 complete refresh session for a secp256k1 key share. + /// + /// All `n` parties of the group must run this together under the same + /// fresh `session_id`: the refreshed shares are salted with it and cannot + /// co-sign with shares from any other generation. The refresh rebuilds + /// the OT and zero-share state from scratch, which is what turns a hollow + /// share produced by [`repair_assemble`](crate::repair_assemble) into a + /// signing one. Three message rounds follow (`next()` is called three + /// times); the input handle stays valid and the output is a new one. + pub fn new_dkls( + key_share: &KeyShareHandle, + session_id: &[u8], + ) -> Result<(Self, Vec), TssError> { + if key_share.ciphersuite() != Ciphersuite::Secp256k1ECDSA { + return Err(TssError::ProtocolMismatch); + } + + let party = keyshare::clone_dkls_party(key_share)?; + let pubkey = keyshare::clone_dkls_pubkey(key_share)?; + let (state, messages) = dkls_refresh_phase1(party, pubkey, session_id)?; + + Ok(( + Self { + inner: RefreshInner::Dkls(Box::new(Some(state))), + round: 1, + }, + messages, + )) + } + + /// Advance the refresh with the messages received for this round. pub fn next(&mut self, received: &[Message]) -> Result { + match &mut self.inner { + RefreshInner::Complete => Err(TssError::SessionComplete), + RefreshInner::FrostApply { .. } => self.next_frost_apply(received), + RefreshInner::Dkls(_) => self.next_dkls(received), + } + } + + fn next_dkls(&mut self, received: &[Message]) -> Result { + let dkls_ref = match &mut self.inner { + RefreshInner::Dkls(state) => &mut **state, + _ => return Err(TssError::ProtocolMismatch), + }; + + match self.round { + 1 => { + let state = dkls_ref.as_mut().ok_or(TssError::SessionComplete)?; + let messages = dkls_refresh_phase2(state, received)?; + self.round = 2; + Ok(RefreshOutput::Continue(messages)) + } + 2 => { + let state = dkls_ref.as_mut().ok_or(TssError::SessionComplete)?; + let messages = dkls_refresh_phase3(state, received)?; + self.round = 3; + Ok(RefreshOutput::Continue(messages)) + } + 3 => { + let state = dkls_ref.take().ok_or(TssError::SessionComplete)?; + let (party, dkls_pubkey, public_keys) = dkls_refresh_phase4(state, received)?; + let key_share = keyshare::from_dkls_dkg(party, dkls_pubkey)?; + self.inner = RefreshInner::Complete; + self.round = 4; + Ok(RefreshOutput::Complete { + key_share, + public_keys, + }) + } + _ => Err(TssError::SessionComplete), + } + } + + fn next_frost_apply(&mut self, received: &[Message]) -> Result { let state = std::mem::replace(&mut self.inner, RefreshInner::Complete); match state { - RefreshInner::Complete => Err(TssError::SessionComplete), + RefreshInner::Complete | RefreshInner::Dkls(_) => Err(TssError::ProtocolMismatch), RefreshInner::FrostApply { key_share } => { let msg = received.first().ok_or_else(|| { TssError::DeserializeFailed("no refresh data received".into()) @@ -138,6 +214,14 @@ impl RefreshSession { self.round } + pub fn num_rounds(&self) -> u8 { + match &self.inner { + RefreshInner::FrostApply { .. } => 1, + RefreshInner::Dkls(_) => 4, + RefreshInner::Complete => 0, + } + } + pub fn is_complete(&self) -> bool { matches!(self.inner, RefreshInner::Complete) } @@ -145,10 +229,13 @@ impl RefreshSession { #[cfg(test)] mod tests { + use std::collections::BTreeMap; + use super::*; use crate::frost::{frost_aggregate, frost_commit, frost_generate_with_dealer, frost_sign}; use crate::keyshare; - use crate::types::{Ciphersuite, ThresholdConfig}; + use crate::session::dkg::{DkgOutput, DkgSession}; + use crate::types::ThresholdConfig; fn id(value: u16) -> Identifier { Identifier::new(value).unwrap() @@ -219,4 +306,186 @@ mod tests { Ok(()) } + + fn route( + outputs: &BTreeMap>, + recipients: &[Identifier], + ) -> BTreeMap> { + let mut routed: BTreeMap> = + recipients.iter().map(|&r| (r, Vec::new())).collect(); + for (&sender, messages) in outputs { + for msg in messages { + match msg.to { + Some(to) => routed.get_mut(&to).unwrap().push(msg.clone()), + None => { + for &r in recipients { + if r != sender { + routed.get_mut(&r).unwrap().push(msg.clone()); + } + } + } + } + } + } + routed + } + + fn run_dkls_dkg(threshold: u16, share_count: u16) -> BTreeMap { + let config = ThresholdConfig { + min_signers: threshold, + max_signers: share_count, + suite: Ciphersuite::Secp256k1ECDSA, + }; + let participants: Vec = (1..=share_count).map(id).collect(); + let mut sessions = BTreeMap::new(); + let mut outputs = BTreeMap::new(); + for &pid in &participants { + let (session, messages) = DkgSession::new(&config, pid, Some(b"refresh-dkg")).unwrap(); + sessions.insert(pid, session); + outputs.insert(pid, messages); + } + + let mut shares = BTreeMap::new(); + while shares.len() < participants.len() { + let inputs = route(&outputs, &participants); + outputs = BTreeMap::new(); + for &pid in &participants { + match sessions + .get_mut(&pid) + .unwrap() + .next(inputs.get(&pid).unwrap()) + .unwrap() + { + DkgOutput::Continue(messages) => { + outputs.insert(pid, messages); + } + DkgOutput::Complete { key_share, .. } => { + shares.insert(pid, key_share); + } + } + } + } + shares + } + + fn run_dkls_refresh( + shares: &BTreeMap, + sids: &BTreeMap, + ) -> Result, TssError> { + let participants: Vec = shares.keys().copied().collect(); + let mut sessions = BTreeMap::new(); + let mut outputs = BTreeMap::new(); + for &pid in &participants { + let (session, messages) = RefreshSession::new_dkls(&shares[&pid], sids[&pid])?; + assert_eq!(session.round(), 1); + assert_eq!(session.num_rounds(), 4); + sessions.insert(pid, session); + outputs.insert(pid, messages); + } + + let mut refreshed = BTreeMap::new(); + for _ in 0..3 { + let inputs = route(&outputs, &participants); + outputs = BTreeMap::new(); + for &pid in &participants { + match sessions + .get_mut(&pid) + .unwrap() + .next(inputs.get(&pid).unwrap())? + { + RefreshOutput::Continue(messages) => { + outputs.insert(pid, messages); + } + RefreshOutput::Complete { + key_share, + public_keys, + } => { + refreshed.insert(pid, (key_share, public_keys)); + } + } + } + } + assert_eq!(refreshed.len(), participants.len()); + for session in sessions.values() { + assert!(session.is_complete()); + assert_eq!(session.round(), 4); + } + Ok(refreshed) + } + + #[test] + fn dkls_refresh_session_keeps_group_key_and_moves_shares() -> Result<(), TssError> { + let shares = run_dkls_dkg(2, 3); + let sids: BTreeMap = shares + .keys() + .map(|&pid| (pid, b"refresh-sid".as_slice())) + .collect(); + + let refreshed = run_dkls_refresh(&shares, &sids)?; + + let reference = &refreshed[&id(1)].1; + for (pid, (key_share, public_keys)) in &refreshed { + assert_eq!(public_keys, reference); + assert_eq!(key_share.identifier(), *pid); + assert_eq!( + key_share.group_verifying_key(), + shares[pid].group_verifying_key() + ); + assert_eq!(key_share.chain_code()?, shares[pid].chain_code()?); + assert_ne!(key_share.verifying_share(), shares[pid].verifying_share()); + assert_eq!( + Some(key_share.verifying_share().as_slice()), + public_keys.verifying_share(*pid) + ); + assert_eq!(key_share.public_key_package(), public_keys); + } + assert_eq!( + shares[&id(1)].identifier(), + id(1), + "input handle stays valid" + ); + + Ok(()) + } + + #[test] + fn dkls_refresh_rejects_wrong_suite_and_empty_session_id() { + let config = ThresholdConfig { + min_signers: 2, + max_signers: 3, + suite: Ciphersuite::Ed25519, + }; + let (frost_shares, pubkey) = frost_generate_with_dealer(&config).unwrap(); + let frost_share = + keyshare::from_frost_dkg(frost_shares.into_iter().next().unwrap(), &pubkey).unwrap(); + assert!(matches!( + RefreshSession::new_dkls(&frost_share, b"sid"), + Err(TssError::ProtocolMismatch) + )); + + let shares = run_dkls_dkg(2, 2); + assert!(matches!( + RefreshSession::new_dkls(&shares[&id(1)], b""), + Err(TssError::InvalidConfig(_)) + )); + assert!(matches!( + RefreshSession::new_frost_receiver(&shares[&id(1)]), + Err(TssError::ProtocolMismatch) + )); + } + + #[test] + fn dkls_refresh_mixed_session_ids_abort() { + let shares = run_dkls_dkg(2, 3); + let mut sids: BTreeMap = shares + .keys() + .map(|&pid| (pid, b"refresh-sid".as_slice())) + .collect(); + sids.insert(id(3), b"other-sid"); + + assert!(matches!( + run_dkls_refresh(&shares, &sids), + Err(TssError::Abort { .. }) + )); + } } diff --git a/libtss/src/session/sign.rs b/libtss/src/session/sign.rs index 75f5a67..df55c84 100644 --- a/libtss/src/session/sign.rs +++ b/libtss/src/session/sign.rs @@ -154,6 +154,9 @@ impl SignSession { } let party = keyshare::clone_dkls_party(key_share)?; + if party.mul_senders.is_empty() { + return Err(TssError::InvalidShare); + } let (wrapper, messages) = DklsSignWrapper::new(party, sign_id, counterparties, message_hash)?; diff --git a/libtss/src/types.rs b/libtss/src/types.rs index 3a33ec6..f2d0a85 100644 --- a/libtss/src/types.rs +++ b/libtss/src/types.rs @@ -274,6 +274,10 @@ impl PublicKeyPackage { self.verifying_shares.get(&id).map(Vec::as_slice) } + pub fn verifying_shares(&self) -> &BTreeMap> { + &self.verifying_shares + } + pub fn min_signers(&self) -> u16 { self.min_signers } diff --git a/libtss/tests/dkls_repair_refresh.rs b/libtss/tests/dkls_repair_refresh.rs new file mode 100644 index 0000000..1cd5fb8 --- /dev/null +++ b/libtss/tests/dkls_repair_refresh.rs @@ -0,0 +1,206 @@ +//! End-to-end: a 3-of-5 DKLs23 group loses party 2's share, three survivors +//! repair it into a hollow share, a complete refresh across all five parties +//! turns it back into a signing share, and quorums with and without the +//! original survivors sign under the unchanged group key. + +use std::collections::BTreeMap; + +use libtss::session::dkg::{DkgOutput, DkgSession}; +use libtss::session::refresh::{RefreshOutput, RefreshSession}; +use libtss::session::sign::{SignOutput, SignSession}; +use libtss::types::{Ciphersuite, Identifier, ThresholdConfig}; +use libtss::{repair_assemble, repair_helper_term, KeyShareHandle, Message, TssError}; +use sha2::Digest; + +fn id(value: u16) -> Identifier { + Identifier::new(value).unwrap() +} + +fn route( + outputs: &BTreeMap>, + recipients: &[Identifier], +) -> BTreeMap> { + let mut routed: BTreeMap> = + recipients.iter().map(|&r| (r, Vec::new())).collect(); + for (&sender, messages) in outputs { + for msg in messages { + match msg.to { + Some(to) => routed.get_mut(&to).unwrap().push(msg.clone()), + None => { + for &r in recipients { + if r != sender { + routed.get_mut(&r).unwrap().push(msg.clone()); + } + } + } + } + } + } + routed +} + +fn run_dkg(threshold: u16, share_count: u16) -> BTreeMap { + let config = ThresholdConfig { + min_signers: threshold, + max_signers: share_count, + suite: Ciphersuite::Secp256k1ECDSA, + }; + let participants: Vec = (1..=share_count).map(id).collect(); + let mut sessions = BTreeMap::new(); + let mut outputs = BTreeMap::new(); + for &pid in &participants { + let (session, messages) = DkgSession::new(&config, pid, Some(b"e2e-dkg")).unwrap(); + sessions.insert(pid, session); + outputs.insert(pid, messages); + } + + let mut shares = BTreeMap::new(); + while shares.len() < participants.len() { + let inputs = route(&outputs, &participants); + outputs = BTreeMap::new(); + for &pid in &participants { + match sessions + .get_mut(&pid) + .unwrap() + .next(inputs.get(&pid).unwrap()) + .unwrap() + { + DkgOutput::Continue(messages) => { + outputs.insert(pid, messages); + } + DkgOutput::Complete { key_share, .. } => { + shares.insert(pid, key_share); + } + } + } + } + shares +} + +fn run_refresh( + shares: &BTreeMap, + session_id: &[u8], +) -> BTreeMap { + let participants: Vec = shares.keys().copied().collect(); + let mut sessions = BTreeMap::new(); + let mut outputs = BTreeMap::new(); + for &pid in &participants { + let (session, messages) = RefreshSession::new_dkls(&shares[&pid], session_id).unwrap(); + sessions.insert(pid, session); + outputs.insert(pid, messages); + } + + let mut refreshed = BTreeMap::new(); + while refreshed.len() < participants.len() { + let inputs = route(&outputs, &participants); + outputs = BTreeMap::new(); + for &pid in &participants { + match sessions + .get_mut(&pid) + .unwrap() + .next(inputs.get(&pid).unwrap()) + .unwrap() + { + RefreshOutput::Continue(messages) => { + outputs.insert(pid, messages); + } + RefreshOutput::Complete { key_share, .. } => { + refreshed.insert(pid, key_share); + } + } + } + } + refreshed +} + +fn run_sign( + shares: &BTreeMap, + signers: &[Identifier], + message: &[u8], +) -> Result, TssError> { + let hash: [u8; 32] = sha2::Sha256::digest(message).into(); + let mut sessions = BTreeMap::new(); + let mut outputs = BTreeMap::new(); + for &pid in signers { + let counterparties: Vec = + signers.iter().copied().filter(|c| *c != pid).collect(); + let (session, messages) = + SignSession::new_dkls(&shares[&pid], b"e2e-sign".to_vec(), &counterparties, hash)?; + sessions.insert(pid, session); + outputs.insert(pid, messages); + } + + loop { + let inputs = route(&outputs, signers); + outputs = BTreeMap::new(); + for &pid in signers { + match sessions + .get_mut(&pid) + .unwrap() + .next(inputs.get(&pid).unwrap())? + { + SignOutput::Continue(messages) => { + outputs.insert(pid, messages); + } + SignOutput::Complete(signature) => return Ok(signature.as_bytes().to_vec()), + } + } + } +} + +#[test] +fn repair_then_refresh_restores_a_signing_group() { + let original = run_dkg(3, 5); + let group_key = original[&id(1)].group_verifying_key(); + let chain_code = original[&id(1)].chain_code().unwrap(); + let lost_verifying_share = original[&id(2)].verifying_share(); + + let mut survivors = original; + survivors.remove(&id(2)); + + let helpers = [id(1), id(3), id(4)]; + let repair_sid = b"e2e-repair-sid"; + let terms: Vec<_> = helpers + .iter() + .map(|helper| repair_helper_term(&survivors[helper], &helpers, id(2), repair_sid).unwrap()) + .collect(); + let term_slices: Vec<&[u8]> = terms.iter().map(|term| term.as_slice()).collect(); + let hollow = repair_assemble( + Ciphersuite::Secp256k1ECDSA, + survivors[&id(1)].public_key_package(), + &chain_code, + id(2), + &term_slices, + repair_sid, + ) + .unwrap(); + + assert_eq!(hollow.identifier(), id(2)); + assert_eq!(hollow.verifying_share(), lost_verifying_share); + assert_eq!(hollow.group_verifying_key(), group_key); + + let mut pre_refresh = survivors; + pre_refresh.insert(id(2), hollow); + assert_eq!( + run_sign(&pre_refresh, &[id(1), id(2), id(3)], b"before refresh").unwrap_err(), + TssError::InvalidShare + ); + + let refreshed = run_refresh(&pre_refresh, b"e2e-refresh-sid"); + + for (pid, share) in &refreshed { + assert_eq!(share.identifier(), *pid); + assert_eq!(share.group_verifying_key(), group_key); + assert_eq!(share.chain_code().unwrap(), chain_code); + assert_ne!(share.verifying_share(), pre_refresh[pid].verifying_share()); + } + + let message = b"after refresh"; + for signers in [[id(1), id(2), id(3)], [id(2), id(4), id(5)]] { + let signature = run_sign(&refreshed, &signers, message).unwrap(); + assert!( + libtss::verify(Ciphersuite::Secp256k1ECDSA, message, &signature, &group_key).unwrap(), + "quorum {signers:?} must sign under the original group key" + ); + } +} diff --git a/libtss/tests/protocol_tests.rs b/libtss/tests/protocol_tests.rs index fb74eb2..c484a01 100644 --- a/libtss/tests/protocol_tests.rs +++ b/libtss/tests/protocol_tests.rs @@ -848,7 +848,7 @@ fn concurrent_frost_sign_sessions() { for (&pid, (data, suite)) in exported { local_shares.insert(pid, libtss::import_key_share(data, *suite).unwrap()); } - let msg = format!("concurrent-sign-{}", thread_idx); + let msg = format!("concurrent-sign-{thread_idx}"); run_frost_sign(&local_shares, &[id(1), id(2)], msg.as_bytes()) }) }) diff --git a/spec/03-api-design.md b/spec/03-api-design.md index 7f91c85..27bad2e 100644 --- a/spec/03-api-design.md +++ b/spec/03-api-design.md @@ -222,6 +222,12 @@ impl KeyShareHandle { /// Group public key (combined verification key). pub fn group_verifying_key(&self) -> Vec; + /// 32-byte BIP-32 chain code paired with the group key; together they form + /// an xpub whose off-MPC child derivation matches `derive_child`. Only the + /// BIP-32-capable suites (Secp256k1ECDSA, Secp256r1ECDSA, Secp256k1Taproot); + /// others return `ProtocolMismatch`. + pub fn chain_code(&self) -> Result, TssError>; + /// Full public key package (group key + all verifying shares). pub fn public_key_package(&self) -> &PublicKeyPackage; @@ -459,11 +465,13 @@ pub enum RefreshOutput { } impl RefreshSession { - /// Interactive share refresh (recommended mode). - /// - /// FROST: DKG-based refresh with zero-constant polynomial. - /// DKLs23: Complete refresh (re-initializes OT/multiplication state). - pub fn new(key_share: &KeyShareHandle) -> Result<(Self, Vec), TssError>; + /// FROST dealer refresh: compute and distribute refresh shares to + /// `participants` (one round; the dealer applies its own via `next()`). + /// Returns `TssError::ProtocolMismatch` for DKLs23 key shares. + pub fn new_frost( + key_share: &KeyShareHandle, + participants: &[Identifier], + ) -> Result<(Self, Vec), TssError>; /// Create a FROST refresh receiver session (non-dealer participant). /// The receiver waits for messages from the dealer refresh and does @@ -471,12 +479,29 @@ impl RefreshSession { /// Returns `TssError::ProtocolMismatch` for DKLs23 key shares. pub fn new_frost_receiver(key_share: &KeyShareHandle) -> Result; + /// DKLs23 complete refresh (secp256k1 only): re-runs DKG with a + /// zero-constant polynomial and re-initializes the OT/multiplication + /// state. All `n` parties must run it under the same fresh `session_id`; + /// three message rounds follow. The input handle stays valid and the + /// completed output is a new handle. Returns `ProtocolMismatch` for + /// FROST and secp256r1 shares, `InvalidConfig` for an empty session id. + pub fn new_dkls( + key_share: &KeyShareHandle, + session_id: &[u8], + ) -> Result<(Self, Vec), TssError>; + pub fn next(&mut self, received: &[Message]) -> Result; pub fn round(&self) -> u8; + /// 1 for the FROST dealer/receiver flow, 4 for DKLs23, 0 once complete. pub fn num_rounds(&self) -> u8; } ``` +Refreshed DKLs23 shares carry `session_id` as their new party session id, +which salts signing. Shares from different refresh generations therefore +never co-sign: a partial refresh leaves the group unsignable until the +stragglers are repaired from the majority generation and refreshed again. + ## Common Types ### Signature @@ -615,6 +640,41 @@ pub fn frost_repair_part3( ) -> Result; ``` +### Share Repair (DKLs23 secp256k1) + +One masked term per helper, one round, no new key material: the masks are +additive shares of zero drawn from the pairwise zero-share seeds every party +already holds from DKG, under the domain `b"libtss/dkls23-repair/v1" || repair_sid`. + +```rust +/// Called by each helper: `poly_point · λ_self(target) + mask`, a 32-byte scalar +/// to unicast to the target. `helpers` is the full helper set (self included, +/// target excluded), identical on every helper. Fails with `InvalidShare` if the +/// caller lacks a zero-share pairing with any helper (a hollow share), instead of +/// emitting an unmasked term. +pub fn repair_helper_term( + key_share: &KeyShareHandle, + helpers: &[Identifier], + target: Identifier, + repair_sid: &[u8], +) -> Result>, TssError>; + +/// Called by the target: sums at least `min_signers` terms into a *hollow* +/// share. Checks `G · Σ terms == verifying_share(target)` and fails with +/// `InvalidShare` otherwise — no handle is created for a share that does not +/// match the group. The result holds no OT or zero-share state and cannot sign +/// (`SignSession::new_dkls` returns `InvalidShare`) until a complete refresh +/// (`RefreshSession::new_dkls`) has run across all `n` parties. +pub fn repair_assemble( + suite: Ciphersuite, + pubkey_package: &PublicKeyPackage, + chain_code: &[u8], + target: Identifier, + terms: &[&[u8]], + session_id: &[u8], +) -> Result; +``` + ## Channel Security The library is transport-agnostic but imposes requirements on channels. diff --git a/spec/06-ffi-layer.md b/spec/06-ffi-layer.md index 7c79415..fa3eb89 100644 --- a/spec/06-ffi-layer.md +++ b/spec/06-ffi-layer.md @@ -255,6 +255,9 @@ void tss_session_free(TssHandle session); TssStatus tss_handle_identifier(TssHandle handle, uint16_t *out_id); TssStatus tss_handle_verifying_share(TssHandle handle, TssBuffer *out); TssStatus tss_handle_group_key(TssHandle handle, TssBuffer *out); +/// 32-byte BIP-32 chain code paired with the group key. BIP-32-capable suites only +/// (Secp256k1ECDSA, Secp256r1ECDSA, Secp256k1Taproot); others -> TSS_ERR_PROTO_MISMATCH. +TssStatus tss_handle_chain_code(TssHandle handle, TssBuffer *out); TssStatus tss_handle_pubkey_package(TssHandle handle, TssBuffer *out); uint8_t tss_handle_ciphersuite(TssHandle handle); @@ -338,6 +341,17 @@ TssStatus tss_refresh_receiver( TssHandle *out_session ); +/// Create a DKLs23 complete refresh session (secp256k1 shares only). +/// Every party of the group runs it under the same fresh session_id and +/// advances with tss_refresh_next three times. key_share stays valid; the +/// completed session yields a new handle. tss_refresh_new keeps returning +/// TSS_ERR_PROTO_MISMATCH for DKLs23 shares; this is the DKLs23 entry point. +TssStatus tss_refresh_new_dkls( + TssHandle key_share, + const uint8_t *session_id, size_t session_id_len, + TssHandle *out_session, TssBuffer *out_messages +); + /// Advance the refresh session with received messages. TssStatus tss_refresh_next( TssHandle session, @@ -429,6 +443,38 @@ TssStatus tss_frost_repair_part3( TssHandle *out_key ); +// --- Share Repair (DKLs23 secp256k1) --- + +/// Helper side: this party's masked term for the target's lost share. +/// helpers is the full helper set (self included, target excluded) and must +/// be identical on every helper, as must session_id. out_term is a 32-byte +/// scalar to unicast to the target (free with tss_buffer_free). A share with +/// no zero-share pairing to some helper (a hollow share) returns +/// TSS_ERR_INVALID_SHARE instead of an unmasked term. +TssStatus tss_repair_helper_term( + TssHandle key_share, + const uint16_t *helpers, size_t helpers_len, + uint16_t target, + const uint8_t *session_id, size_t session_id_len, + TssBuffer *out_term +); + +/// Target side: sum at least min_signers terms into a hollow share. +/// pubkey_package is the serialized group package and chain_code its 32-byte +/// BIP-32 chain code, as the helpers report them. G·Σterms is checked against +/// the target's verifying share; a mismatch returns TSS_ERR_INVALID_SHARE and +/// creates no handle. The result cannot sign (tss_sign_new returns +/// TSS_ERR_INVALID_SHARE) until tss_refresh_new_dkls has run across the group. +TssStatus tss_repair_assemble( + uint8_t suite, + TssSlice pubkey_package, + TssSlice chain_code, + uint16_t target, + const TssSlice *terms, size_t terms_len, + const uint8_t *session_id, size_t session_id_len, + TssHandle *out_key_share +); + // ============================================================ // Verification & Key Management // ============================================================ @@ -454,13 +500,14 @@ const char* tss_version(void); | Category | Functions | Notes | |----------|-----------|-------| | Initialization | 1 | `tss_init` | -| Unified sessions | 8 | dkg (2) + sign (2) + refresh (2) + refresh_receiver (1) + session_free (1) | +| Unified sessions | 9 | dkg (2) + sign (2) + refresh (2) + refresh_receiver (1) + refresh_new_dkls (1) + session_free (1) | | Message helpers | 3 | count, at, build | | FROST-specific | 9 | aggregate, tweak (2), dealer (2), refresh (2), repair (3) | +| DKLs23-specific | 2 | repair (helper_term, assemble) | | Verification + key mgmt | 5 | verify, derive (2), export, import | -| Handle + memory | 8 | handle queries (4) + handle_free + buffer_free + session_free + version | +| Handle + memory | 9 | handle queries (5) + handle_free + buffer_free + session_free + version | | Error | 6 | last_error, last_error_len, last_error_copy, culprit_count, culprit, banned_party | -| **Total** | **~40** | **Core unified path: 11 functions (sessions + messages)** | +| **Total** | **~41** | **Core unified path: 11 functions (sessions + messages)** | The core DKG + signing workflow uses only 7 functions: `tss_dkg_new`, `tss_dkg_next`, `tss_sign_new`, `tss_sign_next`, `tss_message_count`, `tss_message_at`, `tss_message_build`. diff --git a/spec/07-key-management.md b/spec/07-key-management.md index 59fc680..4dbb0a2 100644 --- a/spec/07-key-management.md +++ b/spec/07-key-management.md @@ -95,7 +95,7 @@ fn frost_derive_child(key_handle: u64, child_number: u32) -> HandleResult { // Compute BIP-32 tweak let parent_pubkey = key_package.verifying_key().serialize(); - let chain_code = get_chain_code(key_handle)?; + let chain_code = key_handle.chain_code()?; let (il, ir) = hmac_sha512(&chain_code, &[&parent_pubkey, &child_number.to_be_bytes()]); let delta = Scalar::deserialize(&il)?; @@ -151,6 +151,21 @@ The `Party::derive_child` method: | Hardened derivation | Not supported | Requires MPC hash (future work) | | Path format | `m/N/N/N/...` | Standard BIP-32 path notation | +### Exposing the Chain Code (xpub Construction) + +`KeyShareHandle::chain_code()` (C ABI `tss_handle_chain_code`) returns the 32-byte +chain code paired with `group_verifying_key()`. Wrapping the two into a BIP-32 +extended public key lets a host derive child public keys off-MPC that match the +child shares every party derives with `derive_child` / `derive_path`. + +| Ciphersuite | Chain code | Agreement with an external CKDpub | +|-------------|------------|-----------------------------------| +| Secp256k1ECDSA / Secp256r1ECDSA (DKLs23) | Established at DKG; children carry the BIP-32 `IR` | Any depth | +| Secp256k1Taproot (FROST) | `SHA-256(group verifying key)`, re-derived from each child's key | Depth 1 only — FROST derivation never carries `IR` (follow-up) | +| All others | Not available | `ProtocolMismatch` — these suites have no BIP-32 derivation | + +The chain code is public material; exposing it reveals nothing about the shares. + ### Security Note: Derivation + Refresh Deriving a child key and then refreshing its shares is safe but has a subtlety: @@ -211,9 +226,30 @@ pub fn frost_apply_refresh( ### DKLs23 Complete Refresh -DKLs23 refresh uses `RefreshSession::new`, which re-runs the full DKG -(including OT re-initialization). This is the recommended mode when OT -state may have been partially compromised. +DKLs23 refresh uses `RefreshSession::new_dkls(key_share, session_id)` (C ABI: +`tss_refresh_new_dkls`), which re-runs DKG with a zero-constant polynomial and +re-initializes the OT and zero-share state from scratch. Three message rounds +follow the same shape as DKG phases 2–4, minus the BIP-32 derivation +broadcasts; the chain code is carried over unchanged. dkls23's +`refresh_complete_phase4` returns only the refreshed `Party`, so libtss rebuilds +the public key package itself: every verifying share moves by its party's +correction point (`share'(j) = share(j) + G·correction_j`, read from the +broadcast proof commitments) while the group key stays put, and the result is +self-checked against the new share before a handle is created. + +Two properties bind callers: + +- **All `n` parties must refresh together under one fresh `session_id`.** The + refreshed party records it as its session id, and signing salts the + two-party multiplication transcript with it, so pre- and post-refresh shares + never co-sign. A partial refresh leaves the group unsignable until the + stragglers are repaired from the majority generation and refreshed again. +- **Only secp256k1 shares are supported.** FROST and secp256r1 shares return + `ProtocolMismatch`; a hollow share from [`repair_assemble`](#dkls23-share-repair) + is accepted — turning it back into a signing share is the point. + +This is also the recommended mode when OT state may have been partially +compromised. ### FROST Dealer/Receiver Refresh @@ -287,14 +323,46 @@ pub fn frost_repair_part3( ### DKLs23 Share Repair -DKLs23 does not have a built-in repair mechanism equivalent to FROST's RTS. For DKLs23, -share recovery requires: +dkls23 has no built-in repair; libtss implements one for secp256k1 as a +two-step recovery (`repair_helper_term` / `repair_assemble`, C ABI +`tss_repair_helper_term` / `tss_repair_assemble`) followed by a complete +refresh. The OT correlations in a `Party` cannot be reconstructed by helpers, +so repair rebuilds only the *secret share* and leaves the refresh to rebuild +the rest. + +**Step 1 — helpers (one round, unicast to the target).** A set of at least +`t` surviving parties agree on the helper set and a fresh `repair_sid`. Each +helper `i` sends the target -1. Re-running the complete DKG with the recovering party -2. Or using the re-key function with the reconstructed secret (requires trusted entity) +```text +term_i = poly_point_i · λ_i(target) + ZeroShare::compute(helpers \ {i}, "libtss/dkls23-repair/v1" || repair_sid) +``` -This is a limitation of the OT-based approach where the Party state includes OT -correlations that cannot be independently reconstructed. +`λ_i(target)` is the Lagrange coefficient over the helper indices, so the +unmasked terms sum to `f(target)`. The masks are the same pairwise zero-share +seeds signing uses, keyed by a domain-separated session id, and sum to zero +over the helper set — no new key material and no mask exchange. Any proper +subset of the terms is uniformly random, so no helper learns the share. +`repair_helper_term` fails closed with `InvalidShare` if the caller lacks a +seed pair with any helper counterparty (`ZeroShare::compute` would otherwise +skip it silently and leave `poly_point · λ` unmasked). + +**Step 2 — target.** `repair_assemble` sums the terms, checks +`G · Σ terms == verifying_share(target)` from the helpers' public key package +(`InvalidShare` otherwise — no handle is created) and builds a *hollow* +`Party`: correct `poly_point`, `pk`, address and root-level derivation data +(`depth 0`, the group chain code), but an empty `ZeroShare` and empty +multiplication maps. The handle passes import validation, exports, derives +addresses and refreshes; `SignSession::new_dkls` refuses it with +`InvalidShare` and it cannot act as a repair helper. + +**Step 3 — complete refresh across all `n`.** `RefreshSession::new_dkls` +rebuilds the OT and zero-share state for every party and re-randomises every +share under the unchanged group key. Only after this can the repaired party +sign. + +Only master (depth-0) shares are repaired; derived shares are re-derived from +the refreshed master, per dkls23's own guidance on refreshing derivations. ## Key Share Persistence diff --git a/spec/10-testing.md b/spec/10-testing.md index d7055ac..41ec53d 100644 --- a/spec/10-testing.md +++ b/spec/10-testing.md @@ -73,6 +73,8 @@ cd libtss-ffi && cargo test --test integration | `TestDeriveChild` | BIP-32 child derivation produces correct public key | | `TestDerivePath` | Multi-level path derivation matches reference | | `TestDeriveChild_AllParties` | All parties derive same child public key independently | +| `TestChainCode_DKLs_MatchesBIP32` | Chain code is equal across parties; child chain code / key match an independent CKDpub | +| `TestChainCode_UnsupportedSuite` | `chain_code()` on a non-BIP-32 suite returns `ProtocolMismatch` | | `TestRefresh_FROST` | Share refresh preserves signing ability | | `TestRefresh_DKLs` | Complete and quick refresh preserve signing | | `TestRefresh_OldSharesInvalid` | Old shares cannot produce valid signatures after refresh |