From e29a5c8eb8e75f2df029ec4b5e99e6825d2b9577 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jose=CC=81=20A=2EP?=
<53834183+Jossec101@users.noreply.github.com>
Date: Thu, 27 Aug 2026 17:27:46 +0200
Subject: [PATCH 1/6] Inline format args that stable clippy rejects
CI runs clippy on the latest stable with -D warnings, and that version
rejects uninlined format args, so every pull request fails before its own
changes are even checked.
Inlines the four format! calls it flags, in the tokio example, the FFI
memory hardening test and the protocol tests.
---
examples/tokio-tss/src/router.rs | 6 ++----
libtss-ffi/tests/memory_hardening_ffi.rs | 3 +--
libtss/tests/protocol_tests.rs | 2 +-
3 files changed, 4 insertions(+), 7 deletions(-)
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-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/tests/protocol_tests.rs b/libtss/tests/protocol_tests.rs
index fb74eb2..c484a01 100644
--- a/libtss/tests/protocol_tests.rs
+++ b/libtss/tests/protocol_tests.rs
@@ -848,7 +848,7 @@ fn concurrent_frost_sign_sessions() {
for (&pid, (data, suite)) in exported {
local_shares.insert(pid, libtss::import_key_share(data, *suite).unwrap());
}
- let msg = format!("concurrent-sign-{}", thread_idx);
+ let msg = format!("concurrent-sign-{thread_idx}");
run_frost_sign(&local_shares, &[id(1), id(2)], msg.as_bytes())
})
})
From dc56195afabadd8137d348dc8ca48403db235fe8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jose=CC=81=20A=2EP?=
<53834183+Jossec101@users.noreply.github.com>
Date: Thu, 27 Aug 2026 17:27:46 +0200
Subject: [PATCH 2/6] Expose the BIP-32 chain code from a key share
A wallet service hands out a new address for every customer or payment,
and today each one needs every party to run derive_child. The group key
plus the chain code make a BIP-32 xpub, so a server can derive addresses
on its own, and they match the ones the parties later sign for.
Adds chain_code() to the key share handle, in Rust, the C API
(tss_handle_chain_code) and every binding. DKLs23 returns the chain code
made at DKG; FROST Taproot returns the value derive.rs already uses,
through one shared helper. Other suites return ProtocolMismatch. Tests
check child keys against a separate BIP-32 implementation, and the FFI
tests get a shared DKG and signing driver.
---
Cargo.lock | 3 +
libtss-dotnet/src/Libtss/KeyShareHandle.cs | 31 +++
libtss-dotnet/src/Libtss/Native.cs | 3 +
.../tests/Libtss.Tests/IntegrationTests.cs | 63 +++++
libtss-ffi/Cargo.toml | 3 +
libtss-ffi/libtss.h | 8 +
libtss-ffi/src/handles.rs | 17 ++
libtss-ffi/tests/common/mod.rs | 222 ++++++++++++++++++
libtss-ffi/tests/derive_test.rs | 134 ++++++++++-
libtss-go/tss/handle.go | 22 ++
libtss-go/tss/tss_test.go | 69 ++++++
libtss-node/README.md | 15 +-
libtss-node/jest.config.js | 1 +
libtss-node/src/__tests__/handle.test.ts | 106 +++++++++
libtss-node/src/__tests__/helpers.ts | 83 +++++++
libtss-node/src/ffi.ts | 3 +
libtss-node/src/handle.ts | 17 ++
libtss-python/libtss/_ffi.py | 3 +
libtss-python/libtss/handle.py | 19 ++
libtss-python/tests/test_libtss.py | 36 +++
libtss-rn/android/src/main/cpp/libtss-jni.cpp | 17 ++
.../main/java/com/libtss/LibtssModule.java | 6 +
libtss-rn/ios/Libtss.mm | 20 ++
libtss-rn/src/NativeLibtss.ts | 1 +
.../src/__tests__/__mocks__/NativeLibtss.ts | 16 ++
libtss-rn/src/__tests__/handle.test.ts | 47 ++++
libtss-rn/src/handle.ts | 16 ++
libtss-wasm/README.md | 2 +-
libtss-wasm/src/handle.rs | 16 ++
libtss-wasm/tests/handle.rs | 155 ++++++++++++
libtss/src/derive.rs | 113 ++++++++-
libtss/src/keyshare.rs | 40 ++++
spec/03-api-design.md | 6 +
spec/06-ffi-layer.md | 7 +-
spec/07-key-management.md | 17 +-
spec/10-testing.md | 2 +
36 files changed, 1317 insertions(+), 22 deletions(-)
create mode 100644 libtss-ffi/tests/common/mod.rs
create mode 100644 libtss-node/src/__tests__/handle.test.ts
create mode 100644 libtss-node/src/__tests__/helpers.ts
create mode 100644 libtss-rn/src/__tests__/handle.test.ts
create mode 100644 libtss-wasm/tests/handle.rs
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/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..1673e15 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);
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/tests/common/mod.rs b/libtss-ffi/tests/common/mod.rs
new file mode 100644
index 0000000..010332a
--- /dev/null
+++ b/libtss-ffi/tests/common/mod.rs
@@ -0,0 +1,222 @@
+//! 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_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 n = keys.len();
+ let mut sessions = Vec::new();
+ let mut pending = vec![Vec::new(); n];
+ let signer_ids: Vec = (1..=n as u16).collect();
+ let sign_id = b"ffi-test-sign-id";
+
+ for (i, key) in keys.iter().enumerate() {
+ let counterparties: Vec = signer_ids
+ .iter()
+ .copied()
+ .filter(|&id| id != signer_ids[i])
+ .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(&pending, n);
+ 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");
+}
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-go/tss/handle.go b/libtss-go/tss/handle.go
index de864df..e3bd37b 100644
--- a/libtss-go/tss/handle.go
+++ b/libtss-go/tss/handle.go
@@ -118,6 +118,28 @@ func (h *KeyShareHandle) GroupKey() ([]byte, error) {
return bufferToBytes(&out), nil
}
+// 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) {
handle, err := h.native.value()
if err != nil {
diff --git a/libtss-go/tss/tss_test.go b/libtss-go/tss/tss_test.go
index 5bf01fa..70fcb0a 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"
)
@@ -389,3 +393,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/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/spec/03-api-design.md b/spec/03-api-design.md
index 7f91c85..5ff5ad7 100644
--- a/spec/03-api-design.md
+++ b/spec/03-api-design.md
@@ -222,6 +222,12 @@ impl KeyShareHandle {
/// Group public key (combined verification key).
pub fn group_verifying_key(&self) -> Vec;
+ /// 32-byte BIP-32 chain code paired with the group key; together they form
+ /// an xpub whose off-MPC child derivation matches `derive_child`. Only the
+ /// BIP-32-capable suites (Secp256k1ECDSA, Secp256r1ECDSA, Secp256k1Taproot);
+ /// others return `ProtocolMismatch`.
+ pub fn chain_code(&self) -> Result, TssError>;
+
/// Full public key package (group key + all verifying shares).
pub fn public_key_package(&self) -> &PublicKeyPackage;
diff --git a/spec/06-ffi-layer.md b/spec/06-ffi-layer.md
index 7c79415..753e999 100644
--- a/spec/06-ffi-layer.md
+++ b/spec/06-ffi-layer.md
@@ -255,6 +255,9 @@ void tss_session_free(TssHandle session);
TssStatus tss_handle_identifier(TssHandle handle, uint16_t *out_id);
TssStatus tss_handle_verifying_share(TssHandle handle, TssBuffer *out);
TssStatus tss_handle_group_key(TssHandle handle, TssBuffer *out);
+/// 32-byte BIP-32 chain code paired with the group key. BIP-32-capable suites only
+/// (Secp256k1ECDSA, Secp256r1ECDSA, Secp256k1Taproot); others -> TSS_ERR_PROTO_MISMATCH.
+TssStatus tss_handle_chain_code(TssHandle handle, TssBuffer *out);
TssStatus tss_handle_pubkey_package(TssHandle handle, TssBuffer *out);
uint8_t tss_handle_ciphersuite(TssHandle handle);
@@ -458,9 +461,9 @@ const char* tss_version(void);
| Message helpers | 3 | count, at, build |
| FROST-specific | 9 | aggregate, tweak (2), dealer (2), refresh (2), repair (3) |
| Verification + key mgmt | 5 | verify, derive (2), export, import |
-| Handle + memory | 8 | handle queries (4) + handle_free + buffer_free + session_free + version |
+| Handle + memory | 9 | handle queries (5) + handle_free + buffer_free + session_free + version |
| Error | 6 | last_error, last_error_len, last_error_copy, culprit_count, culprit, banned_party |
-| **Total** | **~40** | **Core unified path: 11 functions (sessions + messages)** |
+| **Total** | **~41** | **Core unified path: 11 functions (sessions + messages)** |
The core DKG + signing workflow uses only 7 functions: `tss_dkg_new`, `tss_dkg_next`,
`tss_sign_new`, `tss_sign_next`, `tss_message_count`, `tss_message_at`, `tss_message_build`.
diff --git a/spec/07-key-management.md b/spec/07-key-management.md
index 59fc680..36f4021 100644
--- a/spec/07-key-management.md
+++ b/spec/07-key-management.md
@@ -95,7 +95,7 @@ fn frost_derive_child(key_handle: u64, child_number: u32) -> HandleResult {
// Compute BIP-32 tweak
let parent_pubkey = key_package.verifying_key().serialize();
- let chain_code = get_chain_code(key_handle)?;
+ let chain_code = key_handle.chain_code()?;
let (il, ir) = hmac_sha512(&chain_code, &[&parent_pubkey, &child_number.to_be_bytes()]);
let delta = Scalar::deserialize(&il)?;
@@ -151,6 +151,21 @@ The `Party::derive_child` method:
| Hardened derivation | Not supported | Requires MPC hash (future work) |
| Path format | `m/N/N/N/...` | Standard BIP-32 path notation |
+### Exposing the Chain Code (xpub Construction)
+
+`KeyShareHandle::chain_code()` (C ABI `tss_handle_chain_code`) returns the 32-byte
+chain code paired with `group_verifying_key()`. Wrapping the two into a BIP-32
+extended public key lets a host derive child public keys off-MPC that match the
+child shares every party derives with `derive_child` / `derive_path`.
+
+| Ciphersuite | Chain code | Agreement with an external CKDpub |
+|-------------|------------|-----------------------------------|
+| Secp256k1ECDSA / Secp256r1ECDSA (DKLs23) | Established at DKG; children carry the BIP-32 `IR` | Any depth |
+| Secp256k1Taproot (FROST) | `SHA-256(group verifying key)`, re-derived from each child's key | Depth 1 only — FROST derivation never carries `IR` (follow-up) |
+| All others | Not available | `ProtocolMismatch` — these suites have no BIP-32 derivation |
+
+The chain code is public material; exposing it reveals nothing about the shares.
+
### Security Note: Derivation + Refresh
Deriving a child key and then refreshing its shares is safe but has a subtlety:
diff --git a/spec/10-testing.md b/spec/10-testing.md
index d7055ac..41ec53d 100644
--- a/spec/10-testing.md
+++ b/spec/10-testing.md
@@ -73,6 +73,8 @@ cd libtss-ffi && cargo test --test integration
| `TestDeriveChild` | BIP-32 child derivation produces correct public key |
| `TestDerivePath` | Multi-level path derivation matches reference |
| `TestDeriveChild_AllParties` | All parties derive same child public key independently |
+| `TestChainCode_DKLs_MatchesBIP32` | Chain code is equal across parties; child chain code / key match an independent CKDpub |
+| `TestChainCode_UnsupportedSuite` | `chain_code()` on a non-BIP-32 suite returns `ProtocolMismatch` |
| `TestRefresh_FROST` | Share refresh preserves signing ability |
| `TestRefresh_DKLs` | Complete and quick refresh preserve signing |
| `TestRefresh_OldSharesInvalid` | Old shares cannot produce valid signatures after refresh |
From 37a76dc3c1b8fb2171be99386a2f05f65999c62b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jos=C3=A9=20A=2EP?=
<53834183+Jossec101@users.noreply.github.com>
Date: Tue, 22 Sep 2026 11:47:17 +0200
Subject: [PATCH 3/6] Repair a lost DKLs23 share and refresh all shares
Parties lose shares: a disk dies, a server is replaced, a backup is bad.
Today the only fix is a new DKG, which means a new key, new addresses and
moving every coin on chain. Repair brings the lost party back under the
same key.
Each helper sends the lost party one masked term; the masks cancel, so
the party learns its own share and nothing about the helpers'. It checks
the sum against its verifying share and stops on a mismatch. The rebuilt
share has no OT state, so signing refuses it until a complete refresh
across all parties gives every share new randomness and new OT state.
secp256k1 only for now, with C API and Go bindings, and tests in Rust,
the FFI and Go.
---
libtss-ffi/libtss.h | 49 ++
libtss-ffi/src/lib.rs | 1 +
libtss-ffi/src/repair.rs | 98 ++++
libtss-ffi/src/session.rs | 36 ++
libtss-ffi/tests/common/mod.rs | 147 +++++-
libtss-ffi/tests/refresh_repair_test.rs | 287 +++++++++++
libtss-go/tss/handle.go | 17 +-
libtss-go/tss/repair.go | 93 ++++
libtss-go/tss/repair_test.go | 211 ++++++++
libtss-go/tss/session.go | 43 ++
libtss-go/tss/tss_test.go | 110 +++++
libtss/src/dkls.rs | 513 +++++++++++++++++--
libtss/src/lib.rs | 2 +
libtss/src/repair.rs | 631 ++++++++++++++++++++++++
libtss/src/session/refresh.rs | 279 ++++++++++-
libtss/src/session/sign.rs | 3 +
libtss/src/types.rs | 4 +
libtss/tests/dkls_repair_refresh.rs | 206 ++++++++
18 files changed, 2677 insertions(+), 53 deletions(-)
create mode 100644 libtss-ffi/src/repair.rs
create mode 100644 libtss-ffi/tests/refresh_repair_test.rs
create mode 100644 libtss-go/tss/repair.go
create mode 100644 libtss-go/tss/repair_test.go
create mode 100644 libtss/src/repair.rs
create mode 100644 libtss/tests/dkls_repair_refresh.rs
diff --git a/libtss-ffi/libtss.h b/libtss-ffi/libtss.h
index 1673e15..24a9d31 100644
--- a/libtss-ffi/libtss.h
+++ b/libtss-ffi/libtss.h
@@ -243,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,
@@ -280,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/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
index 010332a..7cc3626 100644
--- a/libtss-ffi/tests/common/mod.rs
+++ b/libtss-ffi/tests/common/mod.rs
@@ -8,7 +8,8 @@ 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_session_free, tss_sign_new, tss_sign_next,
+ 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};
@@ -154,18 +155,69 @@ pub fn run_dkg(suite: u8, participants: u16, min_signers: u16) -> Vec
/// (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 n = keys.len();
+ 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 signer_ids: Vec = (1..=n as u16).collect();
let sign_id = b"ffi-test-sign-id";
- for (i, key) in keys.iter().enumerate() {
- let counterparties: Vec = signer_ids
- .iter()
- .copied()
- .filter(|&id| id != signer_ids[i])
- .collect();
+ 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!(
@@ -186,7 +238,7 @@ pub fn run_dkls_sign(keys: &[TssHandle], message_hash: &[u8]) -> Vec {
}
for _ in 0..8 {
- let routed = route_messages(&pending, n);
+ let routed = route_messages_ids(&pending, &ids);
pending = vec![Vec::new(); n];
for (i, session) in sessions.iter().enumerate() {
@@ -220,3 +272,78 @@ pub fn run_dkls_sign(keys: &[TssHandle], message_hash: &[u8]) -> Vec {
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/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 e3bd37b..4dd276b 100644
--- a/libtss-go/tss/handle.go
+++ b/libtss-go/tss/handle.go
@@ -141,17 +141,28 @@ func (h *KeyShareHandle) ChainCode() ([]byte, error) {
}
func (h *KeyShareHandle) PublicKeyPackage() (PublicKeyPackage, error) {
- handle, err := h.native.value()
+ 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 70fcb0a..722e6be 100644
--- a/libtss-go/tss/tss_test.go
+++ b/libtss-go/tss/tss_test.go
@@ -244,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 {
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/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