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>), Complete, } @@ -92,12 +97,83 @@ impl RefreshSession { }) } - /// Apply received refresh data. Called by each participant after receiving - /// the refresh share from the dealer. + /// Create a DKLs23 complete refresh session for a secp256k1 key share. + /// + /// All `n` parties of the group must run this together under the same + /// fresh `session_id`: the refreshed shares are salted with it and cannot + /// co-sign with shares from any other generation. The refresh rebuilds + /// the OT and zero-share state from scratch, which is what turns a hollow + /// share produced by [`repair_assemble`](crate::repair_assemble) into a + /// signing one. Three message rounds follow (`next()` is called three + /// times); the input handle stays valid and the output is a new one. + pub fn new_dkls( + key_share: &KeyShareHandle, + session_id: &[u8], + ) -> Result<(Self, Vec), TssError> { + if key_share.ciphersuite() != Ciphersuite::Secp256k1ECDSA { + return Err(TssError::ProtocolMismatch); + } + + let party = keyshare::clone_dkls_party(key_share)?; + let pubkey = keyshare::clone_dkls_pubkey(key_share)?; + let (state, messages) = dkls_refresh_phase1(party, pubkey, session_id)?; + + Ok(( + Self { + inner: RefreshInner::Dkls(Box::new(Some(state))), + round: 1, + }, + messages, + )) + } + + /// Advance the refresh with the messages received for this round. pub fn next(&mut self, received: &[Message]) -> Result { + match &mut self.inner { + RefreshInner::Complete => Err(TssError::SessionComplete), + RefreshInner::FrostApply { .. } => self.next_frost_apply(received), + RefreshInner::Dkls(_) => self.next_dkls(received), + } + } + + fn next_dkls(&mut self, received: &[Message]) -> Result { + let dkls_ref = match &mut self.inner { + RefreshInner::Dkls(state) => &mut **state, + _ => return Err(TssError::ProtocolMismatch), + }; + + match self.round { + 1 => { + let state = dkls_ref.as_mut().ok_or(TssError::SessionComplete)?; + let messages = dkls_refresh_phase2(state, received)?; + self.round = 2; + Ok(RefreshOutput::Continue(messages)) + } + 2 => { + let state = dkls_ref.as_mut().ok_or(TssError::SessionComplete)?; + let messages = dkls_refresh_phase3(state, received)?; + self.round = 3; + Ok(RefreshOutput::Continue(messages)) + } + 3 => { + let state = dkls_ref.take().ok_or(TssError::SessionComplete)?; + let (party, dkls_pubkey, public_keys) = dkls_refresh_phase4(state, received)?; + let key_share = keyshare::from_dkls_dkg(party, dkls_pubkey)?; + self.inner = RefreshInner::Complete; + self.round = 4; + Ok(RefreshOutput::Complete { + key_share, + public_keys, + }) + } + _ => Err(TssError::SessionComplete), + } + } + + fn next_frost_apply(&mut self, received: &[Message]) -> Result { let state = std::mem::replace(&mut self.inner, RefreshInner::Complete); match state { - RefreshInner::Complete => Err(TssError::SessionComplete), + RefreshInner::Complete | RefreshInner::Dkls(_) => Err(TssError::ProtocolMismatch), RefreshInner::FrostApply { key_share } => { let msg = received.first().ok_or_else(|| { TssError::DeserializeFailed("no refresh data received".into()) @@ -138,6 +214,14 @@ impl RefreshSession { self.round } + pub fn num_rounds(&self) -> u8 { + match &self.inner { + RefreshInner::FrostApply { .. } => 1, + RefreshInner::Dkls(_) => 4, + RefreshInner::Complete => 0, + } + } + pub fn is_complete(&self) -> bool { matches!(self.inner, RefreshInner::Complete) } @@ -145,10 +229,13 @@ impl RefreshSession { #[cfg(test)] mod tests { + use std::collections::BTreeMap; + use super::*; use crate::frost::{frost_aggregate, frost_commit, frost_generate_with_dealer, frost_sign}; use crate::keyshare; - use crate::types::{Ciphersuite, ThresholdConfig}; + use crate::session::dkg::{DkgOutput, DkgSession}; + use crate::types::ThresholdConfig; fn id(value: u16) -> Identifier { Identifier::new(value).unwrap() @@ -219,4 +306,186 @@ mod tests { Ok(()) } + + fn route( + outputs: &BTreeMap>, + recipients: &[Identifier], + ) -> BTreeMap> { + let mut routed: BTreeMap> = + recipients.iter().map(|&r| (r, Vec::new())).collect(); + for (&sender, messages) in outputs { + for msg in messages { + match msg.to { + Some(to) => routed.get_mut(&to).unwrap().push(msg.clone()), + None => { + for &r in recipients { + if r != sender { + routed.get_mut(&r).unwrap().push(msg.clone()); + } + } + } + } + } + } + routed + } + + fn run_dkls_dkg(threshold: u16, share_count: u16) -> BTreeMap { + let config = ThresholdConfig { + min_signers: threshold, + max_signers: share_count, + suite: Ciphersuite::Secp256k1ECDSA, + }; + let participants: Vec = (1..=share_count).map(id).collect(); + let mut sessions = BTreeMap::new(); + let mut outputs = BTreeMap::new(); + for &pid in &participants { + let (session, messages) = DkgSession::new(&config, pid, Some(b"refresh-dkg")).unwrap(); + sessions.insert(pid, session); + outputs.insert(pid, messages); + } + + let mut shares = BTreeMap::new(); + while shares.len() < participants.len() { + let inputs = route(&outputs, &participants); + outputs = BTreeMap::new(); + for &pid in &participants { + match sessions + .get_mut(&pid) + .unwrap() + .next(inputs.get(&pid).unwrap()) + .unwrap() + { + DkgOutput::Continue(messages) => { + outputs.insert(pid, messages); + } + DkgOutput::Complete { key_share, .. } => { + shares.insert(pid, key_share); + } + } + } + } + shares + } + + fn run_dkls_refresh( + shares: &BTreeMap, + sids: &BTreeMap, + ) -> Result, TssError> { + let participants: Vec = shares.keys().copied().collect(); + let mut sessions = BTreeMap::new(); + let mut outputs = BTreeMap::new(); + for &pid in &participants { + let (session, messages) = RefreshSession::new_dkls(&shares[&pid], sids[&pid])?; + assert_eq!(session.round(), 1); + assert_eq!(session.num_rounds(), 4); + sessions.insert(pid, session); + outputs.insert(pid, messages); + } + + let mut refreshed = BTreeMap::new(); + for _ in 0..3 { + let inputs = route(&outputs, &participants); + outputs = BTreeMap::new(); + for &pid in &participants { + match sessions + .get_mut(&pid) + .unwrap() + .next(inputs.get(&pid).unwrap())? + { + RefreshOutput::Continue(messages) => { + outputs.insert(pid, messages); + } + RefreshOutput::Complete { + key_share, + public_keys, + } => { + refreshed.insert(pid, (key_share, public_keys)); + } + } + } + } + assert_eq!(refreshed.len(), participants.len()); + for session in sessions.values() { + assert!(session.is_complete()); + assert_eq!(session.round(), 4); + } + Ok(refreshed) + } + + #[test] + fn dkls_refresh_session_keeps_group_key_and_moves_shares() -> Result<(), TssError> { + let shares = run_dkls_dkg(2, 3); + let sids: BTreeMap = shares + .keys() + .map(|&pid| (pid, b"refresh-sid".as_slice())) + .collect(); + + let refreshed = run_dkls_refresh(&shares, &sids)?; + + let reference = &refreshed[&id(1)].1; + for (pid, (key_share, public_keys)) in &refreshed { + assert_eq!(public_keys, reference); + assert_eq!(key_share.identifier(), *pid); + assert_eq!( + key_share.group_verifying_key(), + shares[pid].group_verifying_key() + ); + assert_eq!(key_share.chain_code()?, shares[pid].chain_code()?); + assert_ne!(key_share.verifying_share(), shares[pid].verifying_share()); + assert_eq!( + Some(key_share.verifying_share().as_slice()), + public_keys.verifying_share(*pid) + ); + assert_eq!(key_share.public_key_package(), public_keys); + } + assert_eq!( + shares[&id(1)].identifier(), + id(1), + "input handle stays valid" + ); + + Ok(()) + } + + #[test] + fn dkls_refresh_rejects_wrong_suite_and_empty_session_id() { + let config = ThresholdConfig { + min_signers: 2, + max_signers: 3, + suite: Ciphersuite::Ed25519, + }; + let (frost_shares, pubkey) = frost_generate_with_dealer(&config).unwrap(); + let frost_share = + keyshare::from_frost_dkg(frost_shares.into_iter().next().unwrap(), &pubkey).unwrap(); + assert!(matches!( + RefreshSession::new_dkls(&frost_share, b"sid"), + Err(TssError::ProtocolMismatch) + )); + + let shares = run_dkls_dkg(2, 2); + assert!(matches!( + RefreshSession::new_dkls(&shares[&id(1)], b""), + Err(TssError::InvalidConfig(_)) + )); + assert!(matches!( + RefreshSession::new_frost_receiver(&shares[&id(1)]), + Err(TssError::ProtocolMismatch) + )); + } + + #[test] + fn dkls_refresh_mixed_session_ids_abort() { + let shares = run_dkls_dkg(2, 3); + let mut sids: BTreeMap = shares + .keys() + .map(|&pid| (pid, b"refresh-sid".as_slice())) + .collect(); + sids.insert(id(3), b"other-sid"); + + assert!(matches!( + run_dkls_refresh(&shares, &sids), + Err(TssError::Abort { .. }) + )); + } } diff --git a/libtss/src/session/sign.rs b/libtss/src/session/sign.rs index 75f5a67..df55c84 100644 --- a/libtss/src/session/sign.rs +++ b/libtss/src/session/sign.rs @@ -154,6 +154,9 @@ impl SignSession { } let party = keyshare::clone_dkls_party(key_share)?; + if party.mul_senders.is_empty() { + return Err(TssError::InvalidShare); + } let (wrapper, messages) = DklsSignWrapper::new(party, sign_id, counterparties, message_hash)?; diff --git a/libtss/src/types.rs b/libtss/src/types.rs index 3a33ec6..f2d0a85 100644 --- a/libtss/src/types.rs +++ b/libtss/src/types.rs @@ -274,6 +274,10 @@ impl PublicKeyPackage { self.verifying_shares.get(&id).map(Vec::as_slice) } + pub fn verifying_shares(&self) -> &BTreeMap> { + &self.verifying_shares + } + pub fn min_signers(&self) -> u16 { self.min_signers } diff --git a/libtss/tests/dkls_repair_refresh.rs b/libtss/tests/dkls_repair_refresh.rs new file mode 100644 index 0000000..1cd5fb8 --- /dev/null +++ b/libtss/tests/dkls_repair_refresh.rs @@ -0,0 +1,206 @@ +//! End-to-end: a 3-of-5 DKLs23 group loses party 2's share, three survivors +//! repair it into a hollow share, a complete refresh across all five parties +//! turns it back into a signing share, and quorums with and without the +//! original survivors sign under the unchanged group key. + +use std::collections::BTreeMap; + +use libtss::session::dkg::{DkgOutput, DkgSession}; +use libtss::session::refresh::{RefreshOutput, RefreshSession}; +use libtss::session::sign::{SignOutput, SignSession}; +use libtss::types::{Ciphersuite, Identifier, ThresholdConfig}; +use libtss::{repair_assemble, repair_helper_term, KeyShareHandle, Message, TssError}; +use sha2::Digest; + +fn id(value: u16) -> Identifier { + Identifier::new(value).unwrap() +} + +fn route( + outputs: &BTreeMap>, + recipients: &[Identifier], +) -> BTreeMap> { + let mut routed: BTreeMap> = + recipients.iter().map(|&r| (r, Vec::new())).collect(); + for (&sender, messages) in outputs { + for msg in messages { + match msg.to { + Some(to) => routed.get_mut(&to).unwrap().push(msg.clone()), + None => { + for &r in recipients { + if r != sender { + routed.get_mut(&r).unwrap().push(msg.clone()); + } + } + } + } + } + } + routed +} + +fn run_dkg(threshold: u16, share_count: u16) -> BTreeMap { + let config = ThresholdConfig { + min_signers: threshold, + max_signers: share_count, + suite: Ciphersuite::Secp256k1ECDSA, + }; + let participants: Vec = (1..=share_count).map(id).collect(); + let mut sessions = BTreeMap::new(); + let mut outputs = BTreeMap::new(); + for &pid in &participants { + let (session, messages) = DkgSession::new(&config, pid, Some(b"e2e-dkg")).unwrap(); + sessions.insert(pid, session); + outputs.insert(pid, messages); + } + + let mut shares = BTreeMap::new(); + while shares.len() < participants.len() { + let inputs = route(&outputs, &participants); + outputs = BTreeMap::new(); + for &pid in &participants { + match sessions + .get_mut(&pid) + .unwrap() + .next(inputs.get(&pid).unwrap()) + .unwrap() + { + DkgOutput::Continue(messages) => { + outputs.insert(pid, messages); + } + DkgOutput::Complete { key_share, .. } => { + shares.insert(pid, key_share); + } + } + } + } + shares +} + +fn run_refresh( + shares: &BTreeMap, + session_id: &[u8], +) -> BTreeMap { + let participants: Vec = shares.keys().copied().collect(); + let mut sessions = BTreeMap::new(); + let mut outputs = BTreeMap::new(); + for &pid in &participants { + let (session, messages) = RefreshSession::new_dkls(&shares[&pid], session_id).unwrap(); + sessions.insert(pid, session); + outputs.insert(pid, messages); + } + + let mut refreshed = BTreeMap::new(); + while refreshed.len() < participants.len() { + let inputs = route(&outputs, &participants); + outputs = BTreeMap::new(); + for &pid in &participants { + match sessions + .get_mut(&pid) + .unwrap() + .next(inputs.get(&pid).unwrap()) + .unwrap() + { + RefreshOutput::Continue(messages) => { + outputs.insert(pid, messages); + } + RefreshOutput::Complete { key_share, .. } => { + refreshed.insert(pid, key_share); + } + } + } + } + refreshed +} + +fn run_sign( + shares: &BTreeMap, + signers: &[Identifier], + message: &[u8], +) -> Result, TssError> { + let hash: [u8; 32] = sha2::Sha256::digest(message).into(); + let mut sessions = BTreeMap::new(); + let mut outputs = BTreeMap::new(); + for &pid in signers { + let counterparties: Vec = + signers.iter().copied().filter(|c| *c != pid).collect(); + let (session, messages) = + SignSession::new_dkls(&shares[&pid], b"e2e-sign".to_vec(), &counterparties, hash)?; + sessions.insert(pid, session); + outputs.insert(pid, messages); + } + + loop { + let inputs = route(&outputs, signers); + outputs = BTreeMap::new(); + for &pid in signers { + match sessions + .get_mut(&pid) + .unwrap() + .next(inputs.get(&pid).unwrap())? + { + SignOutput::Continue(messages) => { + outputs.insert(pid, messages); + } + SignOutput::Complete(signature) => return Ok(signature.as_bytes().to_vec()), + } + } + } +} + +#[test] +fn repair_then_refresh_restores_a_signing_group() { + let original = run_dkg(3, 5); + let group_key = original[&id(1)].group_verifying_key(); + let chain_code = original[&id(1)].chain_code().unwrap(); + let lost_verifying_share = original[&id(2)].verifying_share(); + + let mut survivors = original; + survivors.remove(&id(2)); + + let helpers = [id(1), id(3), id(4)]; + let repair_sid = b"e2e-repair-sid"; + let terms: Vec<_> = helpers + .iter() + .map(|helper| repair_helper_term(&survivors[helper], &helpers, id(2), repair_sid).unwrap()) + .collect(); + let term_slices: Vec<&[u8]> = terms.iter().map(|term| term.as_slice()).collect(); + let hollow = repair_assemble( + Ciphersuite::Secp256k1ECDSA, + survivors[&id(1)].public_key_package(), + &chain_code, + id(2), + &term_slices, + repair_sid, + ) + .unwrap(); + + assert_eq!(hollow.identifier(), id(2)); + assert_eq!(hollow.verifying_share(), lost_verifying_share); + assert_eq!(hollow.group_verifying_key(), group_key); + + let mut pre_refresh = survivors; + pre_refresh.insert(id(2), hollow); + assert_eq!( + run_sign(&pre_refresh, &[id(1), id(2), id(3)], b"before refresh").unwrap_err(), + TssError::InvalidShare + ); + + let refreshed = run_refresh(&pre_refresh, b"e2e-refresh-sid"); + + for (pid, share) in &refreshed { + assert_eq!(share.identifier(), *pid); + assert_eq!(share.group_verifying_key(), group_key); + assert_eq!(share.chain_code().unwrap(), chain_code); + assert_ne!(share.verifying_share(), pre_refresh[pid].verifying_share()); + } + + let message = b"after refresh"; + for signers in [[id(1), id(2), id(3)], [id(2), id(4), id(5)]] { + let signature = run_sign(&refreshed, &signers, message).unwrap(); + assert!( + libtss::verify(Ciphersuite::Secp256k1ECDSA, message, &signature, &group_key).unwrap(), + "quorum {signers:?} must sign under the original group key" + ); + } +} From c449b1b2e3beb12d4ec00aee143a97a7c7f96f7b 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 4/6] Describe repair and refresh in the spec Repair and the complete refresh add new functions and a new kind of key share, a rebuilt one that can't sign yet, so readers of the spec need to know how they fit with the rest of the API. Updates the API design, FFI and key management pages: the repair flow, the hollow share and when it can sign again, the complete refresh, and the three new C functions. --- spec/03-api-design.md | 64 ++++++++++++++++++++++++++++++++--- spec/06-ffi-layer.md | 46 ++++++++++++++++++++++++- spec/07-key-management.md | 71 ++++++++++++++++++++++++++++++++++----- 3 files changed, 166 insertions(+), 15 deletions(-) diff --git a/spec/03-api-design.md b/spec/03-api-design.md index 5ff5ad7..27bad2e 100644 --- a/spec/03-api-design.md +++ b/spec/03-api-design.md @@ -465,11 +465,13 @@ pub enum RefreshOutput { } impl RefreshSession { - /// Interactive share refresh (recommended mode). - /// - /// FROST: DKG-based refresh with zero-constant polynomial. - /// DKLs23: Complete refresh (re-initializes OT/multiplication state). - pub fn new(key_share: &KeyShareHandle) -> Result<(Self, Vec), TssError>; + /// FROST dealer refresh: compute and distribute refresh shares to + /// `participants` (one round; the dealer applies its own via `next()`). + /// Returns `TssError::ProtocolMismatch` for DKLs23 key shares. + pub fn new_frost( + key_share: &KeyShareHandle, + participants: &[Identifier], + ) -> Result<(Self, Vec), TssError>; /// Create a FROST refresh receiver session (non-dealer participant). /// The receiver waits for messages from the dealer refresh and does @@ -477,12 +479,29 @@ impl RefreshSession { /// Returns `TssError::ProtocolMismatch` for DKLs23 key shares. pub fn new_frost_receiver(key_share: &KeyShareHandle) -> Result; + /// DKLs23 complete refresh (secp256k1 only): re-runs DKG with a + /// zero-constant polynomial and re-initializes the OT/multiplication + /// state. All `n` parties must run it under the same fresh `session_id`; + /// three message rounds follow. The input handle stays valid and the + /// completed output is a new handle. Returns `ProtocolMismatch` for + /// FROST and secp256r1 shares, `InvalidConfig` for an empty session id. + pub fn new_dkls( + key_share: &KeyShareHandle, + session_id: &[u8], + ) -> Result<(Self, Vec), TssError>; + pub fn next(&mut self, received: &[Message]) -> Result; pub fn round(&self) -> u8; + /// 1 for the FROST dealer/receiver flow, 4 for DKLs23, 0 once complete. pub fn num_rounds(&self) -> u8; } ``` +Refreshed DKLs23 shares carry `session_id` as their new party session id, +which salts signing. Shares from different refresh generations therefore +never co-sign: a partial refresh leaves the group unsignable until the +stragglers are repaired from the majority generation and refreshed again. + ## Common Types ### Signature @@ -621,6 +640,41 @@ pub fn frost_repair_part3( ) -> Result; ``` +### Share Repair (DKLs23 secp256k1) + +One masked term per helper, one round, no new key material: the masks are +additive shares of zero drawn from the pairwise zero-share seeds every party +already holds from DKG, under the domain `b"libtss/dkls23-repair/v1" || repair_sid`. + +```rust +/// Called by each helper: `poly_point · λ_self(target) + mask`, a 32-byte scalar +/// to unicast to the target. `helpers` is the full helper set (self included, +/// target excluded), identical on every helper. Fails with `InvalidShare` if the +/// caller lacks a zero-share pairing with any helper (a hollow share), instead of +/// emitting an unmasked term. +pub fn repair_helper_term( + key_share: &KeyShareHandle, + helpers: &[Identifier], + target: Identifier, + repair_sid: &[u8], +) -> Result>, TssError>; + +/// Called by the target: sums at least `min_signers` terms into a *hollow* +/// share. Checks `G · Σ terms == verifying_share(target)` and fails with +/// `InvalidShare` otherwise — no handle is created for a share that does not +/// match the group. The result holds no OT or zero-share state and cannot sign +/// (`SignSession::new_dkls` returns `InvalidShare`) until a complete refresh +/// (`RefreshSession::new_dkls`) has run across all `n` parties. +pub fn repair_assemble( + suite: Ciphersuite, + pubkey_package: &PublicKeyPackage, + chain_code: &[u8], + target: Identifier, + terms: &[&[u8]], + session_id: &[u8], +) -> Result; +``` + ## Channel Security The library is transport-agnostic but imposes requirements on channels. diff --git a/spec/06-ffi-layer.md b/spec/06-ffi-layer.md index 753e999..fa3eb89 100644 --- a/spec/06-ffi-layer.md +++ b/spec/06-ffi-layer.md @@ -341,6 +341,17 @@ TssStatus tss_refresh_receiver( TssHandle *out_session ); +/// Create a DKLs23 complete refresh session (secp256k1 shares only). +/// Every party of the group runs it under the same fresh session_id and +/// advances with tss_refresh_next three times. key_share stays valid; the +/// completed session yields a new handle. tss_refresh_new keeps returning +/// TSS_ERR_PROTO_MISMATCH for DKLs23 shares; this is the DKLs23 entry point. +TssStatus tss_refresh_new_dkls( + TssHandle key_share, + const uint8_t *session_id, size_t session_id_len, + TssHandle *out_session, TssBuffer *out_messages +); + /// Advance the refresh session with received messages. TssStatus tss_refresh_next( TssHandle session, @@ -432,6 +443,38 @@ TssStatus tss_frost_repair_part3( TssHandle *out_key ); +// --- Share Repair (DKLs23 secp256k1) --- + +/// Helper side: this party's masked term for the target's lost share. +/// helpers is the full helper set (self included, target excluded) and must +/// be identical on every helper, as must session_id. out_term is a 32-byte +/// scalar to unicast to the target (free with tss_buffer_free). A share with +/// no zero-share pairing to some helper (a hollow share) returns +/// TSS_ERR_INVALID_SHARE instead of an unmasked term. +TssStatus tss_repair_helper_term( + TssHandle key_share, + const uint16_t *helpers, size_t helpers_len, + uint16_t target, + const uint8_t *session_id, size_t session_id_len, + TssBuffer *out_term +); + +/// Target side: sum at least min_signers terms into a hollow share. +/// pubkey_package is the serialized group package and chain_code its 32-byte +/// BIP-32 chain code, as the helpers report them. G·Σterms is checked against +/// the target's verifying share; a mismatch returns TSS_ERR_INVALID_SHARE and +/// creates no handle. The result cannot sign (tss_sign_new returns +/// TSS_ERR_INVALID_SHARE) until tss_refresh_new_dkls has run across the group. +TssStatus tss_repair_assemble( + uint8_t suite, + TssSlice pubkey_package, + TssSlice chain_code, + uint16_t target, + const TssSlice *terms, size_t terms_len, + const uint8_t *session_id, size_t session_id_len, + TssHandle *out_key_share +); + // ============================================================ // Verification & Key Management // ============================================================ @@ -457,9 +500,10 @@ const char* tss_version(void); | Category | Functions | Notes | |----------|-----------|-------| | Initialization | 1 | `tss_init` | -| Unified sessions | 8 | dkg (2) + sign (2) + refresh (2) + refresh_receiver (1) + session_free (1) | +| Unified sessions | 9 | dkg (2) + sign (2) + refresh (2) + refresh_receiver (1) + refresh_new_dkls (1) + session_free (1) | | Message helpers | 3 | count, at, build | | FROST-specific | 9 | aggregate, tweak (2), dealer (2), refresh (2), repair (3) | +| DKLs23-specific | 2 | repair (helper_term, assemble) | | Verification + key mgmt | 5 | verify, derive (2), export, import | | Handle + memory | 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 | diff --git a/spec/07-key-management.md b/spec/07-key-management.md index 36f4021..4dbb0a2 100644 --- a/spec/07-key-management.md +++ b/spec/07-key-management.md @@ -226,9 +226,30 @@ pub fn frost_apply_refresh( ### DKLs23 Complete Refresh -DKLs23 refresh uses `RefreshSession::new`, which re-runs the full DKG -(including OT re-initialization). This is the recommended mode when OT -state may have been partially compromised. +DKLs23 refresh uses `RefreshSession::new_dkls(key_share, session_id)` (C ABI: +`tss_refresh_new_dkls`), which re-runs DKG with a zero-constant polynomial and +re-initializes the OT and zero-share state from scratch. Three message rounds +follow the same shape as DKG phases 2–4, minus the BIP-32 derivation +broadcasts; the chain code is carried over unchanged. dkls23's +`refresh_complete_phase4` returns only the refreshed `Party`, so libtss rebuilds +the public key package itself: every verifying share moves by its party's +correction point (`share'(j) = share(j) + G·correction_j`, read from the +broadcast proof commitments) while the group key stays put, and the result is +self-checked against the new share before a handle is created. + +Two properties bind callers: + +- **All `n` parties must refresh together under one fresh `session_id`.** The + refreshed party records it as its session id, and signing salts the + two-party multiplication transcript with it, so pre- and post-refresh shares + never co-sign. A partial refresh leaves the group unsignable until the + stragglers are repaired from the majority generation and refreshed again. +- **Only secp256k1 shares are supported.** FROST and secp256r1 shares return + `ProtocolMismatch`; a hollow share from [`repair_assemble`](#dkls23-share-repair) + is accepted — turning it back into a signing share is the point. + +This is also the recommended mode when OT state may have been partially +compromised. ### FROST Dealer/Receiver Refresh @@ -302,14 +323,46 @@ pub fn frost_repair_part3( ### DKLs23 Share Repair -DKLs23 does not have a built-in repair mechanism equivalent to FROST's RTS. For DKLs23, -share recovery requires: +dkls23 has no built-in repair; libtss implements one for secp256k1 as a +two-step recovery (`repair_helper_term` / `repair_assemble`, C ABI +`tss_repair_helper_term` / `tss_repair_assemble`) followed by a complete +refresh. The OT correlations in a `Party` cannot be reconstructed by helpers, +so repair rebuilds only the *secret share* and leaves the refresh to rebuild +the rest. -1. Re-running the complete DKG with the recovering party -2. Or using the re-key function with the reconstructed secret (requires trusted entity) +**Step 1 — helpers (one round, unicast to the target).** A set of at least +`t` surviving parties agree on the helper set and a fresh `repair_sid`. Each +helper `i` sends the target -This is a limitation of the OT-based approach where the Party state includes OT -correlations that cannot be independently reconstructed. +```text +term_i = poly_point_i · λ_i(target) + ZeroShare::compute(helpers \ {i}, "libtss/dkls23-repair/v1" || repair_sid) +``` + +`λ_i(target)` is the Lagrange coefficient over the helper indices, so the +unmasked terms sum to `f(target)`. The masks are the same pairwise zero-share +seeds signing uses, keyed by a domain-separated session id, and sum to zero +over the helper set — no new key material and no mask exchange. Any proper +subset of the terms is uniformly random, so no helper learns the share. +`repair_helper_term` fails closed with `InvalidShare` if the caller lacks a +seed pair with any helper counterparty (`ZeroShare::compute` would otherwise +skip it silently and leave `poly_point · λ` unmasked). + +**Step 2 — target.** `repair_assemble` sums the terms, checks +`G · Σ terms == verifying_share(target)` from the helpers' public key package +(`InvalidShare` otherwise — no handle is created) and builds a *hollow* +`Party`: correct `poly_point`, `pk`, address and root-level derivation data +(`depth 0`, the group chain code), but an empty `ZeroShare` and empty +multiplication maps. The handle passes import validation, exports, derives +addresses and refreshes; `SignSession::new_dkls` refuses it with +`InvalidShare` and it cannot act as a repair helper. + +**Step 3 — complete refresh across all `n`.** `RefreshSession::new_dkls` +rebuilds the OT and zero-share state for every party and re-randomises every +share under the unchanged group key. Only after this can the repaired party +sign. + +Only master (depth-0) shares are repaired; derived shares are re-derived from +the refreshed master, per dkls23's own guidance on refreshing derivations. ## Key Share Persistence From 1aa2ee894986ff4ff9c43c465a0c9a05cb1b47d9 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:45:13 +0200 Subject: [PATCH 5/6] Add scalar tweaks for DKLs23 key shares Lightning derives a fresh key for every channel state by adding a number to its base key, or multiplying it. If the node is backed by a threshold key, every party has to apply the same tweak to its own share, and apps reach DKLs23 through libtss. Adds tweak_add and tweak_mul for DKLs23 shares in Rust, the C API (tss_tweak_add, tss_tweak_mul) and every binding. The math is DKLs23's Party and PublicKeyPackage tweak API; libtss parses the 32-byte scalar, picks the address function and wraps the result in a new handle. Tests cover DKG, tweak, sign and verify under the tweaked key at the Rust, FFI, Go, Node and Python layers. --- libtss-dotnet/src/Libtss/Native.cs | 14 + libtss-dotnet/src/Libtss/TweakOperations.cs | 100 ++++++ .../tests/Libtss.Tests/TweakTests.cs | 320 ++++++++++++++++++ libtss-ffi/libtss.h | 25 ++ libtss-ffi/src/lib.rs | 1 + libtss-ffi/src/tweak.rs | 67 ++++ libtss-ffi/tests/tweak_test.rs | 204 +++++++++++ libtss-go/tss/tweak.go | 61 ++++ libtss-go/tss/tweak_test.go | 155 +++++++++ libtss-node/README.md | 2 + libtss-node/src/__tests__/tweak.test.ts | 157 +++++++++ libtss-node/src/ffi.ts | 8 + libtss-node/src/index.ts | 56 +++ libtss-python/libtss/_ffi.py | 7 + libtss-python/libtss/tweak.py | 100 ++++++ libtss-python/tests/test_libtss.py | 104 ++++++ libtss-rn/android/src/main/cpp/libtss-jni.cpp | 52 +++ .../main/java/com/libtss/LibtssModule.java | 12 + libtss-rn/ios/Libtss.mm | 60 ++++ libtss-rn/src/NativeLibtss.ts | 2 + .../src/__tests__/__mocks__/NativeLibtss.ts | 28 ++ libtss-rn/src/__tests__/tweak.test.ts | 130 +++++++ libtss-rn/src/index.ts | 62 ++++ libtss-wasm/README.md | 2 + libtss-wasm/src/lib.rs | 2 + libtss-wasm/src/tweak.rs | 60 ++++ libtss-wasm/tests/tweak.rs | 196 +++++++++++ libtss/src/lib.rs | 2 + libtss/src/tweak.rs | 301 ++++++++++++++++ libtss/tests/protocol_tests.rs | 149 ++++++++ spec/03-api-design.md | 25 +- spec/04-frost-integration.md | 2 + spec/06-ffi-layer.md | 9 +- spec/07-key-management.md | 43 +++ spec/10-testing.md | 3 + 35 files changed, 2517 insertions(+), 4 deletions(-) create mode 100644 libtss-dotnet/src/Libtss/TweakOperations.cs create mode 100644 libtss-dotnet/tests/Libtss.Tests/TweakTests.cs create mode 100644 libtss-ffi/src/tweak.rs create mode 100644 libtss-ffi/tests/tweak_test.rs create mode 100644 libtss-go/tss/tweak.go create mode 100644 libtss-go/tss/tweak_test.go create mode 100644 libtss-node/src/__tests__/tweak.test.ts create mode 100644 libtss-python/libtss/tweak.py create mode 100644 libtss-rn/src/__tests__/tweak.test.ts create mode 100644 libtss-wasm/src/tweak.rs create mode 100644 libtss-wasm/tests/tweak.rs create mode 100644 libtss/src/tweak.rs diff --git a/libtss-dotnet/src/Libtss/Native.cs b/libtss-dotnet/src/Libtss/Native.cs index 63dc89a..02e3219 100644 --- a/libtss-dotnet/src/Libtss/Native.cs +++ b/libtss-dotnet/src/Libtss/Native.cs @@ -288,6 +288,20 @@ public static extern int tss_derive_path( [MarshalAs(UnmanagedType.LPUTF8Str)] string path, ulong* out_handle); + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern int tss_tweak_add( + ulong key_share, + byte* tweak, + nuint tweak_len, + ulong* out_handle); + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern int tss_tweak_mul( + ulong key_share, + byte* factor, + nuint factor_len, + ulong* out_handle); + // --- Init --- [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] diff --git a/libtss-dotnet/src/Libtss/TweakOperations.cs b/libtss-dotnet/src/Libtss/TweakOperations.cs new file mode 100644 index 0000000..c947075 --- /dev/null +++ b/libtss-dotnet/src/Libtss/TweakOperations.cs @@ -0,0 +1,100 @@ +namespace Libtss; + +/// +/// Scalar tweaks for DKLs23 threshold ECDSA key shares +/// ( and ). +/// Lightning derives per-commitment keys from caller-supplied scalars rather than BIP-32 +/// indices: LND's single tweak is k' = k + t (mod n) and its revocation ("double") +/// tweak composes k' = k * a (mod n) with an additive step. +/// +/// +/// +/// Each operation returns a new key share whose secret share, public key, BIP-32 derivation +/// data and address are moved by the given map, together with a public key package whose +/// group key and every verifying share are moved by the same map. Because Lagrange +/// coefficients sum to one, a threshold set of tweaked shares signs under the tweaked group +/// key; every party must apply the same tweak before a signing session. +/// +/// +/// A tweak is not a BIP-32 edge: the derivation metadata still describes the un-tweaked +/// ancestor and the chain code is unchanged. For BIP-341 Taproot tweaking of FROST shares +/// see . +/// +/// +public static class TweakOperations +{ + private const int ScalarLength = 32; + + /// + /// Returns a new key share whose secret is k + tweak (mod n). + /// The original key share remains valid. + /// + /// The key share to tweak. + /// + /// A 32-byte big-endian scalar, reduced mod n (LND's per-commitment "single tweak" + /// convention). A zero tweak is a no-op. + /// + /// A new key share handle for the tweaked share. + /// + /// or is null. + /// + /// is not 32 bytes. + /// + /// when the key share is not a DKLs23 share; + /// when the tweak produces the identity public key. + /// + public static unsafe KeyShareHandle TweakAdd(KeyShareHandle keyShare, byte[] tweak) + { + ArgumentNullException.ThrowIfNull(keyShare); + ValidateScalar(tweak, nameof(tweak)); + + ulong outHandle; + int status; + fixed (byte* p = tweak) + { + status = Native.tss_tweak_add(keyShare.Handle, p, (nuint)tweak.Length, &outHandle); + } + TssException.ThrowIfError(status); + return new KeyShareHandle(outHandle); + } + + /// + /// Returns a new key share whose secret is k * factor (mod n). + /// The original key share remains valid. + /// + /// The key share to tweak. + /// + /// A 32-byte big-endian scalar, reduced mod n; the building block of LND's revocation + /// ("double") tweak. A factor of one is a no-op. + /// + /// A new key share handle for the tweaked share. + /// + /// or is null. + /// + /// is not 32 bytes. + /// + /// when the key share is not a DKLs23 share; + /// when the factor reduces to zero. + /// + public static unsafe KeyShareHandle TweakMul(KeyShareHandle keyShare, byte[] factor) + { + ArgumentNullException.ThrowIfNull(keyShare); + ValidateScalar(factor, nameof(factor)); + + ulong outHandle; + int status; + fixed (byte* p = factor) + { + status = Native.tss_tweak_mul(keyShare.Handle, p, (nuint)factor.Length, &outHandle); + } + TssException.ThrowIfError(status); + return new KeyShareHandle(outHandle); + } + + private static void ValidateScalar(byte[] scalar, string paramName) + { + ArgumentNullException.ThrowIfNull(scalar, paramName); + if (scalar.Length != ScalarLength) + throw new ArgumentException($"Must be exactly {ScalarLength} bytes.", paramName); + } +} diff --git a/libtss-dotnet/tests/Libtss.Tests/TweakTests.cs b/libtss-dotnet/tests/Libtss.Tests/TweakTests.cs new file mode 100644 index 0000000..5804745 --- /dev/null +++ b/libtss-dotnet/tests/Libtss.Tests/TweakTests.cs @@ -0,0 +1,320 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text; +using Libtss; +using Xunit; + +namespace Libtss.Tests; + +/// +/// Exercises against the native library. DKLs23 shares are +/// produced by an in-process DKG so the positive path (tweaked shares signing under the +/// tweaked group key) is covered, not only the argument and status-code rejections. +/// +public class TweakTests +{ + private const int MaxProtocolRounds = 16; + + private const string DkgDriverBlocked = + "Needs a DKLs23 share from DkgSession, but Native.tss_dkg_next / tss_sign_next / tss_refresh_next " + + "declare `[MarshalAs(UnmanagedType.U1)] bool* out_complete`, which the runtime rejects with " + + "MarshalDirectiveException (pre-existing). Declare those parameters as `byte*`, compare against 0 " + + "at the three Next call sites, and remove this Skip."; + + private static readonly byte[] DkgSessionId = Encoding.UTF8.GetBytes("tweak-tests-dkg"); + private static readonly byte[] SignSessionId = Encoding.UTF8.GetBytes("tweak-tests-sign"); + + private static void RequireNativeLibrary() + { + try + { + _ = Tss.Version(); + } + catch (DllNotFoundException) + { + Assert.Fail( + "Native library libtss_ffi not found. " + + "Build the Rust crate first: cargo build --release"); + } + Tss.Init(); + } + + private static byte[] Scalar(ulong value) + { + var scalar = new byte[32]; + BinaryPrimitives.WriteUInt64BigEndian(scalar.AsSpan(24), value); + return scalar; + } + + private static Message[] Inbox(Message[]?[] outbound, ushort[] ids, int recipient) + { + var inbox = new List(); + for (int sender = 0; sender < outbound.Length; sender++) + { + if (sender == recipient || outbound[sender] is null) + continue; + foreach (var message in outbound[sender]!) + { + if (message.To is null || message.To == ids[recipient]) + inbox.Add(message); + } + } + return inbox.ToArray(); + } + + private static KeyShareHandle[] RunDkg(Ciphersuite suite, ushort maxSigners, ushort minSigners) + { + var sessions = new DkgSession[maxSigners]; + var outbound = new Message[]?[maxSigners]; + var ids = new ushort[maxSigners]; + var shares = new KeyShareHandle?[maxSigners]; + try + { + for (ushort id = 1; id <= maxSigners; id++) + { + ids[id - 1] = id; + (sessions[id - 1], outbound[id - 1]) = DkgSession.Create(suite, id, maxSigners, minSigners, DkgSessionId); + } + + for (int round = 0; round < MaxProtocolRounds && shares.Any(s => s is null); round++) + { + var next = new Message[]?[maxSigners]; + for (int i = 0; i < maxSigners; i++) + { + if (shares[i] is not null) + continue; + var result = sessions[i].Next(Inbox(outbound, ids, i)); + if (result.Complete) + shares[i] = result.KeyShare; + else + next[i] = result.Messages; + } + outbound = next; + } + + Assert.All(shares, share => Assert.NotNull(share)); + return shares.Select(share => share!).ToArray(); + } + finally + { + foreach (var session in sessions) + session?.Dispose(); + } + } + + private static byte[] RunSign(KeyShareHandle[] signers, byte[] digest) + { + var ids = signers.Select(share => share.Identifier).ToArray(); + var sessions = new SignSession[signers.Length]; + var outbound = new Message[]?[signers.Length]; + byte[]? signature = null; + try + { + for (int i = 0; i < signers.Length; i++) + { + var counterparties = ids.Where((_, j) => j != i).ToArray(); + (sessions[i], outbound[i]) = SignSession.Create(signers[i], digest, counterparties, SignSessionId); + } + + var complete = new bool[signers.Length]; + for (int round = 0; round < MaxProtocolRounds && complete.Any(c => !c); round++) + { + var next = new Message[]?[signers.Length]; + for (int i = 0; i < signers.Length; i++) + { + if (complete[i]) + continue; + var result = sessions[i].Next(Inbox(outbound, ids, i)); + if (result.Complete) + { + complete[i] = true; + signature = result.Signature; + } + else + { + next[i] = result.Messages; + } + } + outbound = next; + } + + Assert.All(complete, c => Assert.True(c)); + Assert.NotNull(signature); + return signature!; + } + finally + { + foreach (var session in sessions) + session?.Dispose(); + } + } + + private static void DisposeAll(IEnumerable handles) + { + foreach (var handle in handles) + handle.Dispose(); + } + + [Fact] + public void TweakAdd_UnsupportedSuite_Throws() + { + RequireNativeLibrary(); + var (handles, _) = FrostOperations.GenerateDealer(Ciphersuite.Ed25519, maxSigners: 3, minSigners: 2); + try + { + var add = Assert.Throws(() => TweakOperations.TweakAdd(handles[0], Scalar(7))); + Assert.Equal(TssStatus.ProtoMismatch, add.Code); + + var mul = Assert.Throws(() => TweakOperations.TweakMul(handles[0], Scalar(7))); + Assert.Equal(TssStatus.ProtoMismatch, mul.Code); + } + finally + { + DisposeAll(handles); + } + } + + [Fact] + public void TweakAdd_RejectsBadLength() + { + RequireNativeLibrary(); + var (handles, _) = FrostOperations.GenerateDealer(Ciphersuite.Ed25519, maxSigners: 3, minSigners: 2); + try + { + Assert.Throws(() => TweakOperations.TweakAdd(handles[0], new byte[31])); + Assert.Throws(() => TweakOperations.TweakMul(handles[0], new byte[33])); + Assert.Throws(() => TweakOperations.TweakAdd(handles[0], null!)); + Assert.Throws(() => TweakOperations.TweakMul(handles[0], null!)); + Assert.Throws(() => TweakOperations.TweakAdd(null!, Scalar(7))); + Assert.Throws(() => TweakOperations.TweakMul(null!, Scalar(7))); + } + finally + { + DisposeAll(handles); + } + } + + [Fact(Skip = DkgDriverBlocked)] + public void TweakMul_ZeroFactor_Throws() + { + RequireNativeLibrary(); + var shares = RunDkg(Ciphersuite.Secp256k1ECDSA, maxSigners: 2, minSigners: 2); + try + { + var ex = Assert.Throws(() => TweakOperations.TweakMul(shares[0], Scalar(0))); + Assert.Equal(TssStatus.Tweak, ex.Code); + } + finally + { + DisposeAll(shares); + } + } + + [Fact(Skip = DkgDriverBlocked)] + public void TweakMul_ByOne_And_TweakAdd_ByZero_KeepGroupKey() + { + RequireNativeLibrary(); + var shares = RunDkg(Ciphersuite.Secp256k1ECDSA, maxSigners: 2, minSigners: 2); + try + { + using var scaled = TweakOperations.TweakMul(shares[0], Scalar(1)); + using var shifted = TweakOperations.TweakAdd(shares[0], Scalar(0)); + + Assert.Equal(shares[0].GroupVerifyingKey, scaled.GroupVerifyingKey); + Assert.Equal(shares[0].VerifyingShare, scaled.VerifyingShare); + Assert.Equal(shares[0].GroupVerifyingKey, shifted.GroupVerifyingKey); + Assert.Equal(shares[0].VerifyingShare, shifted.VerifyingShare); + } + finally + { + DisposeAll(shares); + } + } + + [Theory(Skip = DkgDriverBlocked)] + [InlineData(Ciphersuite.Secp256k1ECDSA)] + [InlineData(Ciphersuite.Secp256r1ECDSA)] + public void TweakAdd_MovesGroupKeyConsistentlyAcrossParties(Ciphersuite suite) + { + RequireNativeLibrary(); + var shares = RunDkg(suite, maxSigners: 2, minSigners: 2); + var tweaked = new List(); + try + { + byte[] parentKey = shares[0].GroupVerifyingKey; + tweaked.AddRange(shares.Select(share => TweakOperations.TweakAdd(share, Scalar(7)))); + + Assert.Equal(tweaked[0].GroupVerifyingKey, tweaked[1].GroupVerifyingKey); + Assert.NotEqual(parentKey, tweaked[0].GroupVerifyingKey); + Assert.Equal(suite, tweaked[0].Ciphersuite); + Assert.Equal(shares[0].Identifier, tweaked[0].Identifier); + Assert.Equal(shares[1].Identifier, tweaked[1].Identifier); + + Assert.Equal(parentKey, shares[0].GroupVerifyingKey); + } + finally + { + DisposeAll(tweaked); + DisposeAll(shares); + } + } + + [Fact(Skip = DkgDriverBlocked)] + public void TweakAdd_SignsUnderTweakedGroupKey() + { + RequireNativeLibrary(); + var shares = RunDkg(Ciphersuite.Secp256k1ECDSA, maxSigners: 2, minSigners: 2); + var tweaked = new List(); + try + { + tweaked.AddRange(shares.Select(share => TweakOperations.TweakAdd(share, Scalar(7)))); + byte[] parentKey = shares[0].GroupVerifyingKey; + byte[] tweakedKey = tweaked[0].GroupVerifyingKey; + + byte[] message = Encoding.UTF8.GetBytes("hello tweaked ecdsa"); + byte[] signature = RunSign(tweaked.ToArray(), SHA256.HashData(message)); + + Assert.True(Tss.Verify(Ciphersuite.Secp256k1ECDSA, message, signature, tweakedKey)); + Assert.False(Tss.Verify(Ciphersuite.Secp256k1ECDSA, message, signature, parentKey)); + } + finally + { + DisposeAll(tweaked); + DisposeAll(shares); + } + } + + [Fact(Skip = DkgDriverBlocked)] + public void TweakMulThenAdd_SignsUnderComposedGroupKey() + { + RequireNativeLibrary(); + var shares = RunDkg(Ciphersuite.Secp256k1ECDSA, maxSigners: 2, minSigners: 2); + var scaled = new List(); + var composed = new List(); + try + { + scaled.AddRange(shares.Select(share => TweakOperations.TweakMul(share, Scalar(0x2A)))); + composed.AddRange(scaled.Select(share => TweakOperations.TweakAdd(share, Scalar(0x1D)))); + + byte[] parentKey = shares[0].GroupVerifyingKey; + byte[] scaledKey = scaled[0].GroupVerifyingKey; + byte[] composedKey = composed[0].GroupVerifyingKey; + Assert.Equal(composedKey, composed[1].GroupVerifyingKey); + Assert.NotEqual(parentKey, scaledKey); + Assert.NotEqual(scaledKey, composedKey); + + byte[] message = Encoding.UTF8.GetBytes("hello revocation tweak"); + byte[] signature = RunSign(composed.ToArray(), SHA256.HashData(message)); + + Assert.True(Tss.Verify(Ciphersuite.Secp256k1ECDSA, message, signature, composedKey)); + Assert.False(Tss.Verify(Ciphersuite.Secp256k1ECDSA, message, signature, scaledKey)); + Assert.False(Tss.Verify(Ciphersuite.Secp256k1ECDSA, message, signature, parentKey)); + } + finally + { + DisposeAll(composed); + DisposeAll(scaled); + DisposeAll(shares); + } + } +} diff --git a/libtss-ffi/libtss.h b/libtss-ffi/libtss.h index 24a9d31..f5571e2 100644 --- a/libtss-ffi/libtss.h +++ b/libtss-ffi/libtss.h @@ -340,4 +340,29 @@ TssStatus tss_refresh_next(TssHandle session, void tss_session_free(TssHandle session); +// Apply an additive scalar tweak to a key share: `k' = k + tweak mod n`. +// +// `tweak` is a 32-byte big-endian scalar, reduced mod n (LND's per-commitment +// "single tweak" convention). Supported for the DKLs23 ciphersuites +// (Secp256k1ECDSA, Secp256r1ECDSA); other suites return +// `TSS_ERR_PROTO_MISMATCH`. A wrong-length or null `tweak` returns +// `TSS_ERR_INVALID_CONFIG`; a tweak producing the identity point returns +// `TSS_ERR_TWEAK`. The original handle stays valid; `out_handle` receives a +// new key share that must be freed with `tss_handle_free`. +TssStatus tss_tweak_add(TssHandle key_share, + const uint8_t *tweak, + size_t tweak_len, + TssHandle *out_handle); + +// Apply a multiplicative scalar tweak to a key share: `k' = k * factor mod n`. +// +// `factor` is a 32-byte big-endian scalar, reduced mod n; it is the building +// block of LND's revocation ("double") tweak. A factor that reduces to zero +// returns `TSS_ERR_TWEAK`. Ciphersuite support and the remaining error +// mapping match `tss_tweak_add`. +TssStatus tss_tweak_mul(TssHandle key_share, + const uint8_t *factor, + size_t factor_len, + TssHandle *out_handle); + #endif /* LIBTSS_H */ diff --git a/libtss-ffi/src/lib.rs b/libtss-ffi/src/lib.rs index a13ae60..1e38809 100644 --- a/libtss-ffi/src/lib.rs +++ b/libtss-ffi/src/lib.rs @@ -11,6 +11,7 @@ pub mod messages; pub mod registry; pub mod repair; pub mod session; +pub mod tweak; pub mod types; #[macro_export] diff --git a/libtss-ffi/src/tweak.rs b/libtss-ffi/src/tweak.rs new file mode 100644 index 0000000..e2df605 --- /dev/null +++ b/libtss-ffi/src/tweak.rs @@ -0,0 +1,67 @@ +use crate::ffi_entry; +use crate::session::borrow_key_share; +use crate::types::{TssHandle, TssStatus}; + +/// Apply an additive scalar tweak to a key share: `k' = k + tweak mod n`. +/// +/// `tweak` is a 32-byte big-endian scalar, reduced mod n (LND's per-commitment +/// "single tweak" convention). Supported for the DKLs23 ciphersuites +/// (Secp256k1ECDSA, Secp256r1ECDSA); other suites return +/// `TSS_ERR_PROTO_MISMATCH`. A wrong-length or null `tweak` returns +/// `TSS_ERR_INVALID_CONFIG`; a tweak producing the identity point returns +/// `TSS_ERR_TWEAK`. The original handle stays valid; `out_handle` receives a +/// new key share that must be freed with `tss_handle_free`. +#[no_mangle] +pub extern "C" fn tss_tweak_add( + key_share: TssHandle, + tweak: *const u8, + tweak_len: usize, + out_handle: *mut TssHandle, +) -> TssStatus { + ffi_entry!({ + let out = unsafe { out_handle.as_mut() } + .ok_or_else(|| libtss::TssError::InvalidConfig("out_handle is null".into()))?; + *out = 0; + + let bytes = scalar_bytes(tweak, tweak_len, "tweak")?; + let handle = borrow_key_share(key_share)?; + let tweaked = libtss::tweak_add(&handle, bytes)?; + *out = tweaked.handle_id(); + std::mem::forget(tweaked); + Ok(()) + }) +} + +/// Apply a multiplicative scalar tweak to a key share: `k' = k * factor mod n`. +/// +/// `factor` is a 32-byte big-endian scalar, reduced mod n; it is the building +/// block of LND's revocation ("double") tweak. A factor that reduces to zero +/// returns `TSS_ERR_TWEAK`. Ciphersuite support and the remaining error +/// mapping match `tss_tweak_add`. +#[no_mangle] +pub extern "C" fn tss_tweak_mul( + key_share: TssHandle, + factor: *const u8, + factor_len: usize, + out_handle: *mut TssHandle, +) -> TssStatus { + ffi_entry!({ + let out = unsafe { out_handle.as_mut() } + .ok_or_else(|| libtss::TssError::InvalidConfig("out_handle is null".into()))?; + *out = 0; + + let bytes = scalar_bytes(factor, factor_len, "factor")?; + let handle = borrow_key_share(key_share)?; + let tweaked = libtss::tweak_mul(&handle, bytes)?; + *out = tweaked.handle_id(); + std::mem::forget(tweaked); + Ok(()) + }) +} + +fn scalar_bytes<'a>(ptr: *const u8, len: usize, name: &str) -> Result<&'a [u8], libtss::TssError> { + if ptr.is_null() { + return Err(libtss::TssError::InvalidConfig(format!("{name} is null"))); + } + Ok(unsafe { core::slice::from_raw_parts(ptr, len) }) +} diff --git a/libtss-ffi/tests/tweak_test.rs b/libtss-ffi/tests/tweak_test.rs new file mode 100644 index 0000000..0a7aebe --- /dev/null +++ b/libtss-ffi/tests/tweak_test.rs @@ -0,0 +1,204 @@ +//! FFI-level tests for scalar tweaks on DKLs23 key shares (tss_tweak_add, tss_tweak_mul). + +mod common; + +use k256::elliptic_curve::sec1::ToSec1Point; +use k256::{ProjectivePoint, PublicKey, Scalar}; +use libtss_ffi::tweak::{tss_tweak_add, tss_tweak_mul}; +use libtss_ffi::types::*; +use sha2::{Digest, Sha256}; + +fn scalar_bytes(value: u64) -> [u8; 32] { + let mut bytes = [0u8; 32]; + bytes[24..].copy_from_slice(&value.to_be_bytes()); + bytes +} + +fn group_key(handle: TssHandle) -> Vec { + let mut buf = TssBuffer::empty(); + assert_eq!( + libtss_ffi::handles::tss_handle_group_key(handle, &mut buf as *mut TssBuffer), + TSS_OK + ); + common::take_buffer(&mut buf) +} + +fn k1_point(compressed: &[u8]) -> ProjectivePoint { + PublicKey::from_sec1_bytes(compressed) + .unwrap() + .to_projective() +} + +fn compressed(point: ProjectivePoint) -> Vec { + point.to_affine().to_sec1_point(true).as_bytes().to_vec() +} + +fn tweak_add(handle: TssHandle, scalar: &[u8]) -> (TssStatus, TssHandle) { + let mut out: TssHandle = 0; + let status = tss_tweak_add( + handle, + scalar.as_ptr(), + scalar.len(), + &mut out as *mut TssHandle, + ); + (status, out) +} + +fn tweak_mul(handle: TssHandle, scalar: &[u8]) -> (TssStatus, TssHandle) { + let mut out: TssHandle = 0; + let status = tss_tweak_mul( + handle, + scalar.as_ptr(), + scalar.len(), + &mut out as *mut TssHandle, + ); + (status, out) +} + +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() +} + +#[test] +fn tweaked_shares_sign_and_verify_via_ffi() { + let keys = common::run_dkg(common::SUITE_SECP256K1_ECDSA, 2, 2); + let parent_key = group_key(keys[0]); + + let tweaked: Vec = keys + .iter() + .map(|&key| { + let (status, out) = tweak_add(key, &scalar_bytes(7)); + assert_eq!(status, TSS_OK); + assert_ne!(out, 0); + out + }) + .collect(); + + let tweaked_key = group_key(tweaked[0]); + assert_eq!(tweaked_key, group_key(tweaked[1])); + assert_eq!( + tweaked_key, + compressed(k1_point(&parent_key) + ProjectivePoint::GENERATOR * Scalar::from(7u64)) + ); + assert_eq!(group_key(keys[0]), parent_key, "parent must remain valid"); + + let message = b"ffi scalar tweak"; + let msg_hash = Sha256::digest(message); + let signature = common::run_dkls_sign(&tweaked, &msg_hash); + assert!(libtss_ffi::tss_verify( + common::SUITE_SECP256K1_ECDSA, + common::slice(message), + common::slice(&signature), + common::slice(&tweaked_key), + )); + assert!(!libtss_ffi::tss_verify( + common::SUITE_SECP256K1_ECDSA, + common::slice(message), + common::slice(&signature), + common::slice(&parent_key), + )); + + for handle in tweaked.into_iter().chain(keys) { + libtss_ffi::memory::tss_handle_free(handle); + } +} + +#[test] +fn tweak_mul_by_one_keeps_group_key() { + let keys = common::run_dkg(common::SUITE_SECP256K1_ECDSA, 2, 2); + let (status, scaled) = tweak_mul(keys[0], &scalar_bytes(1)); + assert_eq!(status, TSS_OK); + assert_eq!(group_key(scaled), group_key(keys[0])); + + let (status, scaled_by_three) = tweak_mul(keys[0], &scalar_bytes(3)); + assert_eq!(status, TSS_OK); + assert_eq!( + group_key(scaled_by_three), + compressed(k1_point(&group_key(keys[0])) * Scalar::from(3u64)) + ); + + for handle in [scaled, scaled_by_three].into_iter().chain(keys) { + libtss_ffi::memory::tss_handle_free(handle); + } +} + +#[test] +fn tweak_rejects_bad_length() { + let keys = common::run_dkg(common::SUITE_SECP256K1_ECDSA, 2, 2); + + let (status, out) = tweak_add(keys[0], &[1u8; 31]); + assert_eq!(status, TSS_ERR_INVALID_CONFIG); + assert_eq!(out, 0); + + let (status, out) = tweak_mul(keys[0], &[1u8; 33]); + assert_eq!(status, TSS_ERR_INVALID_CONFIG); + assert_eq!(out, 0); + + for handle in keys { + libtss_ffi::memory::tss_handle_free(handle); + } +} + +#[test] +fn tweak_mul_zero_factor_rejected() { + let keys = common::run_dkg(common::SUITE_SECP256K1_ECDSA, 2, 2); + let (status, out) = tweak_mul(keys[0], &scalar_bytes(0)); + assert_eq!(status, TSS_ERR_TWEAK); + assert_eq!(out, 0); + for handle in keys { + libtss_ffi::memory::tss_handle_free(handle); + } +} + +#[test] +fn tweak_unsupported_suite_rejected() { + let handles = ed25519_handles(); + let (status, out) = tweak_add(handles[0], &scalar_bytes(7)); + assert_eq!(status, TSS_ERR_PROTO_MISMATCH); + assert_eq!(out, 0); + let (status, out) = tweak_mul(handles[0], &scalar_bytes(3)); + assert_eq!(status, TSS_ERR_PROTO_MISMATCH); + assert_eq!(out, 0); + for handle in handles { + libtss_ffi::memory::tss_handle_free(handle); + } +} + +#[test] +fn tweak_null_pointers_rejected() { + let keys = common::run_dkg(common::SUITE_SECP256K1_ECDSA, 2, 2); + + let mut out: TssHandle = 0; + assert_eq!( + tss_tweak_add(keys[0], std::ptr::null(), 32, &mut out as *mut TssHandle), + TSS_ERR_INVALID_CONFIG + ); + assert_eq!(out, 0); + + let scalar = scalar_bytes(7); + assert_eq!( + tss_tweak_mul(keys[0], scalar.as_ptr(), scalar.len(), std::ptr::null_mut()), + TSS_ERR_INVALID_CONFIG + ); + + let (status, out) = tweak_add(u64::MAX, &scalar); + assert_eq!(status, TSS_ERR_HANDLE_INVALID); + assert_eq!(out, 0); + + for handle in keys { + libtss_ffi::memory::tss_handle_free(handle); + } +} diff --git a/libtss-go/tss/tweak.go b/libtss-go/tss/tweak.go new file mode 100644 index 0000000..eb99578 --- /dev/null +++ b/libtss-go/tss/tweak.go @@ -0,0 +1,61 @@ +package tss + +/* +#include "tss_ffi.h" +*/ +import "C" + +import ( + "fmt" + "unsafe" +) + +const tweakScalarLen = 32 + +// TweakAdd returns a new key share whose secret is secret + tweak (mod n). +// +// tweak is a 32-byte big-endian scalar, reduced mod n, matching LND's +// per-commitment single tweak. The public key, derivation data, address and +// every verifying share move by the same tweak, so a threshold set of tweaked +// shares signs under the tweaked group key. Supported for Secp256k1ECDSA and +// Secp256r1ECDSA (DKLs23); other ciphersuites return a *TssError with the +// protocol-mismatch code. The original key share remains valid. +func TweakAdd(keyShare *KeyShareHandle, tweak []byte) (*KeyShareHandle, error) { + return applyTweak(keyShare, tweak, true) +} + +// TweakMul returns a new key share whose secret is secret * factor (mod n). +// +// factor is a 32-byte big-endian scalar, reduced mod n; it is the building +// block of LND's revocation (double) tweak. A factor that reduces to zero is +// rejected with a *TssError carrying the tweak error code. Ciphersuite support +// matches TweakAdd. +func TweakMul(keyShare *KeyShareHandle, factor []byte) (*KeyShareHandle, error) { + return applyTweak(keyShare, factor, false) +} + +func applyTweak(keyShare *KeyShareHandle, scalar []byte, add bool) (*KeyShareHandle, error) { + if keyShare == nil { + return nil, fmt.Errorf("keyShare is nil") + } + if len(scalar) != tweakScalarLen { + return nil, fmt.Errorf("tweak must be %d bytes, got %d", tweakScalarLen, len(scalar)) + } + handle, err := keyShare.native.value() + if err != nil { + return nil, err + } + ptr := (*C.uint8_t)(unsafe.Pointer(&scalar[0])) + var out C.TssHandle + if err := callStatus(func() C.TssStatus { + if add { + return C.tss_tweak_add(handle, ptr, C.uintptr_t(len(scalar)), &out) + } + + return C.tss_tweak_mul(handle, ptr, C.uintptr_t(len(scalar)), &out) + }); err != nil { + return nil, err + } + + return newKeyShareHandle(out), nil +} diff --git a/libtss-go/tss/tweak_test.go b/libtss-go/tss/tweak_test.go new file mode 100644 index 0000000..d889e51 --- /dev/null +++ b/libtss-go/tss/tweak_test.go @@ -0,0 +1,155 @@ +package tss + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "errors" + "testing" +) + +// Mirrors of the C status codes; cgo constants are not reachable from _test.go files. +const ( + tssErrTweakCode = 13 + tssErrProtoMismatchCode = 8 +) + +func tweakScalar(value uint64) []byte { + scalar := make([]byte, tweakScalarLen) + binary.BigEndian.PutUint64(scalar[tweakScalarLen-8:], value) + return scalar +} + +func tweakAll(t *testing.T, shares []*KeyShareHandle, scalar []byte, add bool) []*KeyShareHandle { + t.Helper() + tweaked := make([]*KeyShareHandle, len(shares)) + for i, share := range shares { + var err error + if add { + tweaked[i], err = TweakAdd(share, scalar) + } else { + tweaked[i], err = TweakMul(share, scalar) + } + if err != nil { + t.Fatalf("tweak share %d: %v", i+1, err) + } + } + return tweaked +} + +func TestDKLs23TweakAddSignsUnderTweakedKey(t *testing.T) { + shares, _ := runDKG(t, CiphersuiteSecp256k1ECDSA, 2, 2, []byte("tweak-session")) + for _, share := range shares { + defer share.Free() + } + tweaked := tweakAll(t, shares, tweakScalar(7), true) + for _, share := range tweaked { + defer share.Free() + } + + parentKey, err := shares[0].GroupKey() + if err != nil { + t.Fatalf("GroupKey: %v", err) + } + tweakedKey, err := tweaked[0].GroupKey() + if err != nil { + t.Fatalf("GroupKey (tweaked): %v", err) + } + otherTweakedKey, err := tweaked[1].GroupKey() + if err != nil { + t.Fatalf("GroupKey (tweaked, party 2): %v", err) + } + if !bytes.Equal(tweakedKey, otherTweakedKey) { + t.Fatal("tweaked group key differs across parties") + } + if bytes.Equal(tweakedKey, parentKey) { + t.Fatal("tweaked group key equals the parent key") + } + + msg := []byte("hello tweaked ecdsa") + hash := sha256.Sum256(msg) + sig := runSignRound(t, CiphersuiteSecp256k1ECDSA, tweaked, hash[:]) + + valid, err := Verify(CiphersuiteSecp256k1ECDSA, msg, sig.Data, tweakedKey) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if !valid { + t.Fatal("signature by tweaked shares does not verify under the tweaked key") + } + valid, err = Verify(CiphersuiteSecp256k1ECDSA, msg, sig.Data, parentKey) + if err != nil { + t.Fatalf("Verify (parent key): %v", err) + } + if valid { + t.Fatal("signature by tweaked shares must not verify under the parent key") + } +} + +func TestDKLs23TweakMulByOneIsIdentity(t *testing.T) { + shares, _ := runDKG(t, CiphersuiteSecp256k1ECDSA, 2, 2, []byte("tweak-mul-session")) + for _, share := range shares { + defer share.Free() + } + + scaled, err := TweakMul(shares[0], tweakScalar(1)) + if err != nil { + t.Fatalf("TweakMul: %v", err) + } + defer scaled.Free() + + parentKey, _ := shares[0].GroupKey() + scaledKey, _ := scaled.GroupKey() + if !bytes.Equal(parentKey, scaledKey) { + t.Fatal("multiplying by one changed the group key") + } +} + +func TestTweakMulZeroRejected(t *testing.T) { + shares, _ := runDKG(t, CiphersuiteSecp256k1ECDSA, 2, 2, []byte("tweak-zero-session")) + for _, share := range shares { + defer share.Free() + } + + _, err := TweakMul(shares[0], tweakScalar(0)) + var tssErr *TssError + if !errors.As(err, &tssErr) { + t.Fatalf("TweakMul error = %v, want *TssError", err) + } + if tssErr.Code != tssErrTweakCode { + t.Fatalf("TweakMul error code = %d, want %d", tssErr.Code, tssErrTweakCode) + } +} + +func TestTweakRejectsBadLength(t *testing.T) { + shares, _ := runDKG(t, CiphersuiteSecp256k1ECDSA, 2, 2, []byte("tweak-len-session")) + for _, share := range shares { + defer share.Free() + } + + if _, err := TweakAdd(shares[0], make([]byte, 31)); err == nil { + t.Fatal("TweakAdd accepted a 31-byte scalar") + } + if _, err := TweakMul(shares[0], make([]byte, 33)); err == nil { + t.Fatal("TweakMul accepted a 33-byte scalar") + } + if _, err := TweakAdd(nil, tweakScalar(7)); err == nil { + t.Fatal("TweakAdd accepted a nil key share") + } +} + +func TestTweakUnsupportedSuite(t *testing.T) { + shares, _ := runDKG(t, CiphersuiteEd25519, 3, 2, nil) + for _, share := range shares { + defer share.Free() + } + + _, err := TweakAdd(shares[0], tweakScalar(7)) + var tssErr *TssError + if !errors.As(err, &tssErr) { + t.Fatalf("TweakAdd error = %v, want *TssError", err) + } + if tssErr.Code != tssErrProtoMismatchCode { + t.Fatalf("TweakAdd error code = %d, want %d", tssErr.Code, tssErrProtoMismatchCode) + } +} diff --git a/libtss-node/README.md b/libtss-node/README.md index 1e43a01..1e7efc7 100644 --- a/libtss-node/README.md +++ b/libtss-node/README.md @@ -98,6 +98,8 @@ const [s3, r1_3] = DkgSession.create(config, 3); | `frostSplitKey(suite, secretKey, max, min)` | Split a secret key into FROST shares (wipes input) | | `frostTweakKeyShare(ks, merkleRoot?)` | BIP-341 Taproot tweak on a key share | | `frostTweakPubkeyPackage(pkg, merkleRoot?)` | BIP-341 Taproot tweak on a public key package | +| `tweakAdd(ks, tweak)` | Additive scalar tweak `k + tweak` (32-byte big-endian, LND single tweak; DKLs23 only) | +| `tweakMul(ks, factor)` | Multiplicative scalar tweak `k * factor` (32-byte big-endian, LND revocation tweak; DKLs23 only) | ### Ciphersuites diff --git a/libtss-node/src/__tests__/tweak.test.ts b/libtss-node/src/__tests__/tweak.test.ts new file mode 100644 index 0000000..9da86ec --- /dev/null +++ b/libtss-node/src/__tests__/tweak.test.ts @@ -0,0 +1,157 @@ +import { TssError } from "../error"; +import { frostSplitKey, tweakAdd, tweakMul } from "../index"; +import { Ciphersuite, TssStatusCode } from "../types"; +import { freeAll, runDkg } from "./helpers"; + +const TWEAK_SCALAR_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("tweak-session"); + +function scalar(value: number): Uint8Array { + const bytes = new Uint8Array(TWEAK_SCALAR_BYTES); + new DataView(bytes.buffer).setUint32(TWEAK_SCALAR_BYTES - 4, value, false); + return bytes; +} + +function thrownBy(fn: () => unknown): unknown { + try { + fn(); + } catch (error) { + return error; + } + return undefined; +} + +function ed25519Shares() { + const secretKey = new Uint8Array(ED25519_SCALAR_BYTES).fill( + CANONICAL_SCALAR_BYTE, + ); + return frostSplitKey( + TWO_OF_THREE_ED25519.suite, + secretKey, + TWO_OF_THREE_ED25519.maxSigners, + TWO_OF_THREE_ED25519.minSigners, + ).keyShares; +} + +describe("tweakAdd", () => { + it("moves every party to the same tweaked group key and leaves the parent intact", () => { + const { keyShares } = runDkg(TWO_OF_TWO_ECDSA, SESSION_ID); + const parentKey = keyShares[0].groupVerifyingKey(); + const tweaked = keyShares.map((share) => tweakAdd(share, scalar(7))); + try { + const [first, second] = tweaked.map((share) => + share.groupVerifyingKey(), + ); + + expect(second).toEqual(first); + expect(first).not.toEqual(parentKey); + expect(tweaked[0].verifyingShare()).not.toEqual( + keyShares[0].verifyingShare(), + ); + expect(tweaked.map((share) => share.identifier())).toEqual( + keyShares.map((share) => share.identifier()), + ); + expect(keyShares[0].groupVerifyingKey()).toEqual(parentKey); + expect(keyShares[1].groupVerifyingKey()).toEqual(parentKey); + } finally { + freeAll([...tweaked, ...keyShares]); + } + }); + + it("rejects a wrong-length tweak before reaching the native library", () => { + const { keyShares } = runDkg(TWO_OF_TWO_ECDSA, SESSION_ID); + try { + const error = thrownBy(() => + tweakAdd(keyShares[0], new Uint8Array(TWEAK_SCALAR_BYTES - 1)), + ); + + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(TssError); + expect((error as Error).message).toBe("tweak must be 32 bytes"); + } finally { + freeAll(keyShares); + } + }); + + it("rejects ciphersuites without scalar tweaks", () => { + const keyShares = ed25519Shares(); + try { + const error = thrownBy(() => tweakAdd(keyShares[0], scalar(7))); + + expect(error).toBeInstanceOf(TssError); + expect((error as TssError).code).toBe(TssStatusCode.PROTO_MISMATCH); + } finally { + freeAll(keyShares); + } + }); +}); + +describe("tweakMul", () => { + it("leaves the group key unchanged when multiplying by one", () => { + const { keyShares } = runDkg(TWO_OF_TWO_ECDSA, SESSION_ID); + const scaled = tweakMul(keyShares[0], scalar(1)); + try { + expect(scaled.groupVerifyingKey()).toEqual( + keyShares[0].groupVerifyingKey(), + ); + expect(scaled.verifyingShare()).toEqual(keyShares[0].verifyingShare()); + } finally { + freeAll([scaled, ...keyShares]); + } + }); + + it("rejects a zero factor with the TWEAK status", () => { + const { keyShares } = runDkg(TWO_OF_TWO_ECDSA, SESSION_ID); + try { + const error = thrownBy(() => tweakMul(keyShares[0], scalar(0))); + + expect(error).toBeInstanceOf(TssError); + expect((error as TssError).code).toBe(TssStatusCode.TWEAK); + } finally { + freeAll(keyShares); + } + }); + + it("rejects a wrong-length factor before reaching the native library", () => { + const { keyShares } = runDkg(TWO_OF_TWO_ECDSA, SESSION_ID); + try { + const error = thrownBy(() => + tweakMul(keyShares[0], new Uint8Array(TWEAK_SCALAR_BYTES + 1)), + ); + + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(TssError); + expect((error as Error).message).toBe("tweak must be 32 bytes"); + } finally { + freeAll(keyShares); + } + }); + + it("rejects ciphersuites without scalar tweaks", () => { + const keyShares = ed25519Shares(); + try { + const error = thrownBy(() => tweakMul(keyShares[0], scalar(7))); + + expect(error).toBeInstanceOf(TssError); + expect((error as TssError).code).toBe(TssStatusCode.PROTO_MISMATCH); + } finally { + freeAll(keyShares); + } + }); +}); diff --git a/libtss-node/src/ffi.ts b/libtss-node/src/ffi.ts index 0eb5786..f6bf0ed 100644 --- a/libtss-node/src/ffi.ts +++ b/libtss-node/src/ffi.ts @@ -168,6 +168,14 @@ export const lib: Record any> = { tss_derive_path: native.func( "int32_t tss_derive_path(uint64_t key_share, const char *path, _Out_ uint64_t *out_handle)", ), + + // Scalar tweaks + tss_tweak_add: native.func( + "int32_t tss_tweak_add(uint64_t key_share, const uint8_t *tweak, size_t tweak_len, _Out_ uint64_t *out_handle)", + ), + tss_tweak_mul: native.func( + "int32_t tss_tweak_mul(uint64_t key_share, const uint8_t *factor, size_t factor_len, _Out_ uint64_t *out_handle)", + ), }; /* eslint-enable @typescript-eslint/no-explicit-any */ diff --git a/libtss-node/src/index.ts b/libtss-node/src/index.ts index 2b31a92..4a25805 100644 --- a/libtss-node/src/index.ts +++ b/libtss-node/src/index.ts @@ -137,6 +137,62 @@ export function derivePath( return new KeyShareHandle(outHandle[0]); } +// ── Scalar Tweaks ────────────────────────────────────────────────────────── + +const TWEAK_SCALAR_BYTES = 32; + +function assertTweakScalar(scalar: Uint8Array): void { + if (scalar.length !== TWEAK_SCALAR_BYTES) { + throw new Error(`tweak must be ${TWEAK_SCALAR_BYTES} bytes`); + } +} + +/** + * Returns a new key share whose secret is `k + tweak (mod n)`. + * + * `tweak` is a 32-byte big-endian scalar, reduced mod n — LND's per-commitment + * "single tweak" convention. The group key, verifying shares, derivation data + * and address all move by the same tweak, so a threshold set of tweaked shares + * signs under the tweaked group key. Supported only for the DKLs23 ECDSA + * ciphersuites (Secp256k1ECDSA / Secp256r1ECDSA); any other suite throws a + * `TssError` with `PROTO_MISMATCH`. A tweak that lands the group key on the + * identity point throws a `TssError` with `TWEAK`. A wrong-length tweak throws + * a plain `Error` before reaching the native library. The parent handle stays + * valid; the returned handle must be freed like any other key share. + */ +export function tweakAdd( + keyShare: KeyShareHandle, + tweak: Uint8Array, +): KeyShareHandle { + assertTweakScalar(tweak); + const outHandle: [bigint] = [0n]; + checkStatus( + lib.tss_tweak_add(keyShare.handle, tweak, tweak.length, outHandle), + ); + return new KeyShareHandle(outHandle[0]); +} + +/** + * Returns a new key share whose secret is `k * factor (mod n)`. + * + * `factor` is a 32-byte big-endian scalar, reduced mod n — the building block + * of LND's revocation ("double") tweak. A factor that reduces to zero throws a + * `TssError` with `TWEAK`. Ciphersuite support, the wrong-length check and the + * remaining error mapping match `tweakAdd`. The parent handle stays valid; the + * returned handle must be freed like any other key share. + */ +export function tweakMul( + keyShare: KeyShareHandle, + factor: Uint8Array, +): KeyShareHandle { + assertTweakScalar(factor); + const outHandle: [bigint] = [0n]; + checkStatus( + lib.tss_tweak_mul(keyShare.handle, factor, factor.length, outHandle), + ); + return new KeyShareHandle(outHandle[0]); +} + // ── FROST Operations ─────────────────────────────────────────────────────── export function frostTweakKeyShare( diff --git a/libtss-python/libtss/_ffi.py b/libtss-python/libtss/_ffi.py index 29e4cc5..9564cca 100644 --- a/libtss-python/libtss/_ffi.py +++ b/libtss-python/libtss/_ffi.py @@ -344,6 +344,13 @@ def _declare_signatures(lib: ctypes.CDLL) -> None: lib.tss_derive_path.restype = TssStatus lib.tss_derive_path.argtypes = [c_uint64, c_char_p, POINTER(c_uint64)] + # Scalar tweaks + lib.tss_tweak_add.restype = TssStatus + lib.tss_tweak_add.argtypes = [c_uint64, POINTER(c_uint8), c_size_t, POINTER(c_uint64)] + + lib.tss_tweak_mul.restype = TssStatus + lib.tss_tweak_mul.argtypes = [c_uint64, POINTER(c_uint8), c_size_t, POINTER(c_uint64)] + # ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/libtss-python/libtss/tweak.py b/libtss-python/libtss/tweak.py new file mode 100644 index 0000000..bbb327e --- /dev/null +++ b/libtss-python/libtss/tweak.py @@ -0,0 +1,100 @@ +"""Scalar tweaks for DKLs23 ECDSA key shares. + +These are the building blocks of LND's per-commitment key derivation: the +additive "single" tweak (``k + tweak``) and the multiplicative half of the +revocation ("double") tweak (``k * factor``). +""" + +from __future__ import annotations + +import ctypes + +from libtss._ffi import get_lib, wipe_ctypes_array +from libtss.errors import check_status +from libtss.handle import KeyShareHandle + +SCALAR_LEN = 32 + + +def tweak_add(key_share: KeyShareHandle, tweak: bytes) -> KeyShareHandle: + """Apply an additive scalar tweak to a key share: ``k' = k + tweak mod n``. + + The public key, BIP-32 derivation data, address and every verifying + share move by the same map, so the tweaked shares sign under the + tweaked group key (``pk' = pk + tweak * G``). This is LND's + per-commitment "single tweak". + + Supports Secp256k1ECDSA and Secp256r1ECDSA (DKLs23) only; other + ciphersuites raise :class:`~libtss.TssError` with code + ``TSS_ERR_PROTO_MISMATCH`` (8). A tweak whose result is the identity + point raises :class:`~libtss.TssError` with code ``TSS_ERR_TWEAK`` (13). + + The original key share remains valid; the returned handle is a new + key share that must be closed independently. + + Args: + key_share: Handle to the parent key share. + tweak: Exactly 32 bytes, big-endian scalar, reduced mod n. + + Returns: + A new :class:`~libtss.KeyShareHandle` for the tweaked share. + + Raises: + ValueError: If ``tweak`` is not exactly 32 bytes (checked before + any native call). + """ + return _apply_scalar_tweak("tss_tweak_add", key_share, tweak, "tweak") + + +def tweak_mul(key_share: KeyShareHandle, factor: bytes) -> KeyShareHandle: + """Apply a multiplicative scalar tweak to a key share: ``k' = k * factor mod n``. + + The public key, BIP-32 derivation data, address and every verifying + share move by the same map, so the tweaked shares sign under the + tweaked group key (``pk' = factor * pk``). This is the multiplicative + step of LND's revocation ("double") tweak. + + Supports Secp256k1ECDSA and Secp256r1ECDSA (DKLs23) only; other + ciphersuites raise :class:`~libtss.TssError` with code + ``TSS_ERR_PROTO_MISMATCH`` (8). A factor that reduces to zero, or a + result that is the identity point, raises :class:`~libtss.TssError` + with code ``TSS_ERR_TWEAK`` (13). + + The original key share remains valid; the returned handle is a new + key share that must be closed independently. + + Args: + key_share: Handle to the parent key share. + factor: Exactly 32 bytes, big-endian scalar, reduced mod n. + + Returns: + A new :class:`~libtss.KeyShareHandle` for the tweaked share. + + Raises: + ValueError: If ``factor`` is not exactly 32 bytes (checked before + any native call). + """ + return _apply_scalar_tweak("tss_tweak_mul", key_share, factor, "factor") + + +def _apply_scalar_tweak( + ffi_function: str, + key_share: KeyShareHandle, + scalar: bytes, + param_name: str, +) -> KeyShareHandle: + if len(scalar) != SCALAR_LEN: + raise ValueError( + f"{param_name} must be exactly {SCALAR_LEN} bytes, got {len(scalar)}" + ) + + out = ctypes.c_uint64() + scalar_arr = (ctypes.c_uint8 * SCALAR_LEN).from_buffer_copy(scalar) + try: + status = getattr(get_lib(), ffi_function)( + key_share.raw, scalar_arr, SCALAR_LEN, ctypes.byref(out), + ) + check_status(status) + return KeyShareHandle(out.value) + finally: + wipe_ctypes_array(scalar_arr) diff --git a/libtss-python/tests/test_libtss.py b/libtss-python/tests/test_libtss.py index 7d5f1ba..0a00a1d 100644 --- a/libtss-python/tests/test_libtss.py +++ b/libtss-python/tests/test_libtss.py @@ -30,6 +30,7 @@ version, ) from libtss import frost +from libtss import tweak from libtss.derive import derive_child @@ -430,3 +431,106 @@ def test_roundtrip(self): assert decoded[1].sender == 2 assert decoded[1].to == 1 assert decoded[1].data == b"p2p data" + + +class TestTweak: + SCALAR_ONE = (1).to_bytes(32, "big") + SCALAR_SEVEN = (7).to_bytes(32, "big") + + def test_tweak_add_signs_under_tweaked_key(self): + shares, _ = run_dkg( + Ciphersuite.SECP256K1_ECDSA, 2, 2, + session_id=b"tweak-add-session", + ) + parent_group_key = shares[0].group_verifying_key + + tweaked = [tweak.tweak_add(ks, self.SCALAR_SEVEN) for ks in shares] + + tweaked_group_key = tweaked[0].group_verifying_key + assert tweaked[1].group_verifying_key == tweaked_group_key + assert tweaked_group_key != parent_group_key + for parent, child in zip(shares, tweaked): + assert child.identifier == parent.identifier + assert child.ciphersuite == Ciphersuite.SECP256K1_ECDSA + assert child.verifying_share != parent.verifying_share + # The parent handle stays usable after tweaking. + assert shares[0].group_verifying_key == parent_group_key + + raw_msg = b"tweaked dkls ecdsa test" + msg_hash = hashlib.sha256(raw_msg).digest() + sig = run_sign(tweaked, msg_hash, sign_id=b"tweak-sign-session-1") + assert len(sig) > 0 + # verify() expects the raw message (hashes internally) + assert verify(Ciphersuite.SECP256K1_ECDSA, raw_msg, sig, tweaked_group_key) + assert not verify(Ciphersuite.SECP256K1_ECDSA, raw_msg, sig, parent_group_key) + + for ks in shares + tweaked: + ks.close() + + def test_tweak_mul_by_one_is_identity(self): + shares, _ = run_dkg( + Ciphersuite.SECP256K1_ECDSA, 2, 2, + session_id=b"tweak-mul-session", + ) + + tweaked = [tweak.tweak_mul(ks, self.SCALAR_ONE) for ks in shares] + + for parent, child in zip(shares, tweaked): + assert child.raw != parent.raw + assert child.identifier == parent.identifier + assert child.group_verifying_key == parent.group_verifying_key + assert child.verifying_share == parent.verifying_share + + raw_msg = b"identity tweak dkls ecdsa test" + msg_hash = hashlib.sha256(raw_msg).digest() + sig = run_sign(tweaked, msg_hash, sign_id=b"tweak-sign-session-2") + assert verify(Ciphersuite.SECP256K1_ECDSA, raw_msg, sig, shares[0].group_verifying_key) + + for ks in shares + tweaked: + ks.close() + + def test_tweak_rejects_bad_length(self): + shares, _ = run_dkg( + Ciphersuite.SECP256K1_ECDSA, 2, 2, + session_id=b"tweak-len-session", + ) + + for bad_scalar in (b"", bytes(31), bytes(33)): + with pytest.raises(ValueError): + tweak.tweak_add(shares[0], bad_scalar) + with pytest.raises(ValueError): + tweak.tweak_mul(shares[0], bad_scalar) + + for ks in shares: + ks.close() + + def test_tweak_mul_zero_rejected(self): + shares, _ = run_dkg( + Ciphersuite.SECP256K1_ECDSA, 2, 2, + session_id=b"tweak-zero-session", + ) + + secp256k1_order = bytes.fromhex( + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141" + ) + for zero_factor in (bytes(32), secp256k1_order): + with pytest.raises(TssError) as excinfo: + tweak.tweak_mul(shares[0], zero_factor) + assert excinfo.value.code == 13 + + for ks in shares: + ks.close() + + def test_tweak_unsupported_suite(self): + handles, _ = frost.generate_dealer(Ciphersuite.ED25519, 3, 2) + + with pytest.raises(TssError) as excinfo: + tweak.tweak_add(handles[0], self.SCALAR_SEVEN) + assert excinfo.value.code == 8 + + with pytest.raises(TssError) as excinfo: + tweak.tweak_mul(handles[0], self.SCALAR_SEVEN) + assert excinfo.value.code == 8 + + for h in handles: + h.close() diff --git a/libtss-rn/android/src/main/cpp/libtss-jni.cpp b/libtss-rn/android/src/main/cpp/libtss-jni.cpp index f3be21a..f5ab4cc 100644 --- a/libtss-rn/android/src/main/cpp/libtss-jni.cpp +++ b/libtss-rn/android/src/main/cpp/libtss-jni.cpp @@ -92,6 +92,8 @@ TssStatus tss_refresh_next( bool* out_complete); void tss_session_free(TssHandle session); const char* tss_version(void); +TssStatus tss_tweak_add(TssHandle key_share, const uint8_t* tweak, size_t tweak_len, TssHandle* out_handle); +TssStatus tss_tweak_mul(TssHandle key_share, const uint8_t* factor, size_t factor_len, TssHandle* out_handle); } class UtfChars { @@ -904,3 +906,53 @@ Java_com_libtss_LibtssModule_nativeImportKeyShareRaw(JNIEnv* env, jclass, jint s return handle_to_jstring(env, out); } + +extern "C" JNIEXPORT jstring JNICALL +Java_com_libtss_LibtssModule_nativeTweakAdd(JNIEnv* env, jclass, jstring keyShareHandle, jstring tweak) { + TssHandle key_share = 0; + if (!parse_handle(env, keyShareHandle, &key_share)) { + return nullptr; + } + + std::vector tweak_bytes; + if (!decode_base64(env, tweak, &tweak_bytes)) { + return nullptr; + } + + TssHandle out = 0; + TssStatus status = tss_tweak_add( + key_share, + tweak_bytes.empty() ? nullptr : tweak_bytes.data(), + tweak_bytes.size(), + &out); + if (!ensure_status(env, status)) { + return nullptr; + } + + return handle_to_jstring(env, out); +} + +extern "C" JNIEXPORT jstring JNICALL +Java_com_libtss_LibtssModule_nativeTweakMul(JNIEnv* env, jclass, jstring keyShareHandle, jstring factor) { + TssHandle key_share = 0; + if (!parse_handle(env, keyShareHandle, &key_share)) { + return nullptr; + } + + std::vector factor_bytes; + if (!decode_base64(env, factor, &factor_bytes)) { + return nullptr; + } + + TssHandle out = 0; + TssStatus status = tss_tweak_mul( + key_share, + factor_bytes.empty() ? nullptr : factor_bytes.data(), + factor_bytes.size(), + &out); + if (!ensure_status(env, status)) { + return nullptr; + } + + return handle_to_jstring(env, out); +} 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 9c64ae3..bc97a70 100644 --- a/libtss-rn/android/src/main/java/com/libtss/LibtssModule.java +++ b/libtss-rn/android/src/main/java/com/libtss/LibtssModule.java @@ -333,4 +333,16 @@ public void verify(int suite, String message, String signature, String publicKey private static native boolean nativeVerify(int suite, String message, String signature, String publicKey); + @ReactMethod + public void tweakAdd(String keyShareHandle, String tweak, Promise promise) { + resolve(promise, () -> nativeTweakAdd(keyShareHandle, tweak)); + } + + @ReactMethod + public void tweakMul(String keyShareHandle, String factor, Promise promise) { + resolve(promise, () -> nativeTweakMul(keyShareHandle, factor)); + } + + private static native String nativeTweakAdd(String keyShareHandle, String tweak); + private static native String nativeTweakMul(String keyShareHandle, String factor); } diff --git a/libtss-rn/ios/Libtss.mm b/libtss-rn/ios/Libtss.mm index f841e37..12fedc7 100644 --- a/libtss-rn/ios/Libtss.mm +++ b/libtss-rn/ios/Libtss.mm @@ -95,6 +95,8 @@ bool tss_verify(uint8_t suite, struct TssSlice message, struct TssSlice signature, struct TssSlice public_key); +TssStatus tss_tweak_add(TssHandle key_share, const uint8_t *tweak, size_t tweak_len, TssHandle *out_handle); +TssStatus tss_tweak_mul(TssHandle key_share, const uint8_t *factor, size_t factor_len, TssHandle *out_handle); } NSString *LastErrorMessage() { @@ -820,4 +822,62 @@ @implementation Libtss resolve(HandleString(out)); } +RCT_EXPORT_METHOD(tweakAdd:(NSString *)keyShareHandle + tweak:(NSString *)tweak + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + TssHandle keyShare = 0; + if (!ParseHandle(keyShareHandle, &keyShare, reject)) { + return; + } + + NSData *tweakData = DecodeBase64(tweak, reject); + if (tweakData == nil) { + return; + } + + TssHandle out = 0; + TssStatus status = tss_tweak_add( + keyShare, + static_cast(tweakData.bytes), + static_cast(tweakData.length), + &out); + if (status != TSS_OK) { + RejectStatus(reject, status); + return; + } + + resolve(HandleString(out)); +} + +RCT_EXPORT_METHOD(tweakMul:(NSString *)keyShareHandle + factor:(NSString *)factor + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + TssHandle keyShare = 0; + if (!ParseHandle(keyShareHandle, &keyShare, reject)) { + return; + } + + NSData *factorData = DecodeBase64(factor, reject); + if (factorData == nil) { + return; + } + + TssHandle out = 0; + TssStatus status = tss_tweak_mul( + keyShare, + static_cast(factorData.bytes), + static_cast(factorData.length), + &out); + if (status != TSS_OK) { + RejectStatus(reject, status); + return; + } + + resolve(HandleString(out)); +} + @end diff --git a/libtss-rn/src/NativeLibtss.ts b/libtss-rn/src/NativeLibtss.ts index 80796cf..2066ff2 100644 --- a/libtss-rn/src/NativeLibtss.ts +++ b/libtss-rn/src/NativeLibtss.ts @@ -62,6 +62,8 @@ export interface NativeLibtssModule { initialize(options: { mlock: boolean }): Promise; exportKeyShareSecure(handle: string): Promise; importKeyShareSecure(suite: number, keychainId: string): Promise; + tweakAdd(keyShareHandle: string, tweak: string): Promise; + tweakMul(keyShareHandle: string, factor: string): Promise; } type ReactNativeModule = { diff --git a/libtss-rn/src/__tests__/__mocks__/NativeLibtss.ts b/libtss-rn/src/__tests__/__mocks__/NativeLibtss.ts index e9aee2e..2c2945e 100644 --- a/libtss-rn/src/__tests__/__mocks__/NativeLibtss.ts +++ b/libtss-rn/src/__tests__/__mocks__/NativeLibtss.ts @@ -51,6 +51,8 @@ const refreshNewQueue: Array<{ }> = []; const refreshReceiverQueue: string[] = []; const refreshNextQueue: SessionResult[] = []; +const tweakAddQueue: string[] = []; +const tweakMulQueue: string[] = []; let versionValue = "0.0.0-test"; function bytes(data: Uint8Array | number[]): Uint8Array { @@ -91,6 +93,14 @@ function nextQueued(queue: T[], name: string): T { return value; } +function registerTweakedHandle(parentHandle: string, handle: string): void { + const parent = handles.get(parentHandle); + if (!parent) { + throw new Error(`unknown handle ${parentHandle}`); + } + handles.set(handle, { ...parent }); +} + const NativeLibtss = { dkgNew: jest.fn(async () => { const entry = nextQueued(dkgNewQueue, "dkgNew"); @@ -213,6 +223,16 @@ const NativeLibtss = { return handle; }, ), + tweakAdd: jest.fn(async (keyShareHandle: string, _tweak: string) => { + const handle = nextQueued(tweakAddQueue, "tweakAdd"); + registerTweakedHandle(keyShareHandle, handle); + return handle; + }), + tweakMul: jest.fn(async (keyShareHandle: string, _factor: string) => { + const handle = nextQueued(tweakMulQueue, "tweakMul"); + registerTweakedHandle(keyShareHandle, handle); + return handle; + }), }; export const __mock = { @@ -268,6 +288,8 @@ export const __mock = { refreshNewQueue.length = 0; refreshReceiverQueue.length = 0; refreshNextQueue.length = 0; + tweakAddQueue.length = 0; + tweakMulQueue.length = 0; versionValue = "0.0.0-test"; for (const fn of Object.values(NativeLibtss)) { @@ -277,6 +299,12 @@ export const __mock = { setVersion(value: string): void { versionValue = value; }, + pushTweakAdd(handle: string): void { + tweakAddQueue.push(handle); + }, + pushTweakMul(handle: string): void { + tweakMulQueue.push(handle); + }, }; export default NativeLibtss; diff --git a/libtss-rn/src/__tests__/tweak.test.ts b/libtss-rn/src/__tests__/tweak.test.ts new file mode 100644 index 0000000..0adcea6 --- /dev/null +++ b/libtss-rn/src/__tests__/tweak.test.ts @@ -0,0 +1,130 @@ +import { TssError } from "../error"; +import { KeyShareHandle } from "../handle"; +import { tweakAdd, tweakMul } from "../index"; +import { Ciphersuite, TssStatusCode } from "../types"; +import NativeLibtss, { __mock } from "./__mocks__/NativeLibtss"; + +const SCALAR_LENGTH = 32; +const SHORT_SCALAR_LENGTH = 31; +const LONG_SCALAR_LENGTH = 33; +const TWEAK_FILL = 7; +const FACTOR_FILL = 9; +const PARENT_HANDLE = "700"; +const ADDED_HANDLE = "701"; +const MULTIPLIED_HANDLE = "702"; + +function scalar(fill: number, length = SCALAR_LENGTH): Uint8Array { + return new Uint8Array(length).fill(fill); +} + +function parent(): KeyShareHandle { + return new KeyShareHandle(PARENT_HANDLE); +} + +beforeEach(() => { + __mock.reset(); + __mock.registerHandle(PARENT_HANDLE, { + suite: Ciphersuite.Secp256k1ECDSA, + }); +}); + +describe("tweakAdd", () => { + it("passes the base64 scalar and wraps the returned handle", async () => { + __mock.pushTweakAdd(ADDED_HANDLE); + const tweak = scalar(TWEAK_FILL); + + const result = await tweakAdd(parent(), tweak); + + expect(NativeLibtss.tweakAdd).toHaveBeenCalledWith( + PARENT_HANDLE, + __mock.base64(tweak), + ); + expect(result).toBeInstanceOf(KeyShareHandle); + expect(result.handle).toBe(ADDED_HANDLE); + await expect(result.ciphersuite()).resolves.toBe( + Ciphersuite.Secp256k1ECDSA, + ); + }); + + it("rejects non-32-byte input before calling native", async () => { + for (const length of [SHORT_SCALAR_LENGTH, LONG_SCALAR_LENGTH, 0]) { + const error: unknown = await tweakAdd( + parent(), + scalar(TWEAK_FILL, length), + ).catch((caught) => caught); + + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(TssError); + expect((error as Error).message).toContain(`${SCALAR_LENGTH}`); + } + + expect(NativeLibtss.tweakAdd).not.toHaveBeenCalled(); + }); + + it("maps a native rejection to TssError", async () => { + NativeLibtss.tweakAdd.mockRejectedValueOnce({ + code: TssStatusCode.PROTO_MISMATCH, + message: "tweaks require a DKLs23 ECDSA suite", + }); + + const error: unknown = await tweakAdd(parent(), scalar(TWEAK_FILL)).catch( + (caught) => caught, + ); + + expect(error).toBeInstanceOf(TssError); + expect((error as TssError).code).toBe(TssStatusCode.PROTO_MISMATCH); + expect((error as TssError).message).toBe( + "tweaks require a DKLs23 ECDSA suite", + ); + }); +}); + +describe("tweakMul", () => { + it("passes the base64 scalar and wraps the returned handle", async () => { + __mock.pushTweakMul(MULTIPLIED_HANDLE); + const factor = scalar(FACTOR_FILL); + + const result = await tweakMul(parent(), factor); + + expect(NativeLibtss.tweakMul).toHaveBeenCalledWith( + PARENT_HANDLE, + __mock.base64(factor), + ); + expect(result).toBeInstanceOf(KeyShareHandle); + expect(result.handle).toBe(MULTIPLIED_HANDLE); + await expect(result.ciphersuite()).resolves.toBe( + Ciphersuite.Secp256k1ECDSA, + ); + }); + + it("rejects non-32-byte input before calling native", async () => { + for (const length of [SHORT_SCALAR_LENGTH, LONG_SCALAR_LENGTH, 0]) { + const error: unknown = await tweakMul( + parent(), + scalar(FACTOR_FILL, length), + ).catch((caught) => caught); + + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(TssError); + expect((error as Error).message).toContain(`${SCALAR_LENGTH}`); + } + + expect(NativeLibtss.tweakMul).not.toHaveBeenCalled(); + }); + + it("maps a native rejection to TssError", async () => { + NativeLibtss.tweakMul.mockRejectedValueOnce({ + code: TssStatusCode.TWEAK, + message: "factor reduces to zero", + }); + + const error: unknown = await tweakMul( + parent(), + scalar(FACTOR_FILL), + ).catch((caught) => caught); + + expect(error).toBeInstanceOf(TssError); + expect((error as TssError).code).toBe(TssStatusCode.TWEAK); + expect((error as TssError).message).toBe("factor reduces to zero"); + }); +}); diff --git a/libtss-rn/src/index.ts b/libtss-rn/src/index.ts index 75882ff..cf76a10 100644 --- a/libtss-rn/src/index.ts +++ b/libtss-rn/src/index.ts @@ -110,3 +110,65 @@ export async function verify( throw TssError.fromNative(error); } } + +const SCALAR_LENGTH = 32; + +function assertScalarLength(value: Uint8Array, name: string): void { + if (value.length !== SCALAR_LENGTH) { + throw new Error( + `${name} must be exactly ${SCALAR_LENGTH} bytes, got ${value.length}`, + ); + } +} + +/** + * Derives a new key share whose secret is `k + tweak (mod n)`. + * + * The group public key, every verifying share and the derivation data move by + * the same map, so signatures produced with the returned share verify under the + * tweaked group key (the LND per-commitment "single" tweak). `tweak` is a + * 32-byte big-endian scalar reduced mod n; a wrong length rejects with a plain + * `Error` before any native call. Only the DKLs23 ECDSA suites support tweaking + * (other suites reject with `TssStatusCode.PROTO_MISMATCH`), and a tweak that + * lands on the identity rejects with `TssStatusCode.TWEAK`. The parent share + * stays valid; the returned handle must be freed separately. + */ +export async function tweakAdd( + keyShare: KeyShareHandle, + tweak: Uint8Array, +): Promise { + assertScalarLength(tweak, "tweak"); + + try { + return new KeyShareHandle( + await NativeLibtss.tweakAdd(keyShare.handle, encodeBase64(tweak)), + ); + } catch (error) { + throw TssError.fromNative(error); + } +} + +/** + * Derives a new key share whose secret is `k * factor (mod n)`. + * + * The multiplicative counterpart of {@link tweakAdd} and the building block of + * the LND revocation ("double") tweak. `factor` is a 32-byte big-endian scalar + * reduced mod n; a wrong length rejects with a plain `Error` before any native + * call, and a factor that reduces to zero rejects with `TssStatusCode.TWEAK`. + * Only the DKLs23 ECDSA suites support tweaking. The parent share stays valid; + * the returned handle must be freed separately. + */ +export async function tweakMul( + keyShare: KeyShareHandle, + factor: Uint8Array, +): Promise { + assertScalarLength(factor, "factor"); + + try { + return new KeyShareHandle( + await NativeLibtss.tweakMul(keyShare.handle, encodeBase64(factor)), + ); + } catch (error) { + throw TssError.fromNative(error); + } +} diff --git a/libtss-wasm/README.md b/libtss-wasm/README.md index f12a1a8..5ee8078 100644 --- a/libtss-wasm/README.md +++ b/libtss-wasm/README.md @@ -217,6 +217,8 @@ console.log("Ed25519 signature (hex):", Buffer.from(sig1.signature).toString("he | `suiR1Address(ks)` | Sui secp256r1 address (suite 7 only) | | `deriveChild(ks, n)` | BIP-32 non-hardened child derivation | | `derivePath(ks, path)` | BIP-32 path derivation (e.g. `"m/44/60/0/0"`) | +| `tweakAdd(ks, tweak)` | Additive scalar tweak `k + t` (suites 6, 7 only) | +| `tweakMul(ks, factor)` | Multiplicative scalar tweak `k * a` (suites 6, 7 only) | | `messageCount(buf)` | Count TLV-framed messages in a buffer | | `messageAt(buf, i)` | Decode message at index `i` from a buffer | | `messageBuild(from, to, data)` | Build a single TLV-framed message | diff --git a/libtss-wasm/src/lib.rs b/libtss-wasm/src/lib.rs index 9d6d983..eb6e417 100644 --- a/libtss-wasm/src/lib.rs +++ b/libtss-wasm/src/lib.rs @@ -25,6 +25,7 @@ pub mod handle; pub mod message; pub mod secure; pub mod session; +pub mod tweak; // Re-export all public wasm-bindgen items so wasm-pack finds them. pub use address::{ @@ -39,6 +40,7 @@ pub use session::{ WasmDkgResult, WasmDkgSession, WasmRefreshResult, WasmRefreshSession, WasmSignResult, WasmSignSession, }; +pub use tweak::{tweak_add, tweak_mul}; use js_sys::Uint8Array; use libtss::Ciphersuite; diff --git a/libtss-wasm/src/tweak.rs b/libtss-wasm/src/tweak.rs new file mode 100644 index 0000000..91fa353 --- /dev/null +++ b/libtss-wasm/src/tweak.rs @@ -0,0 +1,60 @@ +use js_sys::Uint8Array; +use wasm_bindgen::prelude::*; + +use crate::error::map_err; +use crate::handle::WasmKeyShareHandle; + +/// Return a key share whose secret is `secret + tweak (mod n)`. +/// +/// `tweak` is a 32-byte big-endian scalar, reduced mod n — the convention of +/// btcec's `ModNScalar` and therefore of LND's per-commitment "single" tweak. +/// The public key, BIP-32 derivation data, address and every verifying share +/// of the public key package are moved by the same map, so a threshold set of +/// tweaked shares signs under the tweaked group key. Every party must apply +/// the same tweak before signing. A zero tweak is a no-op. +/// +/// This is a plain scalar tweak on DKLs23 ECDSA shares; it is distinct from +/// BIP-341 Taproot tweaking of FROST shares, which is not exposed here. +/// +/// Supported ciphersuites: +/// - 6 (`Secp256k1ECDSA`, DKLs23) +/// - 7 (`Secp256r1ECDSA`, DKLs23) +/// +/// Throws a `TssError` whose `code` is: +/// - `InvalidConfig` when `tweak` is not exactly 32 bytes +/// - `TweakError` when the tweaked public key would be the identity point +/// - `ProtocolMismatch` for any other ciphersuite +/// +/// Returns a new `WasmKeyShareHandle`. The parent handle is not consumed and +/// stays valid. +#[wasm_bindgen(js_name = "tweakAdd")] +pub fn tweak_add( + key_share: &WasmKeyShareHandle, + tweak: &Uint8Array, +) -> Result { + let tweaked = map_err(libtss::tweak_add(&key_share.inner, &tweak.to_vec()))?; + Ok(WasmKeyShareHandle { inner: tweaked }) +} + +/// Return a key share whose secret is `secret * factor (mod n)`. +/// +/// `factor` is a 32-byte big-endian scalar, reduced mod n. It is the building +/// block of LND's revocation ("double") tweak. A factor of one is a no-op. +/// Ciphersuite support, the moved public data and the distinction from +/// BIP-341 Taproot tweaking match `tweakAdd`. +/// +/// Throws a `TssError` whose `code` is: +/// - `InvalidConfig` when `factor` is not exactly 32 bytes +/// - `TweakError` when `factor` reduces to zero, which would destroy the key +/// - `ProtocolMismatch` for any other ciphersuite +/// +/// Returns a new `WasmKeyShareHandle`. The parent handle is not consumed and +/// stays valid. +#[wasm_bindgen(js_name = "tweakMul")] +pub fn tweak_mul( + key_share: &WasmKeyShareHandle, + factor: &Uint8Array, +) -> Result { + let tweaked = map_err(libtss::tweak_mul(&key_share.inner, &factor.to_vec()))?; + Ok(WasmKeyShareHandle { inner: tweaked }) +} diff --git a/libtss-wasm/tests/tweak.rs b/libtss-wasm/tests/tweak.rs new file mode 100644 index 0000000..2f3a6f0 --- /dev/null +++ b/libtss-wasm/tests/tweak.rs @@ -0,0 +1,196 @@ +//! WASM tests for the scalar tweak bindings (`tweakAdd` / `tweakMul`). +//! +//! Run with: +//! ```sh +//! wasm-pack test --node -- --features testing --test tweak +//! ``` + +use std::collections::BTreeMap; + +use libtss::session::dkg::{DkgOutput, DkgSession}; +use libtss::types::{Ciphersuite, Identifier, ThresholdConfig}; +use libtss::Message; +use libtss_wasm::{tweak_add, tweak_mul, WasmKeyShareHandle}; +use wasm_bindgen::JsValue; +use wasm_bindgen_test::*; + +const PARTY_ONE: u16 = 1; +const PARTY_TWO: u16 = 2; + +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_two_of_two_dkls_dkg() -> (WasmKeyShareHandle, WasmKeyShareHandle) { + let config = ThresholdConfig { + min_signers: 2, + max_signers: 2, + suite: Ciphersuite::Secp256k1ECDSA, + }; + let participants = [id(PARTY_ONE), id(PARTY_TWO)]; + let session_id = b"wasm-tweak-test-session"; + + 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); + } + + let mut inbound = route(&phase1, &participants); + for _ in 0..2 { + let mut outbound = BTreeMap::new(); + for &pid in &participants { + match sessions + .get_mut(&pid) + .unwrap() + .next(&inbound[&pid]) + .unwrap() + { + DkgOutput::Continue(msgs) => { + outbound.insert(pid, msgs); + } + DkgOutput::Complete { .. } => panic!("expected Continue"), + } + } + inbound = route(&outbound, &participants); + } + + let mut key_shares = BTreeMap::new(); + for &pid in &participants { + match sessions + .get_mut(&pid) + .unwrap() + .next(&inbound[&pid]) + .unwrap() + { + DkgOutput::Complete { key_share, .. } => { + key_shares.insert(pid, WasmKeyShareHandle::from(key_share)); + } + DkgOutput::Continue(_) => panic!("expected Complete"), + } + } + ( + key_shares.remove(&id(PARTY_ONE)).unwrap(), + key_shares.remove(&id(PARTY_TWO)).unwrap(), + ) +} + +fn ed25519_handle() -> WasmKeyShareHandle { + let config = ThresholdConfig { + min_signers: 2, + max_signers: 3, + suite: Ciphersuite::Ed25519, + }; + let (shares, pubkey) = libtss::frost::frost_generate_with_dealer(&config).unwrap(); + let share = shares.into_iter().next().unwrap(); + libtss::keyshare::from_frost_dkg(share, &pubkey) + .unwrap() + .into() +} + +fn scalar_bytes(value: u64) -> js_sys::Uint8Array { + let mut bytes = [0u8; 32]; + bytes[24..].copy_from_slice(&value.to_be_bytes()); + js_sys::Uint8Array::from(&bytes[..]) +} + +fn error_code(result: Result) -> String { + let err = result.err().expect("tweak must be rejected"); + js_sys::Reflect::get(&err, &JsValue::from_str("code")) + .unwrap() + .as_string() + .unwrap() +} + +#[wasm_bindgen_test] +fn tweak_add_moves_both_parties_to_the_same_new_group_key() { + let (party_one, party_two) = run_two_of_two_dkls_dkg(); + let parent_group_key = party_one.group_verifying_key().to_vec(); + let tweak = scalar_bytes(7); + + let tweaked_one = tweak_add(&party_one, &tweak).unwrap(); + let tweaked_two = tweak_add(&party_two, &tweak).unwrap(); + + assert_eq!( + tweaked_one.group_verifying_key().to_vec(), + tweaked_two.group_verifying_key().to_vec() + ); + assert_ne!(tweaked_one.group_verifying_key().to_vec(), parent_group_key); + assert_eq!(party_one.group_verifying_key().to_vec(), parent_group_key); + assert_eq!(tweaked_one.identifier(), party_one.identifier()); + assert_eq!(tweaked_one.ciphersuite(), party_one.ciphersuite()); +} + +#[wasm_bindgen_test] +fn tweak_mul_by_one_keeps_group_key() { + let (party_one, _) = run_two_of_two_dkls_dkg(); + + let tweaked = tweak_mul(&party_one, &scalar_bytes(1)).unwrap(); + + assert_eq!( + tweaked.group_verifying_key().to_vec(), + party_one.group_verifying_key().to_vec() + ); + assert_eq!( + tweaked.verifying_share().to_vec(), + party_one.verifying_share().to_vec() + ); +} + +#[wasm_bindgen_test] +fn tweak_mul_zero_factor_throws_tweak_error() { + let (party_one, _) = run_two_of_two_dkls_dkg(); + + assert_eq!( + error_code(tweak_mul(&party_one, &scalar_bytes(0))), + "TweakError" + ); +} + +#[wasm_bindgen_test] +fn tweak_rejects_scalar_of_wrong_length() { + let (party_one, _) = run_two_of_two_dkls_dkg(); + let short = js_sys::Uint8Array::from(&[1u8; 31][..]); + + assert_eq!(error_code(tweak_add(&party_one, &short)), "InvalidConfig"); + assert_eq!(error_code(tweak_mul(&party_one, &short)), "InvalidConfig"); +} + +#[wasm_bindgen_test] +fn tweak_rejects_frost_key_share() { + let handle = ed25519_handle(); + + assert_eq!( + error_code(tweak_add(&handle, &scalar_bytes(7))), + "ProtocolMismatch" + ); + assert_eq!( + error_code(tweak_mul(&handle, &scalar_bytes(3))), + "ProtocolMismatch" + ); +} diff --git a/libtss/src/lib.rs b/libtss/src/lib.rs index f0e4290..9eb0fe0 100644 --- a/libtss/src/lib.rs +++ b/libtss/src/lib.rs @@ -10,6 +10,7 @@ pub mod keyshare; pub mod message; pub mod repair; pub mod session; +pub mod tweak; pub mod types; pub mod verify; @@ -29,6 +30,7 @@ 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}; +pub use tweak::{tweak_add, tweak_mul}; pub use verify::verify; pub use types::{Ciphersuite, Identifier, Protocol, PublicKeyPackage, Signature, ThresholdConfig}; diff --git a/libtss/src/tweak.rs b/libtss/src/tweak.rs new file mode 100644 index 0000000..7b255f3 --- /dev/null +++ b/libtss/src/tweak.rs @@ -0,0 +1,301 @@ +//! Scalar tweaks on DKLs23 key shares. +//! +//! BIP-32 [`derive_child`](crate::derive_child) applies an HMAC-derived +//! additive tweak. Lightning (LND) instead derives per-commitment keys from +//! caller-supplied scalars: +//! +//! - single tweak (additive): `k' = k + t (mod n)` +//! - revocation / double tweak: `k' = k * a (mod n)`, then an additive step +//! +//! The math lives in DKLs23 (`Party::tweak_add` / `tweak_mul` and the matching +//! `PublicKeyPackage` methods): the secret share, public key, derivation data +//! and address move with the map, as do the group key and every verifying +//! share, so a threshold set of tweaked shares signs under the tweaked group +//! key. OT correlations, zero-share seeds, multiplication state and the chain +//! code are reused unchanged. This module parses the scalar, picks the +//! address function for the ciphersuite and wraps the result in a new handle. +//! Every party must apply the same tweak before signing; a mismatch aborts +//! recoverably in signing phase 3. +//! +//! For BIP-341 Taproot tweaking of FROST shares see +//! [`frost_tweak_key_share`](crate::frost::frost_tweak_key_share). + +use dkls23_secp256k1::curve::DklsCurve; +use dkls23_secp256k1::protocols::tweak::{scalar_from_be_bytes, TweakError}; +use dkls23_secp256k1::protocols::{Party, PublicKeyPackage}; + +use crate::keyshare::{self, KeyShareHandle}; +use crate::types::Ciphersuite; +use crate::TssError; + +/// Return a key share whose secret is `secret + tweak (mod n)`. +/// +/// `tweak` is a 32-byte big-endian scalar, reduced mod n (matching btcec's +/// `ModNScalar` and therefore LND's single-tweak convention). A zero tweak is +/// a no-op. Supported for `Secp256k1ECDSA` and `Secp256r1ECDSA` (DKLs23); +/// other ciphersuites return [`TssError::ProtocolMismatch`]. A tweak whose +/// result is the identity point returns [`TssError::TweakError`]; a scalar of +/// the wrong length returns [`TssError::InvalidConfig`]. The original key +/// share remains valid. +pub fn tweak_add(key_share: &KeyShareHandle, tweak: &[u8]) -> Result { + apply(key_share, tweak, Tweak::Add) +} + +/// Return a key share whose secret is `secret * factor (mod n)`. +/// +/// `factor` is a 32-byte big-endian scalar, reduced mod n. It is the building +/// block of LND's revocation ("double") tweak. A factor that reduces to zero +/// would destroy the key and returns [`TssError::TweakError`]; a factor of one +/// is a no-op. Ciphersuite support and error mapping match [`tweak_add`]. +pub fn tweak_mul(key_share: &KeyShareHandle, factor: &[u8]) -> Result { + apply(key_share, factor, Tweak::Mul) +} + +#[derive(Clone, Copy)] +enum Tweak { + Add, + Mul, +} + +fn apply( + key_share: &KeyShareHandle, + scalar: &[u8], + tweak: Tweak, +) -> Result { + match key_share.ciphersuite() { + Ciphersuite::Secp256k1ECDSA => { + let scalar = scalar_from_be_bytes::(scalar).map_err(tss_error)?; + let (party, pubkey) = tweak_party( + &keyshare::clone_dkls_party(key_share)?, + &keyshare::clone_dkls_pubkey(key_share)?, + tweak, + &scalar, + dkls23_secp256k1::compute_eth_address, + )?; + keyshare::from_dkls_dkg(party, pubkey) + } + Ciphersuite::Secp256r1ECDSA => { + let scalar = scalar_from_be_bytes::(scalar).map_err(tss_error)?; + let (party, pubkey) = tweak_party( + &keyshare::clone_dkls_r1_party(key_share)?, + &keyshare::clone_dkls_r1_pubkey(key_share)?, + tweak, + &scalar, + dkls23_secp256r1::compute_neo3_address, + )?; + keyshare::from_dkls_r1_dkg(party, pubkey) + } + _ => Err(TssError::ProtocolMismatch), + } +} + +fn tweak_party( + party: &Party, + pubkey: &PublicKeyPackage, + tweak: Tweak, + scalar: &C::Scalar, + address_fn: impl Fn(&C::AffinePoint) -> String, +) -> Result<(Party, PublicKeyPackage), TssError> { + match tweak { + Tweak::Add => Ok(( + party.tweak_add(scalar, address_fn).map_err(tss_error)?, + pubkey.tweak_add(scalar).map_err(tss_error)?, + )), + Tweak::Mul => Ok(( + party.tweak_mul(scalar, address_fn).map_err(tss_error)?, + pubkey.tweak_mul(scalar).map_err(tss_error)?, + )), + } +} + +fn tss_error(err: TweakError) -> TssError { + match err { + TweakError::InvalidScalarLength { .. } => TssError::InvalidConfig(err.to_string()), + _ => TssError::TweakError(err.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::address::{ethereum_address, neo3_address}; + use crate::Identifier; + use dkls23_secp256k1::protocols::re_key::re_key; + use dkls23_secp256k1::protocols::Parameters; + use k256::elliptic_curve::sec1::ToSec1Point; + use k256::{ProjectivePoint, PublicKey, Scalar}; + + const SECRET: u64 = 15; + const SHARE_COUNT: u16 = 3; + + fn dkls_handle() -> KeyShareHandle { + let parameters = Parameters::new(2, SHARE_COUNT as u8).unwrap(); + let (parties, pubkeys) = re_key( + ¶meters, + b"session", + &Scalar::from(SECRET), + Some([3u8; 32]), + dkls23_secp256k1::compute_eth_address, + ); + + keyshare::from_dkls_dkg(parties[0].clone(), pubkeys).unwrap() + } + + fn dkls_r1_handle() -> KeyShareHandle { + let parameters = Parameters::new(2, SHARE_COUNT as u8).unwrap(); + let (parties, pubkeys) = dkls23_secp256r1::protocols::re_key::re_key( + ¶meters, + b"session", + &p256::Scalar::from(SECRET), + Some([3u8; 32]), + dkls23_secp256r1::compute_neo3_address, + ); + + keyshare::from_dkls_r1_dkg(parties[0].clone(), pubkeys).unwrap() + } + + fn scalar_bytes(value: u64) -> [u8; 32] { + let mut bytes = [0u8; 32]; + bytes[24..].copy_from_slice(&value.to_be_bytes()); + bytes + } + + fn k1_point(compressed: &[u8]) -> ProjectivePoint { + PublicKey::from_sec1_bytes(compressed) + .unwrap() + .to_projective() + } + + fn compressed(point: ProjectivePoint) -> Vec { + point.to_affine().to_sec1_point(true).as_bytes().to_vec() + } + + #[test] + fn tweak_add_k1_shifts_group_key_and_every_verifying_share() { + let handle = dkls_handle(); + let tweaked = tweak_add(&handle, &scalar_bytes(7)).unwrap(); + let shift = ProjectivePoint::GENERATOR * Scalar::from(7u64); + + assert_eq!(tweaked.ciphersuite(), Ciphersuite::Secp256k1ECDSA); + assert_eq!(tweaked.identifier(), handle.identifier()); + assert_eq!( + tweaked.group_verifying_key(), + compressed(k1_point(&handle.group_verifying_key()) + shift) + ); + assert_eq!( + tweaked.verifying_share(), + compressed(k1_point(&handle.verifying_share()) + shift) + ); + for index in 1..=SHARE_COUNT { + let id = Identifier::new(index).unwrap(); + let before = handle.public_key_package().verifying_share(id).unwrap(); + let after = tweaked.public_key_package().verifying_share(id).unwrap(); + assert_eq!(after.to_vec(), compressed(k1_point(before) + shift)); + } + } + + #[test] + fn tweak_add_updates_ethereum_address() { + let handle = dkls_handle(); + let tweaked = tweak_add(&handle, &scalar_bytes(7)).unwrap(); + let party = keyshare::clone_dkls_party(&tweaked).unwrap(); + + assert_ne!( + ethereum_address(&tweaked).unwrap(), + ethereum_address(&handle).unwrap() + ); + assert_eq!( + ethereum_address(&tweaked).unwrap(), + dkls23_secp256k1::compute_eth_address(&party.pk) + ); + assert_eq!( + party.pk.to_sec1_point(true).as_bytes(), + tweaked.group_verifying_key().as_slice() + ); + } + + #[test] + fn tweak_add_r1_matches_expected_public_key() { + use p256::elliptic_curve::sec1::ToSec1Point; + + let handle = dkls_r1_handle(); + let tweaked = tweak_add(&handle, &scalar_bytes(7)).unwrap(); + let expected = (p256::PublicKey::from_sec1_bytes(&handle.group_verifying_key()) + .unwrap() + .to_projective() + + p256::ProjectivePoint::GENERATOR * p256::Scalar::from(7u64)) + .to_affine(); + + assert_eq!(tweaked.ciphersuite(), Ciphersuite::Secp256r1ECDSA); + assert_eq!( + tweaked.group_verifying_key(), + expected.to_sec1_point(true).as_bytes() + ); + assert_eq!( + neo3_address(&tweaked).unwrap(), + dkls23_secp256r1::compute_neo3_address(&expected) + ); + } + + #[test] + fn tweak_mul_zero_factor_rejected() { + let handle = dkls_handle(); + assert!(matches!( + tweak_mul(&handle, &scalar_bytes(0)), + Err(TssError::TweakError(_)) + )); + assert!(matches!( + tweak_mul(&dkls_r1_handle(), &scalar_bytes(0)), + Err(TssError::TweakError(_)) + )); + } + + #[test] + fn tweak_add_identity_result_rejected() { + let handle = dkls_handle(); + assert!(matches!( + tweak_add(&handle, &(-Scalar::from(SECRET)).to_bytes()), + Err(TssError::TweakError(_)) + )); + } + + #[test] + fn tweak_rejects_wrong_length_scalar() { + let handle = dkls_handle(); + assert!(matches!( + tweak_add(&handle, &[1u8; 31]), + Err(TssError::InvalidConfig(_)) + )); + assert!(matches!( + tweak_mul(&handle, &[1u8; 33]), + Err(TssError::InvalidConfig(_)) + )); + assert!(matches!( + tweak_add(&dkls_r1_handle(), &[]), + Err(TssError::InvalidConfig(_)) + )); + } + + #[test] + fn tweak_unsupported_suite_rejected() { + for suite in [Ciphersuite::Secp256k1Taproot, Ciphersuite::Ed25519] { + let config = crate::types::ThresholdConfig { + min_signers: 2, + max_signers: 3, + suite, + }; + let (shares, pubkey) = crate::frost::frost_generate_with_dealer(&config).unwrap(); + let handle = + keyshare::from_frost_dkg(shares.into_iter().next().unwrap(), &pubkey).unwrap(); + + assert!(matches!( + tweak_add(&handle, &scalar_bytes(7)), + Err(TssError::ProtocolMismatch) + )); + assert!(matches!( + tweak_mul(&handle, &scalar_bytes(3)), + Err(TssError::ProtocolMismatch) + )); + } + } +} diff --git a/libtss/tests/protocol_tests.rs b/libtss/tests/protocol_tests.rs index c484a01..91d2dec 100644 --- a/libtss/tests/protocol_tests.rs +++ b/libtss/tests/protocol_tests.rs @@ -10,6 +10,7 @@ use libtss::session::dkg::{DkgOutput, DkgSession}; use libtss::session::sign::{SignOutput, SignSession}; use libtss::types::{Ciphersuite, Identifier, Protocol, ThresholdConfig}; use libtss::{Message, TssError}; +use sha2::Digest; // --------------------------------------------------------------------------- // Test harness: message routing @@ -255,6 +256,60 @@ fn run_dkls_sign( } } +fn scalar_bytes(value: u64) -> [u8; 32] { + let mut bytes = [0u8; 32]; + bytes[24..].copy_from_slice(&value.to_be_bytes()); + bytes +} + +fn tweak_all( + shares: &BTreeMap, + scalar: &[u8], + add: bool, +) -> BTreeMap { + shares + .iter() + .map(|(pid, ks)| { + let tweaked = if add { + libtss::tweak_add(ks, scalar) + } else { + libtss::tweak_mul(ks, scalar) + }; + (*pid, tweaked.unwrap()) + }) + .collect() +} + +/// Sign a fixed message with the given DKLs23 shares and verify the signature +/// under `group_key` with the standard (hash-then-verify) ECDSA verifier. +fn dkls_signs_under( + shares: &BTreeMap, + signer_ids: &[Identifier], + group_key: &[u8], +) -> bool { + let message = b"threshold ecdsa under a scalar tweak"; + let msg_hash: [u8; 32] = sha2::Sha256::digest(message).into(); + let sig = run_dkls_sign(shares, signer_ids, msg_hash).unwrap(); + libtss::verify( + Ciphersuite::Secp256k1ECDSA, + message, + sig.as_bytes(), + group_key, + ) + .unwrap() +} + +fn k1_point(compressed: &[u8]) -> k256::ProjectivePoint { + k256::PublicKey::from_sec1_bytes(compressed) + .unwrap() + .to_projective() +} + +fn compressed(point: k256::ProjectivePoint) -> Vec { + use k256::elliptic_curve::sec1::ToSec1Point; + point.to_affine().to_sec1_point(true).as_bytes().to_vec() +} + // =========================================================================== // Protocol Correctness Tests // =========================================================================== @@ -1081,3 +1136,97 @@ fn test_unified_verify_dkls_secp256k1ecdsa() { .unwrap(); assert!(!verifies_wrong); } + +// ---------- DKLs23 scalar tweaks ---------- + +#[test] +fn dkls_tweak_add_shares_sign_under_tweaked_key() { + let shares = run_dkls_dkg_session(2, 2).unwrap(); + let tweaked = tweak_all(&shares, &scalar_bytes(7), true); + + let group_key = tweaked[&id(1)].group_verifying_key(); + assert_eq!(group_key, tweaked[&id(2)].group_verifying_key()); + assert_eq!( + group_key, + compressed( + k1_point(&shares[&id(1)].group_verifying_key()) + + k256::ProjectivePoint::GENERATOR * k256::Scalar::from(7u64) + ) + ); + + assert!(dkls_signs_under(&tweaked, &[id(1), id(2)], &group_key)); + assert!( + dkls_signs_under( + &shares, + &[id(1), id(2)], + &shares[&id(1)].group_verifying_key() + ), + "parent shares must stay valid after tweaking" + ); +} + +#[test] +fn dkls_tweak_mul_shares_sign_under_scaled_key() { + let shares = run_dkls_dkg_session(2, 3).unwrap(); + let tweaked = tweak_all(&shares, &scalar_bytes(3), false); + + let group_key = tweaked[&id(1)].group_verifying_key(); + assert!(tweaked + .values() + .all(|ks| ks.group_verifying_key() == group_key)); + assert_eq!( + group_key, + compressed(k1_point(&shares[&id(1)].group_verifying_key()) * k256::Scalar::from(3u64)) + ); + + assert!(dkls_signs_under(&tweaked, &[id(2), id(3)], &group_key)); +} + +#[test] +fn dkls_double_tweak_shares_sign_under_composed_key() { + let shares = run_dkls_dkg_session(2, 2).unwrap(); + let scaled = tweak_all(&shares, &scalar_bytes(3), false); + let tweaked = tweak_all(&scaled, &scalar_bytes(7), true); + + let group_key = tweaked[&id(1)].group_verifying_key(); + assert_eq!( + group_key, + compressed( + k1_point(&shares[&id(1)].group_verifying_key()) * k256::Scalar::from(3u64) + + k256::ProjectivePoint::GENERATOR * k256::Scalar::from(7u64) + ) + ); + + assert!(dkls_signs_under(&tweaked, &[id(1), id(2)], &group_key)); +} + +#[test] +fn dkls_tweak_then_derive_child_agrees_across_parties_and_signs() { + let shares = run_dkls_dkg_session(2, 3).unwrap(); + let tweaked = tweak_all(&shares, &scalar_bytes(7), true); + let children: BTreeMap<_, _> = tweaked + .iter() + .map(|(pid, ks)| (*pid, libtss::derive_child(ks, 4).unwrap())) + .collect(); + + let child_key = children[&id(1)].group_verifying_key(); + assert!(children + .values() + .all(|ks| ks.group_verifying_key() == child_key)); + assert_ne!(child_key, tweaked[&id(1)].group_verifying_key()); + + assert!(dkls_signs_under(&children, &[id(1), id(3)], &child_key)); +} + +#[test] +fn dkls_mixed_tweaked_and_untweaked_shares_cannot_sign() { + let mut shares = run_dkls_dkg_session(2, 2).unwrap(); + let mut tweaked = tweak_all(&shares, &scalar_bytes(7), true); + + let mut mixed = BTreeMap::new(); + mixed.insert(id(1), tweaked.remove(&id(1)).unwrap()); + mixed.insert(id(2), shares.remove(&id(2)).unwrap()); + let msg_hash = [0xABu8; 32]; + + assert!(run_dkls_sign(&mixed, &[id(1), id(2)], msg_hash).is_err()); +} diff --git a/spec/03-api-design.md b/spec/03-api-design.md index 27bad2e..949b313 100644 --- a/spec/03-api-design.md +++ b/spec/03-api-design.md @@ -14,7 +14,7 @@ libtss/ │ │ ├── dkls.rs # DKLs23 protocol wrappers (advanced API) │ │ ├── types.rs # Shared types (Identifier, Signature, Config) │ │ ├── derive.rs # BIP-32 key derivation -│ │ ├── tweak.rs # BIP-341 Taproot tweaking +│ │ ├── tweak.rs # Scalar tweaks on DKLs23 shares (Lightning per-commitment keys) │ │ ├── handle.rs # Handle registry │ │ └── error.rs # Unified TssError │ └── Cargo.toml @@ -172,7 +172,7 @@ pub enum TssError { ProtocolMismatch, /// Deserialization of protocol data failed. DeserializeFailed(String), - /// BIP-341 tweaking error. + /// Tweak error (BIP-341 Taproot tweak or DKLs23 scalar tweak). TweakError(String), /// Session has already completed. SessionComplete, @@ -547,6 +547,27 @@ pub fn derive_path( ) -> Result; ``` +### Scalar Tweaks (DKLs23) + +```rust +/// Key share whose secret is `secret + tweak (mod n)`; `tweak` is a 32-byte +/// big-endian scalar reduced mod n (LND's per-commitment "single tweak"). +pub fn tweak_add(key_share: &KeyShareHandle, tweak: &[u8]) -> Result; + +/// Key share whose secret is `secret * factor (mod n)`; building block of LND's +/// revocation ("double") tweak. A factor reducing to zero is rejected. +pub fn tweak_mul(key_share: &KeyShareHandle, factor: &[u8]) -> Result; +``` + +Both return a new handle (the original stays valid) whose secret share, public +key, derivation data, address and every verifying share in the public key +package are moved by the same map, so a threshold set of tweaked shares signs +under the tweaked group key. Supported for `Secp256k1ECDSA` and +`Secp256r1ECDSA`; other suites return `ProtocolMismatch`. Wrong-length input +returns `InvalidConfig`; a zero factor or an identity result returns +`TweakError`. Distinct from the FROST BIP-341 tweak below, which hashes a +Merkle root into the tweak rather than taking a caller-supplied scalar. + ## FROST-Specific Operations These operations exist only for FROST and are not part of the unified session API. diff --git a/spec/04-frost-integration.md b/spec/04-frost-integration.md index d088df1..0a55f30 100644 --- a/spec/04-frost-integration.md +++ b/spec/04-frost-integration.md @@ -282,6 +282,8 @@ TssStatus tss_frost_split_key( The `frost-secp256k1-tr` crate provides a `Tweak` trait that applies BIP-341 tweaks to key shares and public key packages. This enables both keypath and scriptpath spends. +(Caller-supplied scalar tweaks on DKLs23 ECDSA shares — Lightning per-commitment +keys — are a separate operation; see [07-key-management.md](07-key-management.md#scalar-tweaks-lightning).) ### Tweak Computation diff --git a/spec/06-ffi-layer.md b/spec/06-ffi-layer.md index fa3eb89..2d326e0 100644 --- a/spec/06-ffi-layer.md +++ b/spec/06-ffi-layer.md @@ -485,6 +485,11 @@ bool tss_verify(uint8_t suite, TssSlice message, TssSlice signature, TssSlice pu TssStatus tss_derive_child(TssHandle key_share, uint32_t child_number, TssHandle *out); TssStatus tss_derive_path(TssHandle key_share, const char *path, TssHandle *out); +/// Scalar tweaks on DKLs23 shares: k' = k + tweak, k' = k * factor (mod n). +/// 32-byte big-endian scalars reduced mod n; DKLs23 suites only. +TssStatus tss_tweak_add(TssHandle key_share, const uint8_t *tweak, size_t tweak_len, TssHandle *out_handle); +TssStatus tss_tweak_mul(TssHandle key_share, const uint8_t *factor, size_t factor_len, TssHandle *out_handle); + // --- Export / Import --- TssStatus tss_handle_export(TssHandle handle, TssBuffer *out); TssStatus tss_handle_import(const uint8_t *data, size_t data_len, uint8_t suite, TssHandle *out_handle); @@ -504,10 +509,10 @@ const char* tss_version(void); | Message helpers | 3 | count, at, build | | FROST-specific | 9 | aggregate, tweak (2), dealer (2), refresh (2), repair (3) | | DKLs23-specific | 2 | repair (helper_term, assemble) | -| Verification + key mgmt | 5 | verify, derive (2), export, import | +| Verification + key mgmt | 7 | verify, derive (2), tweak (2), export, import | | 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** | **~41** | **Core unified path: 11 functions (sessions + messages)** | +| **Total** | **~43** | **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 4dbb0a2..b482386 100644 --- a/spec/07-key-management.md +++ b/spec/07-key-management.md @@ -174,6 +174,49 @@ related by the same additive tweak. This is expected and does not affect securit Recommendation: derive keys as needed, refresh the master (root) shares periodically. +## Scalar Tweaks (Lightning) + +Lightning derives per-commitment keys from caller-supplied scalars rather than +BIP-32 indices: LND's single tweak is `k' = k + t (mod n)` and its revocation +("double") tweak composes `k' = k * a (mod n)` with an additive step. libtss +exposes both on DKLs23 shares: + +```rust +pub fn tweak_add(key_share: &KeyShareHandle, tweak: &[u8]) -> Result; +pub fn tweak_mul(key_share: &KeyShareHandle, factor: &[u8]) -> Result; +``` + +```c +TssStatus tss_tweak_add(TssHandle key_share, const uint8_t *tweak, size_t tweak_len, TssHandle *out_handle); +TssStatus tss_tweak_mul(TssHandle key_share, const uint8_t *factor, size_t factor_len, TssHandle *out_handle); +``` + +Every party applies the same tweak to its share and the public key package is +moved by the same map (group key and every verifying share). Because Lagrange +coefficients sum to one, `Σ lᵢ·(xᵢ + t) = sk + t` and `Σ lᵢ·(a·xᵢ) = a·sk`, so +the tweaked shares sign under the tweaked group key. The DKLs23 base-OT +correlations, zero-share seeds and multiplication state never depend on the +share value — the same property BIP-32 derivation relies on — and are reused +unchanged. The map itself is DKLs23's (`Party::tweak_add` / `tweak_mul` and the +matching `PublicKeyPackage` methods); libtss parses the scalar, picks the +address function and wraps the result in a new handle. + +| Field | After `tweak_add(t)` | After `tweak_mul(a)` | +|-------|----------------------|----------------------| +| `poly_point`, `derivation_data.poly_point` | `x + t` | `a·x` | +| `pk`, `derivation_data.pk`, group key, verifying shares | `P + t·G` | `a·P` | +| `address` | recomputed | recomputed | +| `chain_code`, `depth`, `child_number`, `parent_fingerprint`, `session_id`, OT state | unchanged | unchanged | + +Rules: scalars are 32-byte big-endian and reduced mod n (btcec `ModNScalar` +semantics, so LND tweaks round-trip); a factor that reduces to zero or a result +equal to the identity point is rejected with `TweakError`; wrong-length input is +`InvalidConfig`; non-DKLs23 suites return `ProtocolMismatch`. All signers must +apply the same public tweak before a signing session — a mismatch aborts +recoverably in signing phase 3. A tweak is not a BIP-32 edge: the derivation +metadata still describes the un-tweaked ancestor, and `derive_child` on a +tweaked share is a further additive shift under the unchanged chain code. + ## Share Refresh Share refresh replaces all participants' shares with new shares of the same secret key. diff --git a/spec/10-testing.md b/spec/10-testing.md index 41ec53d..39d6887 100644 --- a/spec/10-testing.md +++ b/spec/10-testing.md @@ -75,6 +75,9 @@ cd libtss-ffi && cargo test --test integration | `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` | +| `TestTweakAdd_SignsUnderTweakedKey` | Additively tweaked DKLs23 shares sign; signature verifies under `pk + t·G` only | +| `TestTweakMul_ByOneIsIdentity` | Multiplicative tweak by one leaves the group key unchanged; zero factor rejected | +| `TestTweak_UnsupportedSuite` | `tweak_add` / `tweak_mul` on a non-DKLs23 suite return `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 8170282a69990eabce6dd33b276a02975934beb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=CC=81=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:36:53 +0200 Subject: [PATCH 6/6] Build against the DKLs23 scalar tweaks branch The tweak commit calls Party::tweak_add and friends, which only exist on the DKLs23 pull request that adds them, so this branch can't build against DKLs23 main yet. Points the two dkls23 crates at that pull request's branch on our fork, and moves sha3 to rc.9, which the keccak version DKLs23 now pulls in needs. Revert this commit once the DKLs23 change is on main. --- Cargo.lock | 27 ++++++++++++++------------- Cargo.toml | 4 ++-- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2515f7f..fa8f81f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -620,8 +620,8 @@ dependencies = [ [[package]] name = "dkls23-core" -version = "0.4.1" -source = "git+https://github.com/0xCarbon/DKLs23?branch=main#d4af63103085f6c1341890b75500825f535ecd6c" +version = "0.5.1" +source = "git+https://github.com/Lunar-Rails/DKLs23?branch=upstream%2Fscalar-tweaks#4a4c171137e8874984b7cac769c1eb205533a63b" dependencies = [ "bincode", "elliptic-curve 0.14.0-rc.29", @@ -642,8 +642,8 @@ dependencies = [ [[package]] name = "dkls23-secp256k1" -version = "0.4.2" -source = "git+https://github.com/0xCarbon/DKLs23?branch=main#d4af63103085f6c1341890b75500825f535ecd6c" +version = "0.5.1" +source = "git+https://github.com/Lunar-Rails/DKLs23?branch=upstream%2Fscalar-tweaks#4a4c171137e8874984b7cac769c1eb205533a63b" dependencies = [ "bech32", "bs58", @@ -651,17 +651,17 @@ dependencies = [ "elliptic-curve 0.14.0-rc.29", "hex", "k256 0.14.0-rc.8", - "keccak 0.2.0-rc.2", + "keccak 0.2.2", "ripemd", "rustcrypto-group", "sha2 0.11.0-rc.5", - "sha3 0.11.0-rc.8", + "sha3 0.11.0-rc.9", ] [[package]] name = "dkls23-secp256r1" -version = "0.4.1" -source = "git+https://github.com/0xCarbon/DKLs23?branch=main#d4af63103085f6c1341890b75500825f535ecd6c" +version = "0.5.1" +source = "git+https://github.com/Lunar-Rails/DKLs23?branch=upstream%2Fscalar-tweaks#4a4c171137e8874984b7cac769c1eb205533a63b" dependencies = [ "blake2", "bs58", @@ -1206,10 +1206,11 @@ dependencies = [ [[package]] name = "keccak" -version = "0.2.0-rc.2" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "882b69cb15b1f78b51342322a97ccd16f5123d1dc8a3da981a95244f488e8692" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" dependencies = [ + "cfg-if", "cpufeatures 0.3.0", ] @@ -1890,12 +1891,12 @@ dependencies = [ [[package]] name = "sha3" -version = "0.11.0-rc.8" +version = "0.11.0-rc.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95f78cd62cc39ece5aefbeb6caaa2ea44f70b4815d4b85f7e150ac685ada2bb5" +checksum = "0b233a7d59d7bfc027208506a33ffc9532b2acb24ddc61fe7e758dc2250db431" dependencies = [ "digest 0.11.1", - "keccak 0.2.0-rc.2", + "keccak 0.2.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index bb8fa42..36c21f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,8 +17,8 @@ frost-ed25519 = { git = "https://github.com/ZcashFoundation/frost", branch = "ma frost-p256 = { git = "https://github.com/ZcashFoundation/frost", branch = "main" } frost-ristretto255 = { git = "https://github.com/ZcashFoundation/frost", branch = "main" } frost-ed448 = { git = "https://github.com/ZcashFoundation/frost", branch = "main" } -dkls23-secp256k1 = { git = "https://github.com/0xCarbon/DKLs23", branch = "main", features = ["serde"] } -dkls23-secp256r1 = { git = "https://github.com/0xCarbon/DKLs23", branch = "main", features = ["serde"] } +dkls23-secp256k1 = { git = "https://github.com/Lunar-Rails/DKLs23", branch = "upstream/scalar-tweaks", features = ["serde"] } +dkls23-secp256r1 = { git = "https://github.com/Lunar-Rails/DKLs23", branch = "upstream/scalar-tweaks", features = ["serde"] } zeroize = { version = "1", features = ["derive"] } slotmap = "1" k256 = "=0.14.0-rc.8"