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