diff --git a/Cargo.lock b/Cargo.lock
index 793cbd5..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",
]
@@ -1258,8 +1259,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",
]
@@ -1887,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"
diff --git a/examples/tokio-tss/src/router.rs b/examples/tokio-tss/src/router.rs
index 6877b38..a369689 100644
--- a/examples/tokio-tss/src/router.rs
+++ b/examples/tokio-tss/src/router.rs
@@ -153,8 +153,7 @@ impl EncryptedRouter {
if let Some(target) = message.to {
let (send_channel, _) = self.peer_channels.get_mut(&target).ok_or_else(|| {
TssError::Protocol(format!(
- "party {} has no encrypted channel for peer {}",
- self_id, target
+ "party {self_id} has no encrypted channel for peer {target}"
))
})?;
@@ -211,8 +210,7 @@ pub fn create_encrypted_routers(
if left_shared != right_shared {
return Err(TssError::Encryption(format!(
- "mismatched shared secret for parties {} and {}",
- left, right
+ "mismatched shared secret for parties {left} and {right}"
)));
}
diff --git a/libtss-dotnet/src/Libtss/KeyShareHandle.cs b/libtss-dotnet/src/Libtss/KeyShareHandle.cs
index 24a435d..58a8ff7 100644
--- a/libtss-dotnet/src/Libtss/KeyShareHandle.cs
+++ b/libtss-dotnet/src/Libtss/KeyShareHandle.cs
@@ -87,6 +87,37 @@ public unsafe byte[] GroupVerifyingKey
}
}
+ ///
+ /// The 32-byte BIP-32 chain code paired with .
+ ///
+ ///
+ ///
+ /// Only the BIP-32-capable ciphersuites carry a chain code:
+ /// ,
+ /// and . Every other suite throws a
+ /// with .
+ ///
+ ///
+ /// Pair with to build a BIP-32 extended public key (xpub)
+ /// whose off-MPC child derivation matches .
+ ///
+ ///
+ public unsafe byte[] ChainCode
+ {
+ get
+ {
+ ThrowIfDisposed();
+ var buf = new NativeTssBuffer();
+ int status;
+ fixed (TssBuffer* p = &buf.Raw)
+ {
+ status = Native.tss_handle_chain_code(_handle, p);
+ }
+ TssException.ThrowIfError(status);
+ return buf.ToArray();
+ }
+ }
+
///
/// The public verification key for this specific share as raw bytes.
///
diff --git a/libtss-dotnet/src/Libtss/Native.cs b/libtss-dotnet/src/Libtss/Native.cs
index d9dcb1c..02e3219 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);
@@ -285,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/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-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/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..f5571e2 100644
--- a/libtss-ffi/libtss.h
+++ b/libtss-ffi/libtss.h
@@ -181,6 +181,14 @@ TssStatus tss_handle_verifying_share(TssHandle handle, struct TssBuffer *out);
TssStatus tss_handle_group_key(TssHandle handle, struct TssBuffer *out);
+// Return the 32-byte BIP-32 chain code paired with the group key.
+//
+// Supported for the BIP-32-capable ciphersuites only (Secp256k1ECDSA,
+// Secp256r1ECDSA, Secp256k1Taproot); every other suite returns
+// `TSS_ERR_PROTO_MISMATCH`. Pair with `tss_handle_group_key` to build an
+// extended public key whose off-MPC child derivation matches `tss_derive_child`.
+TssStatus tss_handle_chain_code(TssHandle handle, struct TssBuffer *out);
+
TssStatus tss_handle_pubkey_package(TssHandle handle, struct TssBuffer *out);
uint8_t tss_handle_ciphersuite(TssHandle handle);
@@ -235,6 +243,41 @@ TssStatus tss_message_build(struct TssBuffer *buf,
const uint8_t *data,
size_t data_len);
+// Compute this helper's masked DKLs23 repair term for `target`.
+//
+// `helpers` lists every helper (this party included, the target excluded);
+// all helpers must pass the identical list and `session_id`. `out_term`
+// receives a 32-byte scalar to unicast to the target; free it with
+// `tss_buffer_free`. A share missing a zero-share pairing with any helper
+// (a hollow, not yet refreshed share) returns `TSS_ERR_INVALID_SHARE`
+// rather than an unmasked term.
+TssStatus tss_repair_helper_term(TssHandle key_share,
+ const uint16_t *helpers,
+ size_t helpers_len,
+ uint16_t target,
+ const uint8_t *session_id,
+ size_t session_id_len,
+ struct TssBuffer *out_term);
+
+// Assemble helpers' repair terms into a hollow DKLs23 key share for `target`.
+//
+// `pubkey_package` is the serialized group package and `chain_code` its
+// 32-byte BIP-32 chain code, both as the helpers report them; `terms` holds
+// at least `min_signers` outputs of `tss_repair_helper_term`. The sum of the
+// terms is checked against the target's verifying share and a mismatch
+// returns `TSS_ERR_INVALID_SHARE` without creating a handle. The returned
+// share cannot sign until a `tss_refresh_new_dkls` refresh has run across the
+// whole group. Free it with `tss_handle_free`.
+TssStatus tss_repair_assemble(uint8_t suite,
+ struct TssSlice pubkey_package,
+ struct TssSlice chain_code,
+ uint16_t target,
+ const struct TssSlice *terms,
+ size_t terms_len,
+ const uint8_t *session_id,
+ size_t session_id_len,
+ TssHandle *out_key_share);
+
TssStatus tss_dkg_new(uint8_t suite,
uint16_t self_id,
uint16_t max_signers,
@@ -272,6 +315,20 @@ TssStatus tss_refresh_new(TssHandle key_share,
TssHandle *out_session,
struct TssBuffer *out_messages);
+// Create a DKLs23 complete refresh session for a secp256k1 key share.
+//
+// Every party of the group must run this under the same fresh `session_id`;
+// the refreshed shares are salted with it and cannot co-sign with any other
+// generation. Advance with `tss_refresh_next` three times. `key_share` stays
+// valid; the completed session yields a new handle. Non-DKLs23 shares return
+// `TSS_ERR_PROTO_MISMATCH`; an empty `session_id` returns
+// `TSS_ERR_INVALID_CONFIG`.
+TssStatus tss_refresh_new_dkls(TssHandle key_share,
+ const uint8_t *session_id,
+ size_t session_id_len,
+ TssHandle *out_session,
+ struct TssBuffer *out_messages);
+
TssStatus tss_refresh_receiver(TssHandle key_share, TssHandle *out_session);
TssStatus tss_refresh_next(TssHandle session,
@@ -283,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/handles.rs b/libtss-ffi/src/handles.rs
index 5b67646..1d9cffe 100644
--- a/libtss-ffi/src/handles.rs
+++ b/libtss-ffi/src/handles.rs
@@ -45,6 +45,23 @@ pub extern "C" fn tss_handle_group_key(handle: TssHandle, out: *mut TssBuffer) -
})
}
+/// Return the 32-byte BIP-32 chain code paired with the group key.
+///
+/// Supported for the BIP-32-capable ciphersuites only (Secp256k1ECDSA,
+/// Secp256r1ECDSA, Secp256k1Taproot); every other suite returns
+/// `TSS_ERR_PROTO_MISMATCH`. Pair with `tss_handle_group_key` to build an
+/// extended public key whose off-MPC child derivation matches `tss_derive_child`.
+#[no_mangle]
+pub extern "C" fn tss_handle_chain_code(handle: TssHandle, out: *mut TssBuffer) -> TssStatus {
+ ffi_entry!({
+ let out = unsafe { out.as_mut() }
+ .ok_or_else(|| libtss::TssError::InvalidConfig("out is null".into()))?;
+ let ks = borrow_key_share(handle)?;
+ *out = TssBuffer::from_vec(ks.chain_code()?);
+ Ok(())
+ })
+}
+
#[no_mangle]
pub extern "C" fn tss_handle_pubkey_package(handle: TssHandle, out: *mut TssBuffer) -> TssStatus {
ffi_entry!({
diff --git a/libtss-ffi/src/lib.rs b/libtss-ffi/src/lib.rs
index 498f1bd..1e38809 100644
--- a/libtss-ffi/src/lib.rs
+++ b/libtss-ffi/src/lib.rs
@@ -9,7 +9,9 @@ pub mod init;
pub mod memory;
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/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/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/common/mod.rs b/libtss-ffi/tests/common/mod.rs
new file mode 100644
index 0000000..7cc3626
--- /dev/null
+++ b/libtss-ffi/tests/common/mod.rs
@@ -0,0 +1,349 @@
+//! Shared FFI-level protocol driver for integration tests: runs a complete DKG
+//! (and, for DKLs23, a signing session) purely through the C ABI, routing the
+//! TLV message blobs between parties the way a host application would.
+#![allow(dead_code)]
+
+use std::ptr;
+
+use libtss_ffi::memory::tss_buffer_free;
+use libtss_ffi::messages::{tss_message_at, tss_message_build, tss_message_count};
+use libtss_ffi::session::{
+ tss_dkg_new, tss_dkg_next, tss_refresh_new_dkls, tss_refresh_next, tss_session_free,
+ tss_sign_new, tss_sign_next,
+};
+use libtss_ffi::types::{TssBuffer, TssHandle, TssSlice, TSS_OK};
+
+pub const SUITE_SECP256K1_TAPROOT: u8 = 0;
+pub const SUITE_ED25519: u8 = 2;
+pub const SUITE_SECP256K1_ECDSA: u8 = 6;
+
+pub fn slice(bytes: &[u8]) -> TssSlice {
+ TssSlice {
+ data: bytes.as_ptr(),
+ len: bytes.len(),
+ }
+}
+
+pub fn take_buffer(buf: &mut TssBuffer) -> Vec {
+ if buf.data.is_null() {
+ return Vec::new();
+ }
+ let out = unsafe { std::slice::from_raw_parts(buf.data, buf.len) }.to_vec();
+ tss_buffer_free(buf as *mut TssBuffer);
+ out
+}
+
+/// Route TLV-encoded messages: for each recipient, build a TLV blob containing
+/// only messages addressed to them (direct or broadcast from other senders).
+pub fn route_messages(per_party_msgs: &[Vec], participants: usize) -> Vec> {
+ let mut routed = vec![Vec::::new(); participants];
+
+ for (sender_idx, blob) in per_party_msgs.iter().enumerate() {
+ if blob.is_empty() {
+ continue;
+ }
+ let s = slice(blob);
+ let count = tss_message_count(s);
+
+ for i in 0..count {
+ let mut from: u16 = 0;
+ let mut to: u16 = 0;
+ let mut data = TssSlice {
+ data: ptr::null(),
+ len: 0,
+ };
+ assert_eq!(tss_message_at(s, i, &mut from, &mut to, &mut data), TSS_OK);
+
+ let recipients: Vec = if to != 0 {
+ vec![(to - 1) as usize]
+ } else {
+ (0..participants).filter(|&r| r != sender_idx).collect()
+ };
+
+ for recip_idx in recipients {
+ let mut buf = if routed[recip_idx].is_empty() {
+ TssBuffer::empty()
+ } else {
+ TssBuffer::from_vec(std::mem::take(&mut routed[recip_idx]))
+ };
+ assert_eq!(
+ tss_message_build(&mut buf, from, to, data.data, data.len),
+ TSS_OK
+ );
+ routed[recip_idx] = take_buffer(&mut buf);
+ }
+ }
+ }
+ routed
+}
+
+/// Run a full DKG through the FFI and return one key share handle per party.
+pub fn run_dkg(suite: u8, participants: u16, min_signers: u16) -> Vec {
+ let n = participants as usize;
+ let mut sessions = Vec::new();
+ let mut pending = vec![Vec::new(); n];
+ let mut keys = vec![0u64; n];
+ let session_id = b"ffi-test-session-id";
+
+ for self_id in 1..=participants {
+ let mut session = 0;
+ let mut messages = TssBuffer::empty();
+ assert_eq!(
+ tss_dkg_new(
+ suite,
+ self_id,
+ participants,
+ min_signers,
+ session_id.as_ptr(),
+ session_id.len(),
+ &mut session,
+ &mut messages,
+ ),
+ TSS_OK,
+ );
+ sessions.push(session);
+ pending[(self_id - 1) as usize] = take_buffer(&mut messages);
+ }
+
+ for _ in 0..8 {
+ if keys.iter().all(|h| *h != 0) {
+ break;
+ }
+
+ let routed = route_messages(&pending, n);
+ pending = vec![Vec::new(); n];
+
+ for (index, session) in sessions.iter().enumerate() {
+ if keys[index] != 0 {
+ continue;
+ }
+
+ let mut key_share = 0;
+ let mut pubkey_package = TssBuffer::empty();
+ let mut out_messages = TssBuffer::empty();
+ let mut complete = false;
+ assert_eq!(
+ tss_dkg_next(
+ *session,
+ slice(&routed[index]),
+ &mut key_share,
+ &mut pubkey_package,
+ &mut out_messages,
+ &mut complete,
+ ),
+ TSS_OK,
+ );
+
+ let _ = take_buffer(&mut pubkey_package);
+ if complete {
+ keys[index] = key_share;
+ let _ = take_buffer(&mut out_messages);
+ } else {
+ pending[index] = take_buffer(&mut out_messages);
+ }
+ }
+ }
+
+ assert!(keys.iter().all(|h| *h != 0), "DKG did not complete");
+ for session in sessions {
+ tss_session_free(session);
+ }
+ keys
+}
+
+/// Run a DKLs23 signing session over `message_hash` with the given key shares
+/// (all of them sign; `keys[i]` must belong to party `i + 1`) and return the
+/// 64-byte `r || s` signature.
+pub fn run_dkls_sign(keys: &[TssHandle], message_hash: &[u8]) -> Vec {
+ let signers: Vec<(u16, TssHandle)> = keys
+ .iter()
+ .enumerate()
+ .map(|(i, key)| (i as u16 + 1, *key))
+ .collect();
+ run_dkls_sign_ids(&signers, message_hash)
+}
+
+/// Route messages between an arbitrary set of party ids: `parties[i]` is the
+/// id of the party whose blob is `per_party_msgs[i]`.
+pub fn route_messages_ids(per_party_msgs: &[Vec], parties: &[u16]) -> Vec> {
+ let mut routed = vec![Vec::::new(); parties.len()];
+
+ for (sender_idx, blob) in per_party_msgs.iter().enumerate() {
+ if blob.is_empty() {
+ continue;
+ }
+ let s = slice(blob);
+ let count = tss_message_count(s);
+
+ for i in 0..count {
+ let mut from: u16 = 0;
+ let mut to: u16 = 0;
+ let mut data = TssSlice {
+ data: ptr::null(),
+ len: 0,
+ };
+ assert_eq!(tss_message_at(s, i, &mut from, &mut to, &mut data), TSS_OK);
+
+ let recipients: Vec = if to != 0 {
+ parties.iter().position(|&p| p == to).into_iter().collect()
+ } else {
+ (0..parties.len()).filter(|&r| r != sender_idx).collect()
+ };
+
+ for recip_idx in recipients {
+ let mut buf = if routed[recip_idx].is_empty() {
+ TssBuffer::empty()
+ } else {
+ TssBuffer::from_vec(std::mem::take(&mut routed[recip_idx]))
+ };
+ assert_eq!(
+ tss_message_build(&mut buf, from, to, data.data, data.len),
+ TSS_OK
+ );
+ routed[recip_idx] = take_buffer(&mut buf);
+ }
+ }
+ }
+ routed
+}
+
+/// Run a DKLs23 signing session with an explicit quorum: each entry pairs a
+/// party id with that party's key share handle.
+pub fn run_dkls_sign_ids(signers: &[(u16, TssHandle)], message_hash: &[u8]) -> Vec {
+ let n = signers.len();
+ let ids: Vec = signers.iter().map(|(id, _)| *id).collect();
+ let mut sessions = Vec::new();
+ let mut pending = vec![Vec::new(); n];
+ let sign_id = b"ffi-test-sign-id";
+
+ for (i, (self_id, key)) in signers.iter().enumerate() {
+ let counterparties: Vec = ids.iter().copied().filter(|id| id != self_id).collect();
+ let mut session = 0;
+ let mut out_messages = TssBuffer::empty();
+ assert_eq!(
+ tss_sign_new(
+ *key,
+ slice(message_hash),
+ counterparties.as_ptr(),
+ counterparties.len(),
+ sign_id.as_ptr(),
+ sign_id.len(),
+ &mut session,
+ &mut out_messages,
+ ),
+ TSS_OK,
+ );
+ sessions.push(session);
+ pending[i] = take_buffer(&mut out_messages);
+ }
+
+ for _ in 0..8 {
+ let routed = route_messages_ids(&pending, &ids);
+ pending = vec![Vec::new(); n];
+
+ for (i, session) in sessions.iter().enumerate() {
+ let mut signature = TssBuffer::empty();
+ let mut next_messages = TssBuffer::empty();
+ let mut complete = false;
+ assert_eq!(
+ tss_sign_next(
+ *session,
+ slice(&routed[i]),
+ &mut signature,
+ &mut next_messages,
+ &mut complete,
+ ),
+ TSS_OK,
+ );
+
+ if complete {
+ let sig = take_buffer(&mut signature);
+ let _ = take_buffer(&mut next_messages);
+ for s in &sessions {
+ tss_session_free(*s);
+ }
+ return sig;
+ }
+
+ pending[i] = take_buffer(&mut next_messages);
+ let _ = take_buffer(&mut signature);
+ }
+ }
+
+ panic!("signing did not complete");
+}
+
+/// Run a DKLs23 complete refresh through the FFI over every key in `keys`
+/// (`keys[i]` belongs to party `i + 1`) and return the refreshed handles in
+/// the same order. The input handles stay valid.
+pub fn run_dkls_refresh(keys: &[TssHandle], session_id: &[u8]) -> Vec {
+ let n = keys.len();
+ let mut sessions = Vec::new();
+ let mut pending = vec![Vec::new(); n];
+ let mut refreshed = vec![0u64; n];
+
+ for (i, key) in keys.iter().enumerate() {
+ let mut session = 0;
+ let mut messages = TssBuffer::empty();
+ assert_eq!(
+ tss_refresh_new_dkls(
+ *key,
+ session_id.as_ptr(),
+ session_id.len(),
+ &mut session,
+ &mut messages,
+ ),
+ TSS_OK,
+ );
+ sessions.push(session);
+ pending[i] = take_buffer(&mut messages);
+ }
+
+ for _ in 0..8 {
+ if refreshed.iter().all(|h| *h != 0) {
+ break;
+ }
+
+ let routed = route_messages(&pending, n);
+ pending = vec![Vec::new(); n];
+
+ for (index, session) in sessions.iter().enumerate() {
+ if refreshed[index] != 0 {
+ continue;
+ }
+
+ let mut key_share = 0;
+ let mut pubkey_package = TssBuffer::empty();
+ let mut out_messages = TssBuffer::empty();
+ let mut complete = false;
+ assert_eq!(
+ tss_refresh_next(
+ *session,
+ slice(&routed[index]),
+ &mut key_share,
+ &mut pubkey_package,
+ &mut out_messages,
+ &mut complete,
+ ),
+ TSS_OK,
+ );
+
+ let _ = take_buffer(&mut pubkey_package);
+ if complete {
+ refreshed[index] = key_share;
+ let _ = take_buffer(&mut out_messages);
+ } else {
+ pending[index] = take_buffer(&mut out_messages);
+ }
+ }
+ }
+
+ assert!(
+ refreshed.iter().all(|h| *h != 0),
+ "refresh did not complete"
+ );
+ for session in sessions {
+ tss_session_free(session);
+ }
+ refreshed
+}
diff --git a/libtss-ffi/tests/derive_test.rs b/libtss-ffi/tests/derive_test.rs
index 0c411e6..a589f47 100644
--- a/libtss-ffi/tests/derive_test.rs
+++ b/libtss-ffi/tests/derive_test.rs
@@ -1,6 +1,14 @@
-//! FFI-level tests for BIP-32 key derivation (tss_derive_child, tss_derive_path).
+//! FFI-level tests for BIP-32 key derivation (tss_derive_child, tss_derive_path)
+//! and the chain code accessor (tss_handle_chain_code).
+mod common;
+
+use hmac::{Hmac, Mac};
+use k256::elliptic_curve::ops::Reduce;
+use k256::elliptic_curve::sec1::ToSec1Point;
+use k256::{FieldBytes, ProjectivePoint, PublicKey, Scalar};
use libtss_ffi::types::*;
+use sha2::{Digest, Sha256, Sha512};
use std::ffi::CString;
/// Helper: generate a FROST Taproot 2-of-3 dealer set and return the first handle.
@@ -38,6 +46,59 @@ fn group_key(handle: TssHandle) -> Vec {
bytes
}
+/// Helper: read the chain code for a handle.
+fn chain_code(handle: TssHandle) -> Vec {
+ let mut buf = TssBuffer::empty();
+ let status = libtss_ffi::handles::tss_handle_chain_code(handle, &mut buf as *mut TssBuffer);
+ assert_eq!(status, TSS_OK, "tss_handle_chain_code failed");
+ common::take_buffer(&mut buf)
+}
+
+/// Helper: generate an Ed25519 2-of-3 dealer set (no BIP-32 support) and return all handles.
+fn ed25519_handles() -> Vec {
+ let mut handles = [0u64; 3];
+ let mut count: usize = 0;
+ let mut pkg = TssBuffer::empty();
+
+ let status = libtss_ffi::frost_ops::tss_frost_generate_dealer(
+ common::SUITE_ED25519,
+ 3,
+ 2,
+ handles.as_mut_ptr(),
+ &mut count as *mut usize,
+ &mut pkg as *mut TssBuffer,
+ );
+ assert_eq!(status, TSS_OK);
+ libtss_ffi::memory::tss_buffer_free(&mut pkg);
+ handles[..count].to_vec()
+}
+
+/// BIP-32 CKDpub computed independently of libtss: returns the child public
+/// key (compressed SEC1) and the child chain code (`IR`).
+fn bip32_ckd_pub(
+ parent_key: &[u8],
+ parent_chain_code: &[u8],
+ child_number: u32,
+) -> (Vec, Vec) {
+ let mut mac = Hmac::::new_from_slice(parent_chain_code).unwrap();
+ mac.update(parent_key);
+ mac.update(&child_number.to_be_bytes());
+ let output = mac.finalize().into_bytes();
+ let (il, ir) = output.split_at(32);
+
+ let il_bytes: &FieldBytes = il.try_into().unwrap();
+ let tweak = >::reduce(il_bytes);
+ let parent_point = PublicKey::from_sec1_bytes(parent_key)
+ .unwrap()
+ .to_projective();
+ let child_point = (parent_point + ProjectivePoint::GENERATOR * tweak).to_affine();
+
+ (
+ child_point.to_sec1_point(true).as_bytes().to_vec(),
+ ir.to_vec(),
+ )
+}
+
#[test]
fn derive_child_taproot_produces_new_key() {
let parent = taproot_handle();
@@ -163,3 +224,74 @@ fn derive_null_pointer_rejected() {
libtss_ffi::memory::tss_handle_free(parent);
}
+
+#[test]
+fn handle_chain_code_taproot_is_sha256_of_group_key_and_changes_on_derive() {
+ let parent = taproot_handle();
+ let parent_chain_code = chain_code(parent);
+ assert_eq!(parent_chain_code.len(), 32);
+ assert_eq!(
+ parent_chain_code,
+ Sha256::digest(group_key(parent)).to_vec()
+ );
+
+ let mut child: TssHandle = 0;
+ let status = libtss_ffi::derive::tss_derive_child(parent, 7, &mut child as *mut TssHandle);
+ assert_eq!(status, TSS_OK);
+ assert_ne!(chain_code(child), parent_chain_code);
+
+ libtss_ffi::memory::tss_handle_free(child);
+ libtss_ffi::memory::tss_handle_free(parent);
+}
+
+#[test]
+fn handle_chain_code_unsupported_suite_rejected() {
+ let handles = ed25519_handles();
+
+ let mut buf = TssBuffer::empty();
+ let status = libtss_ffi::handles::tss_handle_chain_code(handles[0], &mut buf as *mut TssBuffer);
+ assert_eq!(status, TSS_ERR_PROTO_MISMATCH);
+ assert!(buf.data.is_null(), "no buffer must be allocated on failure");
+
+ for h in handles {
+ libtss_ffi::memory::tss_handle_free(h);
+ }
+}
+
+#[test]
+fn handle_chain_code_null_out_rejected() {
+ let parent = taproot_handle();
+ let status = libtss_ffi::handles::tss_handle_chain_code(parent, std::ptr::null_mut());
+ assert_eq!(status, TSS_ERR_INVALID_CONFIG);
+ libtss_ffi::memory::tss_handle_free(parent);
+}
+
+#[test]
+fn handle_chain_code_invalid_handle_rejected() {
+ let mut buf = TssBuffer::empty();
+ let status = libtss_ffi::handles::tss_handle_chain_code(u64::MAX, &mut buf as *mut TssBuffer);
+ assert_eq!(status, TSS_ERR_HANDLE_INVALID);
+}
+
+#[test]
+fn handle_chain_code_dkls_equal_across_parties_and_matches_bip32() {
+ let keys = common::run_dkg(common::SUITE_SECP256K1_ECDSA, 2, 2);
+
+ let parent_chain_code = chain_code(keys[0]);
+ assert_eq!(parent_chain_code.len(), 32);
+ assert_eq!(parent_chain_code, chain_code(keys[1]));
+
+ let mut child: TssHandle = 0;
+ let status = libtss_ffi::derive::tss_derive_child(keys[0], 3, &mut child as *mut TssHandle);
+ assert_eq!(status, TSS_OK);
+
+ let (expected_child_key, expected_child_chain_code) =
+ bip32_ckd_pub(&group_key(keys[0]), &parent_chain_code, 3);
+ assert_eq!(group_key(child), expected_child_key);
+ assert_eq!(chain_code(child), expected_child_chain_code);
+
+ libtss_ffi::memory::tss_handle_free(child);
+ for key in keys {
+ libtss_ffi::memory::tss_handle_free(key);
+ }
+}
diff --git a/libtss-ffi/tests/memory_hardening_ffi.rs b/libtss-ffi/tests/memory_hardening_ffi.rs
index fc5e05f..16c93a2 100644
--- a/libtss-ffi/tests/memory_hardening_ffi.rs
+++ b/libtss-ffi/tests/memory_hardening_ffi.rs
@@ -114,8 +114,7 @@ fn init_with_mlock_succeeds_or_fails_gracefully() {
let err_len = libtss_ffi::error::tss_last_error_len();
assert!(
err_len > 0,
- "tss_init failed with status {} but set no error message",
- status
+ "tss_init failed with status {status} but set no error message"
);
let ptr = libtss_ffi::error::tss_last_error();
let msg = unsafe { std::ffi::CStr::from_ptr(ptr) }
diff --git a/libtss-ffi/tests/refresh_repair_test.rs b/libtss-ffi/tests/refresh_repair_test.rs
new file mode 100644
index 0000000..62c52c1
--- /dev/null
+++ b/libtss-ffi/tests/refresh_repair_test.rs
@@ -0,0 +1,287 @@
+//! FFI-level tests for DKLs23 share repair (tss_repair_helper_term,
+//! tss_repair_assemble) and complete refresh (tss_refresh_new_dkls).
+
+mod common;
+
+use libtss_ffi::handles::{
+ tss_handle_chain_code, tss_handle_group_key, tss_handle_identifier, tss_handle_pubkey_package,
+ tss_handle_verifying_share,
+};
+use libtss_ffi::memory::tss_handle_free;
+use libtss_ffi::repair::{tss_repair_assemble, tss_repair_helper_term};
+use libtss_ffi::session::{tss_refresh_new, tss_refresh_new_dkls, tss_sign_new};
+use libtss_ffi::tss_verify;
+use libtss_ffi::types::*;
+use sha2::{Digest, Sha256};
+
+const REPAIR_SID: &[u8] = b"ffi-repair-sid";
+const REFRESH_SID: &[u8] = b"ffi-refresh-sid";
+
+fn buffer_of(f: impl FnOnce(*mut TssBuffer) -> TssStatus) -> Vec {
+ let mut buf = TssBuffer::empty();
+ assert_eq!(f(&mut buf as *mut TssBuffer), TSS_OK);
+ common::take_buffer(&mut buf)
+}
+
+fn group_key(handle: TssHandle) -> Vec {
+ buffer_of(|out| tss_handle_group_key(handle, out))
+}
+
+fn verifying_share(handle: TssHandle) -> Vec {
+ buffer_of(|out| tss_handle_verifying_share(handle, out))
+}
+
+fn chain_code(handle: TssHandle) -> Vec {
+ buffer_of(|out| tss_handle_chain_code(handle, out))
+}
+
+fn pubkey_package(handle: TssHandle) -> Vec {
+ buffer_of(|out| tss_handle_pubkey_package(handle, out))
+}
+
+fn identifier(handle: TssHandle) -> u16 {
+ let mut id = 0u16;
+ assert_eq!(tss_handle_identifier(handle, &mut id), TSS_OK);
+ id
+}
+
+fn helper_term(key: TssHandle, helpers: &[u16], target: u16, sid: &[u8]) -> (TssStatus, Vec) {
+ let mut out = TssBuffer::empty();
+ let status = tss_repair_helper_term(
+ key,
+ helpers.as_ptr(),
+ helpers.len(),
+ target,
+ sid.as_ptr(),
+ sid.len(),
+ &mut out,
+ );
+ (status, common::take_buffer(&mut out))
+}
+
+fn assemble(
+ package: &[u8],
+ chain: &[u8],
+ target: u16,
+ terms: &[Vec],
+ sid: &[u8],
+) -> (TssStatus, TssHandle) {
+ let slices: Vec = terms.iter().map(|t| common::slice(t)).collect();
+ let mut out: TssHandle = 0;
+ let status = tss_repair_assemble(
+ common::SUITE_SECP256K1_ECDSA,
+ common::slice(package),
+ common::slice(chain),
+ target,
+ slices.as_ptr(),
+ slices.len(),
+ sid.as_ptr(),
+ sid.len(),
+ &mut out,
+ );
+ (status, out)
+}
+
+fn repair_terms(keys: &[TssHandle], helpers: &[u16], target: u16, sid: &[u8]) -> Vec> {
+ helpers
+ .iter()
+ .map(|helper| {
+ let (status, term) = helper_term(keys[usize::from(*helper) - 1], helpers, target, sid);
+ assert_eq!(status, TSS_OK, "helper {helper}");
+ assert_eq!(term.len(), 32);
+ term
+ })
+ .collect()
+}
+
+#[test]
+fn repair_then_refresh_signs_via_ffi() {
+ let keys = common::run_dkg(common::SUITE_SECP256K1_ECDSA, 5, 3);
+ let lost = keys[1];
+ let expected_share = verifying_share(lost);
+ let expected_group_key = group_key(lost);
+ let expected_chain_code = chain_code(lost);
+ tss_handle_free(lost);
+
+ let helpers = [1u16, 3, 4];
+ let terms = repair_terms(&keys, &helpers, 2, REPAIR_SID);
+ let (status, hollow) = assemble(
+ &pubkey_package(keys[0]),
+ &expected_chain_code,
+ 2,
+ &terms,
+ REPAIR_SID,
+ );
+ assert_eq!(status, TSS_OK);
+ assert_eq!(identifier(hollow), 2);
+ assert_eq!(verifying_share(hollow), expected_share);
+ assert_eq!(group_key(hollow), expected_group_key);
+ assert_eq!(chain_code(hollow), expected_chain_code);
+
+ let hash = Sha256::digest(b"hollow").to_vec();
+ let counterparties = [1u16, 3];
+ let mut session = 0;
+ let mut messages = TssBuffer::empty();
+ assert_eq!(
+ tss_sign_new(
+ hollow,
+ common::slice(&hash),
+ counterparties.as_ptr(),
+ counterparties.len(),
+ REPAIR_SID.as_ptr(),
+ REPAIR_SID.len(),
+ &mut session,
+ &mut messages,
+ ),
+ TSS_ERR_INVALID_SHARE
+ );
+
+ let mut group = keys.clone();
+ group[1] = hollow;
+ let refreshed = common::run_dkls_refresh(&group, REFRESH_SID);
+
+ for (index, key) in refreshed.iter().enumerate() {
+ assert_eq!(identifier(*key), index as u16 + 1);
+ assert_eq!(group_key(*key), expected_group_key);
+ assert_eq!(chain_code(*key), expected_chain_code);
+ assert_ne!(verifying_share(*key), verifying_share(group[index]));
+ }
+
+ let message = b"after refresh";
+ let hash = Sha256::digest(message).to_vec();
+ for quorum in [[1u16, 2, 3], [2, 4, 5]] {
+ let signers: Vec<(u16, TssHandle)> = quorum
+ .iter()
+ .map(|id| (*id, refreshed[usize::from(*id) - 1]))
+ .collect();
+ let signature = common::run_dkls_sign_ids(&signers, &hash);
+ assert!(tss_verify(
+ common::SUITE_SECP256K1_ECDSA,
+ common::slice(message),
+ common::slice(&signature),
+ common::slice(&expected_group_key),
+ ));
+ }
+
+ for key in group.iter().chain(refreshed.iter()) {
+ tss_handle_free(*key);
+ }
+}
+
+#[test]
+fn repair_helper_term_rejects_bad_inputs() {
+ let keys = common::run_dkg(common::SUITE_SECP256K1_ECDSA, 3, 2);
+
+ let (status, _) = helper_term(keys[0], &[1, 3], 3, REPAIR_SID);
+ assert_eq!(status, TSS_ERR_INVALID_CONFIG, "helpers contain target");
+
+ let (status, _) = helper_term(keys[0], &[1], 2, REPAIR_SID);
+ assert_eq!(status, TSS_ERR_INVALID_CONFIG, "too few helpers");
+
+ let (status, _) = helper_term(keys[0], &[1, 3], 2, b"");
+ assert_eq!(status, TSS_ERR_INVALID_CONFIG, "empty session id");
+
+ let (status, _) = helper_term(keys[0], &[1, 4], 2, REPAIR_SID);
+ assert_eq!(status, TSS_ERR_INVALID_ID, "helper outside the group");
+
+ let (status, _) = helper_term(keys[0], &[0, 3], 2, REPAIR_SID);
+ assert_eq!(status, TSS_ERR_INVALID_ID, "zero identifier");
+
+ let (status, _) = helper_term(0, &[1, 3], 2, REPAIR_SID);
+ assert_eq!(status, TSS_ERR_HANDLE_INVALID);
+
+ let frost = common::run_dkg(common::SUITE_ED25519, 3, 2);
+ let (status, _) = helper_term(frost[0], &[1, 3], 2, REPAIR_SID);
+ assert_eq!(status, TSS_ERR_PROTO_MISMATCH);
+
+ for key in keys.iter().chain(frost.iter()) {
+ tss_handle_free(*key);
+ }
+}
+
+#[test]
+fn repair_assemble_fails_closed() {
+ let keys = common::run_dkg(common::SUITE_SECP256K1_ECDSA, 3, 2);
+ let package = pubkey_package(keys[0]);
+ let chain = chain_code(keys[0]);
+ let helpers = [1u16, 3];
+ let terms = repair_terms(&keys, &helpers, 2, REPAIR_SID);
+
+ let mut tampered = terms.clone();
+ tampered[0][0] ^= 0x01;
+ let (status, handle) = assemble(&package, &chain, 2, &tampered, REPAIR_SID);
+ assert_eq!(status, TSS_ERR_INVALID_SHARE);
+ assert_eq!(handle, 0);
+
+ let (status, _) = assemble(&package, &chain, 2, &terms[..1], REPAIR_SID);
+ assert_eq!(status, TSS_ERR_INVALID_CONFIG, "too few terms");
+
+ let (status, _) = assemble(&package, &chain[..31], 2, &terms, REPAIR_SID);
+ assert_eq!(status, TSS_ERR_INVALID_CONFIG, "short chain code");
+
+ let (status, _) = assemble(&package, &chain, 2, &terms, b"");
+ assert_eq!(status, TSS_ERR_INVALID_CONFIG, "empty session id");
+
+ let (status, _) = assemble(&package, &chain, 3, &terms, REPAIR_SID);
+ assert_eq!(status, TSS_ERR_INVALID_SHARE, "terms for another target");
+
+ let (status, _) = assemble(&package[..package.len() - 1], &chain, 2, &terms, REPAIR_SID);
+ assert_eq!(status, TSS_ERR_DESERIALIZE, "truncated package");
+
+ let frost = common::run_dkg(common::SUITE_ED25519, 3, 2);
+ let (status, _) = assemble(&pubkey_package(frost[0]), &chain, 2, &terms, REPAIR_SID);
+ assert_eq!(status, TSS_ERR_PROTO_MISMATCH);
+
+ let (status, hollow) = assemble(&package, &chain, 2, &terms, REPAIR_SID);
+ assert_eq!(status, TSS_OK);
+ let (status, _) = helper_term(hollow, &[2, 3], 1, REPAIR_SID);
+ assert_eq!(status, TSS_ERR_INVALID_SHARE, "hollow share cannot help");
+
+ tss_handle_free(hollow);
+ for key in keys.iter().chain(frost.iter()) {
+ tss_handle_free(*key);
+ }
+}
+
+#[test]
+fn refresh_new_dkls_rejects_wrong_inputs() {
+ let keys = common::run_dkg(common::SUITE_SECP256K1_ECDSA, 2, 2);
+ let mut session = 0;
+ let mut messages = TssBuffer::empty();
+
+ assert_eq!(
+ tss_refresh_new_dkls(keys[0], std::ptr::null(), 0, &mut session, &mut messages),
+ TSS_ERR_INVALID_CONFIG
+ );
+ assert_eq!(
+ tss_refresh_new_dkls(
+ 0,
+ REFRESH_SID.as_ptr(),
+ REFRESH_SID.len(),
+ &mut session,
+ &mut messages
+ ),
+ TSS_ERR_HANDLE_INVALID
+ );
+ assert_eq!(
+ tss_refresh_new(keys[0], std::ptr::null(), 0, &mut session, &mut messages),
+ TSS_ERR_PROTO_MISMATCH,
+ "the FROST refresh entry point still refuses DKLs23 shares"
+ );
+
+ let frost = common::run_dkg(common::SUITE_ED25519, 3, 2);
+ assert_eq!(
+ tss_refresh_new_dkls(
+ frost[0],
+ REFRESH_SID.as_ptr(),
+ REFRESH_SID.len(),
+ &mut session,
+ &mut messages
+ ),
+ TSS_ERR_PROTO_MISMATCH
+ );
+
+ for key in keys.iter().chain(frost.iter()) {
+ tss_handle_free(*key);
+ }
+}
diff --git a/libtss-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/handle.go b/libtss-go/tss/handle.go
index de864df..4dd276b 100644
--- a/libtss-go/tss/handle.go
+++ b/libtss-go/tss/handle.go
@@ -118,18 +118,51 @@ func (h *KeyShareHandle) GroupKey() ([]byte, error) {
return bufferToBytes(&out), nil
}
-func (h *KeyShareHandle) PublicKeyPackage() (PublicKeyPackage, error) {
+// ChainCode returns the 32-byte BIP-32 chain code this share derives children
+// with. Pair it with GroupKey to build a BIP-32 xpub whose off-MPC pubkey
+// derivation matches the child shares each party derives via DeriveChild /
+// DerivePath.
+//
+// Supported for the BIP-32-capable ciphersuites only (Secp256k1ECDSA,
+// Secp256r1ECDSA, Secp256k1Taproot); every other suite returns a *TssError
+// with the protocol-mismatch code.
+func (h *KeyShareHandle) ChainCode() ([]byte, error) {
handle, err := h.native.value()
+ if err != nil {
+ return nil, err
+ }
+ var out C.struct_TssBuffer
+ if err := callStatus(func() C.TssStatus {
+ return C.tss_handle_chain_code(handle, &out)
+ }); err != nil {
+ return nil, err
+ }
+ return bufferToBytes(&out), nil
+}
+
+func (h *KeyShareHandle) PublicKeyPackage() (PublicKeyPackage, error) {
+ raw, err := h.PublicKeyPackageBytes()
if err != nil {
return PublicKeyPackage{}, err
}
+ return decodePublicKeyPackage(raw)
+}
+
+// PublicKeyPackageBytes returns the group's public key package in libtss's
+// serialized form, the encoding RepairAssemble and FrostRepairPart3 accept.
+// Use PublicKeyPackage for the decoded view.
+func (h *KeyShareHandle) PublicKeyPackageBytes() ([]byte, error) {
+ handle, err := h.native.value()
+ if err != nil {
+ return nil, err
+ }
var out C.struct_TssBuffer
if err := callStatus(func() C.TssStatus {
return C.tss_handle_pubkey_package(handle, &out)
}); err != nil {
- return PublicKeyPackage{}, err
+ return nil, err
}
- return decodePublicKeyPackage(bufferToBytes(&out))
+ return bufferToBytes(&out), nil
}
func (h *KeyShareHandle) Ciphersuite() (Ciphersuite, error) {
diff --git a/libtss-go/tss/repair.go b/libtss-go/tss/repair.go
new file mode 100644
index 0000000..381c317
--- /dev/null
+++ b/libtss-go/tss/repair.go
@@ -0,0 +1,93 @@
+package tss
+
+/*
+#include "tss_ffi.h"
+*/
+import "C"
+
+import (
+ "fmt"
+ "unsafe"
+)
+
+// RepairHelperTerm computes this party's masked repair term for the lost
+// share of target.
+//
+// helpers is the full helper set (this party included, target excluded) and
+// every helper must pass the identical set and sessionID, or the pairwise
+// masks do not cancel and RepairAssemble fails closed. The returned 32-byte
+// scalar is meant for the target only; no proper subset of the helpers' terms
+// reveals anything about the lost share. A hollow share (repaired but not
+// yet refreshed) is refused with the invalid-share code rather than emitting
+// an unmasked term.
+func RepairHelperTerm(keyShare *KeyShareHandle, helpers []Identifier, target Identifier, sessionID []byte) ([]byte, error) {
+ if keyShare == nil {
+ return nil, fmt.Errorf("keyShare is nil")
+ }
+ if len(helpers) == 0 {
+ return nil, fmt.Errorf("helpers must not be empty")
+ }
+ if len(sessionID) == 0 {
+ return nil, fmt.Errorf("sessionID must not be empty")
+ }
+ handle, err := keyShare.native.value()
+ if err != nil {
+ return nil, err
+ }
+ helpersPtr := (*C.uint16_t)(unsafe.Pointer(&helpers[0]))
+ sidPtr := (*C.uint8_t)(unsafe.Pointer(&sessionID[0]))
+ var out C.struct_TssBuffer
+ if err := callStatus(func() C.TssStatus {
+ return C.tss_repair_helper_term(
+ handle,
+ helpersPtr,
+ C.uintptr_t(len(helpers)),
+ C.uint16_t(target),
+ sidPtr,
+ C.uintptr_t(len(sessionID)),
+ &out,
+ )
+ }); err != nil {
+ return nil, err
+ }
+ return bufferToBytes(&out), nil
+}
+
+// RepairAssemble rebuilds the lost share of target from the helpers' terms
+// as a hollow key share.
+//
+// pubkeyPackage is the serialized group package (KeyShareHandle.PublicKeyPackageBytes)
+// and chainCode the 32-byte BIP-32 chain code, as every helper reports them;
+// terms holds at least the threshold number of RepairHelperTerm outputs. The
+// sum of the terms is checked against the target's verifying share and a
+// mismatch fails with the invalid-share code without creating a handle. The
+// result derives addresses and refreshes but cannot sign until a complete
+// refresh (NewRefreshSessionDKLs) has run across the whole group.
+func RepairAssemble(suite Ciphersuite, pubkeyPackage, chainCode []byte, target Identifier, terms [][]byte, sessionID []byte) (*KeyShareHandle, error) {
+ if len(terms) == 0 {
+ return nil, fmt.Errorf("terms must not be empty")
+ }
+ if len(sessionID) == 0 {
+ return nil, fmt.Errorf("sessionID must not be empty")
+ }
+ cSlices, cleanup := buildCSliceArray(terms)
+ defer cleanup()
+ sidPtr := (*C.uint8_t)(unsafe.Pointer(&sessionID[0]))
+ var out C.TssHandle
+ if err := callStatus(func() C.TssStatus {
+ return C.tss_repair_assemble(
+ C.uint8_t(suite),
+ bytesToSlice(pubkeyPackage),
+ bytesToSlice(chainCode),
+ C.uint16_t(target),
+ cSlices,
+ C.uintptr_t(len(terms)),
+ sidPtr,
+ C.uintptr_t(len(sessionID)),
+ &out,
+ )
+ }); err != nil {
+ return nil, err
+ }
+ return newKeyShareHandle(out), nil
+}
diff --git a/libtss-go/tss/repair_test.go b/libtss-go/tss/repair_test.go
new file mode 100644
index 0000000..c989d08
--- /dev/null
+++ b/libtss-go/tss/repair_test.go
@@ -0,0 +1,211 @@
+package tss
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "errors"
+ "testing"
+)
+
+// Mirrors of the C status codes; cgo constants are not reachable from _test.go files.
+const (
+ tssErrInvalidConfigCode = 1
+ tssErrInvalidIDCode = 2
+ tssErrInvalidShareCode = 3
+ tssErrDeserializeCode = 9
+)
+
+var (
+ repairSessionID = []byte("go-repair-sid")
+ refreshSessionID = []byte("go-refresh-sid")
+)
+
+func repairTerms(t *testing.T, shares []*KeyShareHandle, helpers []Identifier, target Identifier, sessionID []byte) [][]byte {
+ t.Helper()
+ terms := make([][]byte, len(helpers))
+ for i, helper := range helpers {
+ term, err := RepairHelperTerm(shares[helper-1], helpers, target, sessionID)
+ if err != nil {
+ t.Fatalf("RepairHelperTerm(helper %d): %v", helper, err)
+ }
+ if len(term) != 32 {
+ t.Fatalf("term from helper %d is %d bytes, want 32", helper, len(term))
+ }
+ terms[i] = term
+ }
+ return terms
+}
+
+func assertTssCode(t *testing.T, err error, code int32, context string) {
+ t.Helper()
+ var tssErr *TssError
+ if !errors.As(err, &tssErr) {
+ t.Fatalf("%s: expected *TssError, got %T (%v)", context, err, err)
+ }
+ if tssErr.Code != code {
+ t.Fatalf("%s: code %d (%s), want %d", context, tssErr.Code, tssErr.Message, code)
+ }
+}
+
+func TestDKLs23RepairAndRefresh(t *testing.T) {
+ shares, pubkeys := runDKG(t, CiphersuiteSecp256k1ECDSA, 5, 3, []byte("repair-dkg"))
+ const target Identifier = 2
+
+ lostShare, err := shares[target-1].VerifyingShare()
+ if err != nil {
+ t.Fatalf("VerifyingShare: %v", err)
+ }
+ chainCode, err := shares[0].ChainCode()
+ if err != nil {
+ t.Fatalf("ChainCode: %v", err)
+ }
+ packageBytes, err := shares[0].PublicKeyPackageBytes()
+ if err != nil {
+ t.Fatalf("PublicKeyPackageBytes: %v", err)
+ }
+ shares[target-1].Free()
+
+ helpers := []Identifier{1, 3, 4}
+ terms := repairTerms(t, shares, helpers, target, repairSessionID)
+ hollow, err := RepairAssemble(CiphersuiteSecp256k1ECDSA, packageBytes, chainCode, target, terms, repairSessionID)
+ if err != nil {
+ t.Fatalf("RepairAssemble: %v", err)
+ }
+ defer hollow.Free()
+
+ if id, err := hollow.Identifier(); err != nil || id != target {
+ t.Fatalf("hollow identifier = %d, %v; want %d", id, err, target)
+ }
+ if got, err := hollow.VerifyingShare(); err != nil || !bytes.Equal(got, lostShare) {
+ t.Fatalf("hollow verifying share mismatch (err %v)", err)
+ }
+ if got, err := hollow.GroupKey(); err != nil || !bytes.Equal(got, pubkeys.VerifyingKey) {
+ t.Fatalf("hollow group key mismatch (err %v)", err)
+ }
+ if got, err := hollow.ChainCode(); err != nil || !bytes.Equal(got, chainCode) {
+ t.Fatalf("hollow chain code mismatch (err %v)", err)
+ }
+
+ hash := sha256.Sum256([]byte("hollow"))
+ _, _, err = NewSignSession(hollow, hash[:], []Identifier{1, 3}, []byte("sign"))
+ assertTssCode(t, err, tssErrInvalidShareCode, "signing with a hollow share")
+
+ _, err = RepairHelperTerm(hollow, []Identifier{2, 3, 4}, 1, repairSessionID)
+ assertTssCode(t, err, tssErrInvalidShareCode, "helping with a hollow share")
+
+ group := append([]*KeyShareHandle(nil), shares...)
+ group[target-1] = hollow
+ refreshed := runDKLsRefresh(t, group, refreshSessionID)
+ for i, share := range refreshed {
+ defer share.Free()
+ id, err := share.Identifier()
+ if err != nil || id != Identifier(i+1) {
+ t.Fatalf("refreshed identifier %d = %d, %v", i+1, id, err)
+ }
+ groupKey, err := share.GroupKey()
+ if err != nil || !bytes.Equal(groupKey, pubkeys.VerifyingKey) {
+ t.Fatalf("refreshed share %d changed the group key (err %v)", i+1, err)
+ }
+ got, err := share.ChainCode()
+ if err != nil || !bytes.Equal(got, chainCode) {
+ t.Fatalf("refreshed share %d changed the chain code (err %v)", i+1, err)
+ }
+ before, err := group[i].VerifyingShare()
+ if err != nil {
+ t.Fatalf("VerifyingShare(before %d): %v", i+1, err)
+ }
+ after, err := share.VerifyingShare()
+ if err != nil {
+ t.Fatalf("VerifyingShare(after %d): %v", i+1, err)
+ }
+ if bytes.Equal(before, after) {
+ t.Fatalf("refresh did not move share %d", i+1)
+ }
+ }
+
+ msg := []byte("after repair and refresh")
+ digest := sha256.Sum256(msg)
+ for _, quorum := range [][]Identifier{{1, 2, 3}, {2, 4, 5}} {
+ signers := make([]*KeyShareHandle, len(quorum))
+ for i, id := range quorum {
+ signers[i] = refreshed[id-1]
+ }
+ sig := runSignRound(t, CiphersuiteSecp256k1ECDSA, signers, digest[:])
+ valid, err := Verify(CiphersuiteSecp256k1ECDSA, msg, sig.Data, pubkeys.VerifyingKey)
+ if err != nil || !valid {
+ t.Fatalf("quorum %v: signature invalid (err %v)", quorum, err)
+ }
+ }
+}
+
+func TestDKLs23RepairRejectsBadInputs(t *testing.T) {
+ shares, _ := runDKG(t, CiphersuiteSecp256k1ECDSA, 3, 2, []byte("repair-negatives"))
+ for _, share := range shares {
+ defer share.Free()
+ }
+ chainCode, err := shares[0].ChainCode()
+ if err != nil {
+ t.Fatalf("ChainCode: %v", err)
+ }
+ packageBytes, err := shares[0].PublicKeyPackageBytes()
+ if err != nil {
+ t.Fatalf("PublicKeyPackageBytes: %v", err)
+ }
+ helpers := []Identifier{1, 3}
+ terms := repairTerms(t, shares, helpers, 2, repairSessionID)
+
+ _, err = RepairHelperTerm(shares[0], []Identifier{1, 2}, 2, repairSessionID)
+ assertTssCode(t, err, tssErrInvalidConfigCode, "helpers containing the target")
+ _, err = RepairHelperTerm(shares[0], []Identifier{1}, 2, repairSessionID)
+ assertTssCode(t, err, tssErrInvalidConfigCode, "too few helpers")
+ _, err = RepairHelperTerm(shares[0], []Identifier{1, 4}, 2, repairSessionID)
+ assertTssCode(t, err, tssErrInvalidIDCode, "helper outside the group")
+ if _, err := RepairHelperTerm(shares[0], helpers, 2, nil); err == nil {
+ t.Fatal("RepairHelperTerm accepted an empty session id")
+ }
+ if _, err := RepairHelperTerm(nil, helpers, 2, repairSessionID); err == nil {
+ t.Fatal("RepairHelperTerm accepted a nil share")
+ }
+
+ tampered := [][]byte{append([]byte(nil), terms[0]...), terms[1]}
+ tampered[0][31] ^= 1
+ _, err = RepairAssemble(CiphersuiteSecp256k1ECDSA, packageBytes, chainCode, 2, tampered, repairSessionID)
+ assertTssCode(t, err, tssErrInvalidShareCode, "tampered term")
+
+ _, err = RepairAssemble(CiphersuiteSecp256k1ECDSA, packageBytes, chainCode, 2, terms[:1], repairSessionID)
+ assertTssCode(t, err, tssErrInvalidConfigCode, "too few terms")
+
+ _, err = RepairAssemble(CiphersuiteSecp256k1ECDSA, packageBytes, chainCode[:31], 2, terms, repairSessionID)
+ assertTssCode(t, err, tssErrInvalidConfigCode, "short chain code")
+
+ _, err = RepairAssemble(CiphersuiteSecp256k1ECDSA, packageBytes, chainCode, 3, terms, repairSessionID)
+ assertTssCode(t, err, tssErrInvalidShareCode, "terms for another target")
+
+ _, err = RepairAssemble(CiphersuiteSecp256k1ECDSA, packageBytes[:len(packageBytes)-1], chainCode, 2, terms, repairSessionID)
+ assertTssCode(t, err, tssErrDeserializeCode, "truncated package")
+
+ otherSID := repairTerms(t, shares, helpers, 2, []byte("other-sid"))
+ mixed := [][]byte{terms[0], otherSID[1]}
+ _, err = RepairAssemble(CiphersuiteSecp256k1ECDSA, packageBytes, chainCode, 2, mixed, repairSessionID)
+ assertTssCode(t, err, tssErrInvalidShareCode, "terms from different repair sessions")
+
+ if _, err := RepairAssemble(CiphersuiteSecp256k1ECDSA, packageBytes, chainCode, 2, terms, nil); err == nil {
+ t.Fatal("RepairAssemble accepted an empty session id")
+ }
+ if _, err := RepairAssemble(CiphersuiteSecp256k1ECDSA, packageBytes, chainCode, 2, nil, repairSessionID); err == nil {
+ t.Fatal("RepairAssemble accepted no terms")
+ }
+
+ frost, _ := runDKG(t, CiphersuiteEd25519, 3, 2, nil)
+ for _, share := range frost {
+ defer share.Free()
+ }
+ _, err = RepairHelperTerm(frost[0], helpers, 2, repairSessionID)
+ assertTssCode(t, err, tssErrProtoMismatchCode, "FROST share as helper")
+ frostPackage, err := frost[0].PublicKeyPackageBytes()
+ if err != nil {
+ t.Fatalf("PublicKeyPackageBytes(frost): %v", err)
+ }
+ _, err = RepairAssemble(CiphersuiteSecp256k1ECDSA, frostPackage, chainCode, 2, terms, repairSessionID)
+ assertTssCode(t, err, tssErrProtoMismatchCode, "FROST package")
+}
diff --git a/libtss-go/tss/session.go b/libtss-go/tss/session.go
index 3ded7e9..2bc50da 100644
--- a/libtss-go/tss/session.go
+++ b/libtss-go/tss/session.go
@@ -6,6 +6,7 @@ package tss
import "C"
import (
+ "fmt"
"runtime"
"unsafe"
)
@@ -259,6 +260,48 @@ func NewRefreshSession(keyShare *KeyShareHandle, participants []Identifier) (*Re
return newRefreshSession(native), msgs, nil
}
+// NewRefreshSessionDKLs starts a DKLs23 complete refresh for a secp256k1 key
+// share.
+//
+// Every party of the group must refresh together under the same fresh
+// sessionID: the refreshed shares are salted with it and cannot co-sign with
+// shares from any other generation. Next is called three times; the input
+// handle stays valid and the completed step carries a new one. The refresh
+// rebuilds the OT and zero-share state from scratch, which is what turns a
+// hollow share from RepairAssemble into a signing one.
+func NewRefreshSessionDKLs(keyShare *KeyShareHandle, sessionID []byte) (*RefreshSession, []Message, error) {
+ if keyShare == nil {
+ return nil, nil, fmt.Errorf("keyShare is nil")
+ }
+ if len(sessionID) == 0 {
+ return nil, nil, fmt.Errorf("sessionID must not be empty")
+ }
+ handle, err := keyShare.native.value()
+ if err != nil {
+ return nil, nil, err
+ }
+ var native C.TssHandle
+ var out C.struct_TssBuffer
+ sidPtr := (*C.uint8_t)(unsafe.Pointer(&sessionID[0]))
+ if err := callStatus(func() C.TssStatus {
+ return C.tss_refresh_new_dkls(
+ handle,
+ sidPtr,
+ C.uintptr_t(len(sessionID)),
+ &native,
+ &out,
+ )
+ }); err != nil {
+ return nil, nil, err
+ }
+ msgs, err := ParseMessages(bufferToBytes(&out))
+ if err != nil {
+ C.tss_session_free(native)
+ return nil, nil, err
+ }
+ return newRefreshSession(native), msgs, nil
+}
+
func NewRefreshReceiver(keyShare *KeyShareHandle) (*RefreshSession, error) {
if err := checkNonNil("keyShare", keyShare); err != nil {
return nil, err
diff --git a/libtss-go/tss/tss_test.go b/libtss-go/tss/tss_test.go
index 5bf01fa..722e6be 100644
--- a/libtss-go/tss/tss_test.go
+++ b/libtss-go/tss/tss_test.go
@@ -1,7 +1,11 @@
package tss
import (
+ "bytes"
+ "crypto/hmac"
"crypto/sha256"
+ "crypto/sha512"
+ "encoding/binary"
"errors"
"testing"
)
@@ -240,6 +244,116 @@ func TestFROSTRefreshAndSign(t *testing.T) {
}
}
+func runDKLsRefresh(t *testing.T, shares []*KeyShareHandle, sessionID []byte) []*KeyShareHandle {
+ t.Helper()
+ sessions := make([]*RefreshSession, len(shares))
+ round := make([][]Message, len(shares))
+ ids := make([]Identifier, len(shares))
+ for i, share := range shares {
+ id, err := share.Identifier()
+ if err != nil {
+ t.Fatalf("Identifier(%d): %v", i, err)
+ }
+ ids[i] = id
+ session, messages, err := NewRefreshSessionDKLs(share, sessionID)
+ if err != nil {
+ t.Fatalf("NewRefreshSessionDKLs(%d): %v", id, err)
+ }
+ sessions[i] = session
+ round[i] = messages
+ defer session.Free()
+ }
+
+ refreshed := make([]*KeyShareHandle, len(shares))
+ for {
+ nextRound := make([][]Message, len(shares))
+ completed := 0
+ for i := range sessions {
+ step, err := sessions[i].Next(collectPeerMessages(round, i, ids[i]))
+ if err != nil {
+ t.Fatalf("Refresh Next(%d): %v", ids[i], err)
+ }
+ if step.Complete {
+ refreshed[i] = step.KeyShare
+ completed++
+ continue
+ }
+ nextRound[i] = step.Messages
+ }
+ if completed == len(shares) {
+ return refreshed
+ }
+ round = nextRound
+ }
+}
+
+func TestDKLs23RefreshAndSign(t *testing.T) {
+ shares, pubkeys := runDKG(t, CiphersuiteSecp256k1ECDSA, 3, 2, []byte("refresh-dkg"))
+ for _, share := range shares {
+ defer share.Free()
+ }
+
+ refreshed := runDKLsRefresh(t, shares, []byte("refresh-sid"))
+ for i, share := range refreshed {
+ defer share.Free()
+ groupKey, err := share.GroupKey()
+ if err != nil || !bytes.Equal(groupKey, pubkeys.VerifyingKey) {
+ t.Fatalf("refreshed share %d changed the group key (err %v)", i+1, err)
+ }
+ before, err := shares[i].VerifyingShare()
+ if err != nil {
+ t.Fatalf("VerifyingShare(before %d): %v", i+1, err)
+ }
+ after, err := share.VerifyingShare()
+ if err != nil {
+ t.Fatalf("VerifyingShare(after %d): %v", i+1, err)
+ }
+ if bytes.Equal(before, after) {
+ t.Fatalf("refresh did not move share %d", i+1)
+ }
+ }
+
+ msg := []byte("dkls refreshed")
+ digest := sha256.Sum256(msg)
+ sig := runSignRound(t, CiphersuiteSecp256k1ECDSA, refreshed[1:], digest[:])
+ valid, err := Verify(CiphersuiteSecp256k1ECDSA, msg, sig.Data, pubkeys.VerifyingKey)
+ if err != nil || !valid {
+ t.Fatalf("refreshed shares produced an invalid signature (err %v)", err)
+ }
+
+ if _, _, err := NewRefreshSessionDKLs(shares[0], nil); err == nil {
+ t.Fatal("NewRefreshSessionDKLs accepted an empty session id")
+ }
+ frost, _ := runDKG(t, CiphersuiteEd25519, 2, 2, nil)
+ for _, share := range frost {
+ defer share.Free()
+ }
+ _, _, err = NewRefreshSessionDKLs(frost[0], []byte("refresh-sid"))
+ var tssErr *TssError
+ if !errors.As(err, &tssErr) || tssErr.Code != tssErrProtoMismatchCode {
+ t.Fatalf("NewRefreshSessionDKLs(frost) = %v, want protocol mismatch", err)
+ }
+}
+
+func TestPublicKeyPackageBytesRoundTrip(t *testing.T) {
+ shares, pubkeys := runDKG(t, CiphersuiteSecp256k1ECDSA, 2, 2, []byte("package-bytes"))
+ for _, share := range shares {
+ defer share.Free()
+ }
+
+ raw, err := shares[0].PublicKeyPackageBytes()
+ if err != nil {
+ t.Fatalf("PublicKeyPackageBytes: %v", err)
+ }
+ decoded, err := decodePublicKeyPackage(raw)
+ if err != nil {
+ t.Fatalf("decodePublicKeyPackage: %v", err)
+ }
+ if !bytes.Equal(decoded.VerifyingKey, pubkeys.VerifyingKey) || decoded.MinSigners != pubkeys.MinSigners {
+ t.Fatal("serialized package does not match the DKG output")
+ }
+}
+
func TestNonceReuseAndFreedHandle(t *testing.T) {
shares, _ := runDKG(t, CiphersuiteEd25519, 2, 2, nil)
for _, share := range shares {
@@ -389,3 +503,68 @@ func TestAbortCulpritAndBan(t *testing.T) {
roundD = stepC.Messages
}
}
+
+// tssErrProtoMismatch mirrors TSS_ERR_PROTO_MISMATCH; cgo constants are not
+// reachable from _test.go files.
+const tssErrProtoMismatch = 8
+
+func TestDKLs23ChainCode(t *testing.T) {
+ shares, _ := runDKG(t, CiphersuiteSecp256k1ECDSA, 2, 2, []byte("chain-code-session"))
+ for _, share := range shares {
+ defer share.Free()
+ }
+
+ parentChainCode, err := shares[0].ChainCode()
+ if err != nil {
+ t.Fatalf("ChainCode: %v", err)
+ }
+ if len(parentChainCode) != 32 {
+ t.Fatalf("chain code length = %d, want 32", len(parentChainCode))
+ }
+ otherChainCode, err := shares[1].ChainCode()
+ if err != nil {
+ t.Fatalf("ChainCode (party 2): %v", err)
+ }
+ if !bytes.Equal(parentChainCode, otherChainCode) {
+ t.Fatal("chain code differs across parties")
+ }
+
+ child, err := DeriveChild(shares[0], 5)
+ if err != nil {
+ t.Fatalf("DeriveChild: %v", err)
+ }
+ defer child.Free()
+
+ parentKey, err := shares[0].GroupKey()
+ if err != nil {
+ t.Fatalf("GroupKey: %v", err)
+ }
+ mac := hmac.New(sha512.New, parentChainCode)
+ mac.Write(parentKey)
+ mac.Write(binary.BigEndian.AppendUint32(nil, 5))
+ expectedChildChainCode := mac.Sum(nil)[32:]
+
+ childChainCode, err := child.ChainCode()
+ if err != nil {
+ t.Fatalf("ChainCode (child): %v", err)
+ }
+ if !bytes.Equal(childChainCode, expectedChildChainCode) {
+ t.Fatal("child chain code is not the BIP-32 IR of its parent")
+ }
+}
+
+func TestChainCodeUnsupportedSuite(t *testing.T) {
+ shares, _ := runDKG(t, CiphersuiteEd25519, 3, 2, nil)
+ for _, share := range shares {
+ defer share.Free()
+ }
+
+ _, err := shares[0].ChainCode()
+ var tssErr *TssError
+ if !errors.As(err, &tssErr) {
+ t.Fatalf("ChainCode error = %v, want *TssError", err)
+ }
+ if tssErr.Code != tssErrProtoMismatch {
+ t.Fatalf("ChainCode error code = %d, want %d", tssErr.Code, tssErrProtoMismatch)
+ }
+}
diff --git a/libtss-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 eb11224..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
@@ -115,7 +117,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 +201,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/__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 9438f8b..f6bf0ed 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)",
),
@@ -165,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/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-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 72737e9..9564cca 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)]
@@ -341,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/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/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 d0aef9c..0a00a1d 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,8 @@
version,
)
from libtss import frost
+from libtss import tweak
+from libtss.derive import derive_child
# ── Helpers ──────────────────────────────────────────────────────────────────
@@ -287,6 +291,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):
@@ -394,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 26ae396..f5ab4cc 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);
@@ -91,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 {
@@ -730,6 +733,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;
@@ -887,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 cebc3be..bc97a70 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);
@@ -327,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 68b9781..12fedc7 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);
@@ -94,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() {
@@ -581,6 +584,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)
@@ -800,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 0352ac8..2066ff2 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;
@@ -61,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 cc6cc79..2c2945e 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;
};
@@ -47,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 {
@@ -87,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");
@@ -136,6 +150,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 +186,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 +215,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),
});
@@ -200,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 = {
@@ -238,6 +271,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),
});
@@ -252,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)) {
@@ -261,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__/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/__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/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-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 6a9b1ca..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 |
@@ -224,7 +226,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/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/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-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/derive.rs b/libtss/src/derive.rs
index 657c072..7b6c3eb 100644
--- a/libtss/src/derive.rs
+++ b/libtss/src/derive.rs
@@ -139,18 +139,8 @@ fn derive_frost_taproot_child(
// from_frost_taproot_derived calls REGISTRY.insert which would deadlock).
let (key_package, public_key_package) = keyshare::clone_frost_taproot(key_share)?;
- // For FROST Taproot BIP-32, we need a chain code. Use the group verifying key
- // as a deterministic chain code source (hash of the group key).
let parent_pubkey = key_package.verifying_key().serialize().map_err(frost_err)?;
-
- // Derive chain code from the verifying key (deterministic)
- let chain_code = {
- use sha2::Digest;
- let hash = sha2::Sha256::digest(&parent_pubkey);
- let mut cc = [0u8; 32];
- cc.copy_from_slice(&hash);
- cc
- };
+ let chain_code = taproot_chain_code(&parent_pubkey);
let mut mac = HmacSha512::new_from_slice(&chain_code)
.map_err(|e| TssError::InvalidConfig(e.to_string()))?;
@@ -248,6 +238,17 @@ fn frost_err(err: E) -> TssError {
TssError::DeserializeFailed(err.to_string())
}
+/// Chain code used for FROST Taproot BIP-32 derivation: `SHA-256(group key)`.
+///
+/// FROST DKG establishes no chain code, so one is derived deterministically
+/// from the parent's compressed group verifying key. Shared between
+/// `derive_child` and `KeyShareHandle::chain_code` so the value a caller reads
+/// is always the value derivation actually mixes into the HMAC.
+pub(crate) fn taproot_chain_code(group_key: &[u8]) -> [u8; 32] {
+ use sha2::Digest;
+ sha2::Sha256::digest(group_key).into()
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -380,4 +381,94 @@ mod tests {
Err(TssError::ProtocolMismatch)
));
}
+
+ fn dkls_r1_handle() -> KeyShareHandle {
+ let parameters = dkls23_secp256r1::protocols::Parameters::new(2, 3).unwrap();
+ let (parties, pubkeys) = dkls23_secp256r1::protocols::re_key::re_key(
+ ¶meters,
+ b"session-r1",
+ &p256::Scalar::from(7u64),
+ Some([2u8; 32]),
+ dkls23_secp256r1::compute_neo3_address,
+ );
+
+ keyshare::from_dkls_r1_dkg(parties[0].clone(), pubkeys).unwrap()
+ }
+
+ fn ed25519_handle() -> KeyShareHandle {
+ let config = crate::types::ThresholdConfig {
+ min_signers: 2,
+ max_signers: 3,
+ suite: Ciphersuite::Ed25519,
+ };
+ let (shares, pubkey) = crate::frost::frost_generate_with_dealer(&config).unwrap();
+ keyshare::from_frost_dkg(shares.into_iter().next().unwrap(), &pubkey).unwrap()
+ }
+
+ #[test]
+ fn chain_code_dkls_exposes_dkg_value() {
+ let handle = dkls_handle();
+ assert_eq!(handle.chain_code().unwrap(), vec![1u8; 32]);
+ }
+
+ #[test]
+ fn chain_code_dkls_r1_exposes_dkg_value() {
+ let handle = dkls_r1_handle();
+ assert_eq!(handle.chain_code().unwrap(), vec![2u8; 32]);
+ }
+
+ #[test]
+ fn chain_code_taproot_is_sha256_of_group_key() {
+ let handle = frost_handle();
+ let expected = taproot_chain_code(&handle.group_verifying_key()).to_vec();
+ assert_eq!(handle.chain_code().unwrap(), expected);
+ }
+
+ #[test]
+ fn chain_code_unsupported_suite_rejected() {
+ let handle = ed25519_handle();
+ assert!(matches!(
+ handle.chain_code(),
+ Err(TssError::ProtocolMismatch)
+ ));
+ }
+
+ #[test]
+ fn chain_code_dkls_child_matches_bip32_ckd_pub() {
+ use k256::elliptic_curve::ops::Reduce;
+
+ let parent = dkls_handle();
+ let child = derive_child(&parent, 7).unwrap();
+
+ let mut mac = HmacSha512::new_from_slice(&parent.chain_code().unwrap()).unwrap();
+ mac.update(&parent.group_verifying_key());
+ mac.update(&7u32.to_be_bytes());
+ let output = mac.finalize().into_bytes();
+ let (il, ir) = output.split_at(32);
+
+ let il_bytes: &k256::FieldBytes = il.try_into().unwrap();
+ let tweak = >::reduce(il_bytes);
+ let parent_point = k256::PublicKey::from_sec1_bytes(&parent.group_verifying_key())
+ .unwrap()
+ .to_projective();
+ let expected_child = (parent_point + k256::ProjectivePoint::GENERATOR * tweak).to_affine();
+
+ assert_eq!(
+ child.group_verifying_key(),
+ expected_child.to_sec1_point(true).as_bytes()
+ );
+ assert_eq!(child.chain_code().unwrap(), ir);
+ }
+
+ #[test]
+ fn chain_code_taproot_child_is_rederived_from_child_key() {
+ let parent = frost_handle();
+ let child = derive_child(&parent, 1).unwrap();
+
+ assert_ne!(child.chain_code().unwrap(), parent.chain_code().unwrap());
+ assert_eq!(
+ child.chain_code().unwrap(),
+ taproot_chain_code(&child.group_verifying_key()).to_vec()
+ );
+ }
}
diff --git a/libtss/src/dkls.rs b/libtss/src/dkls.rs
index 15945e9..03d2906 100644
--- a/libtss/src/dkls.rs
+++ b/libtss/src/dkls.rs
@@ -1,7 +1,8 @@
use std::collections::BTreeMap;
use dkls23_secp256k1::protocols::dkg::{
- BroadcastDerivationPhase2to4, BroadcastDerivationPhase3to4, TransmitInitZeroSharePhase2to4,
+ BroadcastDerivationPhase2to4, BroadcastDerivationPhase3to4, KeepInitMulPhase3to4,
+ KeepInitZeroSharePhase2to3, KeepInitZeroSharePhase3to4, TransmitInitZeroSharePhase2to4,
TransmitInitZeroSharePhase3to4,
};
use dkls23_secp256k1::protocols::messages::{MessageTag, PhaseInput, PhaseOutput};
@@ -11,7 +12,8 @@ use dkls23_secp256k1::protocols::{Abort, AbortKind, Parameters, PartyIndex};
use dkls23_secp256k1::{Party, PublicKeyPackage as DklsPublicKeyPackage};
use k256::elliptic_curve::sec1::ToSec1Point;
use k256::elliptic_curve::PrimeField;
-use k256::Scalar;
+use k256::{AffinePoint, ProjectivePoint, Scalar};
+use zeroize::Zeroize;
type Secp256k1 = k256::Secp256k1;
type DkgSession = dkls23_secp256k1::DkgSession;
@@ -46,12 +48,65 @@ pub struct DklsSignState<'a> {
pub(crate) self_broadcast: Option,
}
-fn identifier_to_party_index(id: Identifier) -> Result {
+/// Per-party state of a DKLs23 complete refresh, carried between the three
+/// message rounds until `dkls_refresh_phase4` consumes it.
+///
+/// The refresh re-runs DKG with a zero-constant polynomial, so it keeps the
+/// same zero-share and multiplication initialisation material DKG does, plus
+/// the original party and public key package the correction is applied to.
+pub struct DklsRefreshState {
+ pub(crate) party: Party,
+ pub(crate) pubkey: DklsPublicKeyPackage,
+ pub(crate) refresh_sid: Vec,
+ pub(crate) self_id: Identifier,
+ pub(crate) share_count: u8,
+ pub(crate) self_fragment: Option,
+ pub(crate) correction_value: Option,
+ pub(crate) self_proof_commitment: Option,
+ pub(crate) zero_kept_2to3: Option>,
+ pub(crate) zero_kept_3to4: Option>,
+ pub(crate) mul_kept_3to4: Option>>,
+ pub(crate) phase2_input: Option,
+}
+
+impl Drop for DklsRefreshState {
+ fn drop(&mut self) {
+ self.refresh_sid.zeroize();
+ if let Some(fragment) = self.self_fragment.as_mut() {
+ fragment.zeroize();
+ }
+ if let Some(correction) = self.correction_value.as_mut() {
+ correction.zeroize();
+ }
+ if let Some(kept) = self.zero_kept_2to3.as_mut() {
+ for value in kept.values_mut() {
+ value.seed.zeroize();
+ value.salt.zeroize();
+ }
+ }
+ if let Some(kept) = self.zero_kept_3to4.as_mut() {
+ for value in kept.values_mut() {
+ value.seed.zeroize();
+ }
+ }
+ if let Some(kept) = self.mul_kept_3to4.as_mut() {
+ for value in kept.values_mut() {
+ value.ot_sender.s.zeroize();
+ value.ot_receiver.seed.zeroize();
+ value.nonce.zeroize();
+ value.vec_r.zeroize();
+ value.correlation.zeroize();
+ }
+ }
+ }
+}
+
+pub(crate) fn identifier_to_party_index(id: Identifier) -> Result {
let value = u8::try_from(id.as_u16()).map_err(|_| TssError::InvalidIdentifier)?;
PartyIndex::new(value).map_err(|_| TssError::InvalidIdentifier)
}
-fn identifier_to_session_party_index(
+pub(crate) fn identifier_to_session_party_index(
id: Identifier,
share_count: u8,
) -> Result {
@@ -62,7 +117,7 @@ fn identifier_to_session_party_index(
Ok(index)
}
-fn party_index_to_identifier(index: PartyIndex) -> Result {
+pub(crate) fn party_index_to_identifier(index: PartyIndex) -> Result {
Identifier::new(u16::from(index.as_u8()))
}
@@ -151,7 +206,7 @@ fn phase_input_p2p(input: &PhaseInput, sender: u8) -> Result Result {
+pub(crate) fn scalar_from_message(data: &[u8]) -> Result {
let bytes: [u8; 32] = data
.try_into()
.map_err(|_| TssError::DeserializeFailed("invalid scalar fragment length".into()))?;
@@ -213,10 +268,31 @@ pub fn dkls_dkg_phase1(
let parameters = Parameters::new(threshold, share_count)
.map_err(|_| TssError::InvalidConfig("invalid DKLs23 parameters".into()))?;
let session = DkgSession::new(parameters, self_index, session_id);
- let fragments = session.phase1();
+ let (self_fragment, messages) = fragment_messages(session.phase1(), self_id, self_index)?;
+
+ Ok((
+ DklsDkgState {
+ session,
+ self_id,
+ share_count,
+ self_fragment: Some(self_fragment),
+ self_proof_commitment: None,
+ self_bip_phase2: None,
+ self_bip_phase3: None,
+ phase2_input: None,
+ },
+ messages,
+ ))
+}
+
+fn fragment_messages(
+ fragments: Vec,
+ self_id: Identifier,
+ self_index: PartyIndex,
+) -> Result<(Scalar, Vec), TssError> {
let self_position = usize::from(self_index.as_u8() - 1);
let self_fragment: Scalar = fragments[self_position];
- let mut messages = Vec::with_capacity(usize::from(share_count.saturating_sub(1)));
+ let mut messages = Vec::with_capacity(fragments.len().saturating_sub(1));
for (index, fragment) in fragments.into_iter().enumerate() {
let receiver = u16::try_from(index + 1)
@@ -232,44 +308,29 @@ pub fn dkls_dkg_phase1(
});
}
- Ok((
- DklsDkgState {
- session,
- self_id,
- share_count,
- self_fragment: Some(self_fragment),
- self_proof_commitment: None,
- self_bip_phase2: None,
- self_bip_phase3: None,
- phase2_input: None,
- },
- messages,
- ))
+ Ok((self_fragment, messages))
}
-pub fn dkls_dkg_phase2(
- state: &mut DklsDkgState,
+fn collect_fragments(
+ self_id: Identifier,
+ share_count: u8,
+ self_fragment: Scalar,
received: &[Message],
-) -> Result, TssError> {
- let self_index = identifier_to_session_party_index(state.self_id, state.share_count)?;
+) -> Result, TssError> {
+ let self_index = identifier_to_session_party_index(self_id, share_count)?;
let self_position = usize::from(self_index.as_u8() - 1);
- let self_fragment = state
- .self_fragment
- .take()
- .ok_or(TssError::SessionComplete)?;
- let mut fragments: Vec = vec![Scalar::ZERO; usize::from(state.share_count)];
+ let mut fragments: Vec = vec![Scalar::ZERO; usize::from(share_count)];
fragments[self_position] = self_fragment;
- let mut seen = vec![false; usize::from(state.share_count)];
+ let mut seen = vec![false; usize::from(share_count)];
seen[self_position] = true;
for message in received {
- if message.to != Some(state.self_id) {
+ if message.to != Some(self_id) {
continue;
}
- let sender = usize::from(
- identifier_to_session_party_index(message.from, state.share_count)?.as_u8() - 1,
- );
+ let sender =
+ usize::from(identifier_to_session_party_index(message.from, share_count)?.as_u8() - 1);
if seen[sender] {
return Err(TssError::DeserializeFailed(
"duplicate DKG fragment from sender".into(),
@@ -285,6 +346,19 @@ pub fn dkls_dkg_phase2(
));
}
+ Ok(fragments)
+}
+
+pub fn dkls_dkg_phase2(
+ state: &mut DklsDkgState,
+ received: &[Message],
+) -> Result, TssError> {
+ let self_fragment = state
+ .self_fragment
+ .take()
+ .ok_or(TssError::SessionComplete)?;
+ let fragments = collect_fragments(state.self_id, state.share_count, self_fragment, received)?;
+
let (proof_commitment, zero_transmit, bip_broadcast) =
state.session.phase2(&fragments).map_err(abort_to_error)?;
let mut output = PhaseOutput::new();
@@ -412,6 +486,248 @@ pub fn dkls_dkg_phase4(
Ok((party, package, unified))
}
+/// Start a DKLs23 complete refresh for `party`, producing the round-1 fragments.
+///
+/// `refresh_sid` becomes the refreshed party's session id, so every party of
+/// the group must refresh under the same fresh id: shares from different
+/// refresh generations salt signing differently and can never co-sign.
+pub fn dkls_refresh_phase1(
+ party: Party,
+ pubkey: DklsPublicKeyPackage,
+ refresh_sid: &[u8],
+) -> Result<(DklsRefreshState, Vec), TssError> {
+ if refresh_sid.is_empty() {
+ return Err(TssError::InvalidConfig(
+ "refresh session_id must not be empty".into(),
+ ));
+ }
+ if party.parameters.share_count != pubkey.share_count()
+ || party.parameters.threshold != pubkey.threshold()
+ {
+ return Err(TssError::InvalidConfig(
+ "party parameters do not match the public key package".into(),
+ ));
+ }
+
+ let self_id = party_index_to_identifier(party.party_index)?;
+ let share_count = party.parameters.share_count;
+ let (self_fragment, messages) =
+ fragment_messages(party.refresh_complete_phase1(), self_id, party.party_index)?;
+
+ Ok((
+ DklsRefreshState {
+ party,
+ pubkey,
+ refresh_sid: refresh_sid.to_vec(),
+ self_id,
+ share_count,
+ self_fragment: Some(self_fragment),
+ correction_value: None,
+ self_proof_commitment: None,
+ zero_kept_2to3: None,
+ zero_kept_3to4: None,
+ mul_kept_3to4: None,
+ phase2_input: None,
+ },
+ messages,
+ ))
+}
+
+pub fn dkls_refresh_phase2(
+ state: &mut DklsRefreshState,
+ received: &[Message],
+) -> Result, TssError> {
+ let self_fragment = state
+ .self_fragment
+ .take()
+ .ok_or(TssError::SessionComplete)?;
+ let fragments = collect_fragments(state.self_id, state.share_count, self_fragment, received)?;
+
+ let (correction_value, proof_commitment, zero_kept, zero_transmit) = state
+ .party
+ .refresh_complete_phase2(&state.refresh_sid, &fragments);
+ let mut output = PhaseOutput::new();
+ output
+ .add_broadcast(&proof_commitment)
+ .map_err(message_error)?;
+ for message in &zero_transmit {
+ output
+ .add_p2p(message.parties.receiver.as_u8(), message)
+ .map_err(message_error)?;
+ }
+
+ state.correction_value = Some(correction_value);
+ state.self_proof_commitment = Some(proof_commitment);
+ state.zero_kept_2to3 = Some(zero_kept);
+
+ phase_output_to_messages(&output, state.self_id)
+}
+
+pub fn dkls_refresh_phase3(
+ state: &mut DklsRefreshState,
+ received: &[Message],
+) -> Result, TssError> {
+ let phase2_input = messages_to_phase_input(received, state.self_id)?;
+ let mut zero_kept_2to3 = state
+ .zero_kept_2to3
+ .take()
+ .ok_or(TssError::SessionComplete)?;
+ let (zero_kept_3to4, zero_transmit, mul_kept, mul_transmit) = state
+ .party
+ .refresh_complete_phase3(&state.refresh_sid, &zero_kept_2to3);
+ for kept in zero_kept_2to3.values_mut() {
+ kept.seed.zeroize();
+ kept.salt.zeroize();
+ }
+
+ let mut output = PhaseOutput::new();
+ for message in &zero_transmit {
+ output
+ .add_p2p(message.parties.receiver.as_u8(), message)
+ .map_err(message_error)?;
+ }
+ for message in &mul_transmit {
+ output
+ .add_p2p(message.parties.receiver.as_u8(), message)
+ .map_err(message_error)?;
+ }
+
+ state.phase2_input = Some(phase2_input);
+ state.zero_kept_3to4 = Some(zero_kept_3to4);
+ state.mul_kept_3to4 = Some(mul_kept);
+
+ phase_output_to_messages(&output, state.self_id)
+}
+
+/// Finish a DKLs23 complete refresh: rebuild the OT state, apply the share
+/// correction and derive the refreshed public key package.
+///
+/// dkls23's `refresh_complete_phase4` returns only the refreshed `Party`; the
+/// package is recomputed here from the broadcast proof commitments, because
+/// every verifying share moves by its party's correction point while the group
+/// key stays put. The result is self-checked against the new party before it
+/// is returned, so a package that disagrees with the share fails closed.
+pub fn dkls_refresh_phase4(
+ mut state: DklsRefreshState,
+ received: &[Message],
+) -> Result<(Party, DklsPublicKeyPackage, UnifiedPublicKeyPackage), TssError> {
+ let phase2_input = state.phase2_input.take().ok_or(TssError::SessionComplete)?;
+ let phase3_input = messages_to_phase_input(received, state.self_id)?;
+ let self_index = identifier_to_party_index(state.self_id)?;
+ let correction_value = state
+ .correction_value
+ .take()
+ .ok_or(TssError::SessionComplete)?;
+ let zero_kept = state
+ .zero_kept_3to4
+ .take()
+ .ok_or(TssError::SessionComplete)?;
+ let mul_kept = state
+ .mul_kept_3to4
+ .take()
+ .ok_or(TssError::SessionComplete)?;
+
+ let mut proofs_commitments = vec![state
+ .self_proof_commitment
+ .take()
+ .ok_or(TssError::SessionComplete)?];
+ let mut zero_received_phase2 = Vec::new();
+ let mut zero_received_phase3 = Vec::new();
+ let mut mul_received = Vec::new();
+
+ for sender in 1..=state.share_count {
+ if sender == self_index.as_u8() {
+ continue;
+ }
+ proofs_commitments.push(phase_input_broadcast::(
+ &phase2_input,
+ sender,
+ )?);
+ zero_received_phase2.push(phase_input_p2p::(
+ &phase2_input,
+ sender,
+ )?);
+ zero_received_phase3.push(phase_input_p2p::(
+ &phase3_input,
+ sender,
+ )?);
+ mul_received.push(phase_input_p2p::(
+ &phase3_input,
+ sender,
+ )?);
+ }
+
+ let party = state
+ .party
+ .refresh_complete_phase4(
+ &state.refresh_sid,
+ &correction_value,
+ &proofs_commitments,
+ &zero_kept,
+ &zero_received_phase2,
+ &zero_received_phase3,
+ &mul_kept,
+ &mul_received,
+ )
+ .map_err(abort_to_error)?;
+
+ let package = refresh_public_key_package(&state.pubkey, &proofs_commitments)?;
+ let expected_share = (ProjectivePoint::GENERATOR * party.poly_point).to_affine();
+ if package.verifying_share(party.party_index) != Some(&expected_share)
+ || party.pk != *package.verifying_key()
+ {
+ return Err(TssError::InvalidShare);
+ }
+
+ let unified = convert_public_key_package(&package)?;
+ Ok((party, package, unified))
+}
+
+/// Apply the refresh corrections to a public key package.
+///
+/// Each party's proof commitment carries `G * correction_j`; the refreshed
+/// verifying share is the old one moved by that point. The group key is
+/// unchanged because the corrections are shares of zero, which
+/// `refresh_complete_phase4` has already verified.
+pub(crate) fn refresh_public_key_package(
+ old: &DklsPublicKeyPackage,
+ proofs_commitments: &[ProofCommitment],
+) -> Result {
+ let share_count = old.share_count();
+ if proofs_commitments.len() != usize::from(share_count) {
+ return Err(TssError::InvalidConfig(
+ "refresh needs one proof commitment per party".into(),
+ ));
+ }
+
+ let mut verifying_shares: BTreeMap = BTreeMap::new();
+ for proof_commitment in proofs_commitments {
+ let index = proof_commitment.index;
+ if index.as_u8() > share_count {
+ return Err(TssError::InvalidIdentifier);
+ }
+ let old_share = old
+ .verifying_share(index)
+ .ok_or_else(|| TssError::DeserializeFailed("missing verifying share".into()))?;
+ let refreshed = (ProjectivePoint::from(*old_share)
+ + ProjectivePoint::from(proof_commitment.proof.point))
+ .to_affine();
+ if verifying_shares.insert(index, refreshed).is_some() {
+ return Err(TssError::InvalidConfig(
+ "duplicate proof commitment for party".into(),
+ ));
+ }
+ }
+
+ let parameters = Parameters::new(old.threshold(), share_count)
+ .map_err(|_| TssError::InvalidConfig("invalid DKLs23 parameters".into()))?;
+ Ok(DklsPublicKeyPackage::new(
+ *old.verifying_key(),
+ verifying_shares,
+ parameters,
+ ))
+}
+
pub fn dkls_sign_new<'a>(
party: &'a Party,
sign_id: Vec,
@@ -728,6 +1044,133 @@ mod tests {
}
}
+ fn synthetic_package(shares: &[Scalar]) -> DklsPublicKeyPackage {
+ let mut verifying_shares = BTreeMap::new();
+ let mut secret = Scalar::ZERO;
+ for (position, share) in shares.iter().enumerate() {
+ let index = PartyIndex::new(u8::try_from(position + 1).unwrap()).unwrap();
+ verifying_shares.insert(index, (ProjectivePoint::GENERATOR * share).to_affine());
+ secret += share;
+ }
+ DklsPublicKeyPackage::new(
+ (ProjectivePoint::GENERATOR * secret).to_affine(),
+ verifying_shares,
+ Parameters::new(2, u8::try_from(shares.len()).unwrap()).unwrap(),
+ )
+ }
+
+ fn synthetic_proof(index: u8, correction: Scalar) -> ProofCommitment {
+ ProofCommitment {
+ index: PartyIndex::new(index).unwrap(),
+ proof: dkls23_secp256k1::utilities::proofs::DLogProof {
+ point: (ProjectivePoint::GENERATOR * correction).to_affine(),
+ rand_commitments: Vec::new(),
+ proofs: Vec::new(),
+ },
+ commitment: [0u8; 32],
+ }
+ }
+
+ #[test]
+ fn refresh_public_key_package_adds_correction_points() {
+ let shares = [Scalar::from(5u64), Scalar::from(8u64), Scalar::from(13u64)];
+ let corrections = [Scalar::from(3u64), Scalar::from(21u64), Scalar::from(34u64)];
+ let old = synthetic_package(&shares);
+ let proofs: Vec = corrections
+ .iter()
+ .enumerate()
+ .map(|(position, correction)| {
+ synthetic_proof(u8::try_from(position + 1).unwrap(), *correction)
+ })
+ .collect();
+
+ let refreshed = refresh_public_key_package(&old, &proofs).unwrap();
+
+ assert_eq!(refreshed.verifying_key(), old.verifying_key());
+ assert_eq!(refreshed.threshold(), old.threshold());
+ assert_eq!(refreshed.share_count(), old.share_count());
+ for (position, (share, correction)) in shares.iter().zip(&corrections).enumerate() {
+ let index = PartyIndex::new(u8::try_from(position + 1).unwrap()).unwrap();
+ assert_eq!(
+ refreshed.verifying_share(index).unwrap(),
+ &(ProjectivePoint::GENERATOR * (share + correction)).to_affine()
+ );
+ }
+ }
+
+ #[test]
+ fn refresh_public_key_package_rejects_wrong_proof_sets() {
+ let old = synthetic_package(&[Scalar::from(5u64), Scalar::from(8u64)]);
+
+ assert!(matches!(
+ refresh_public_key_package(&old, &[synthetic_proof(1, Scalar::ONE)]),
+ Err(TssError::InvalidConfig(_))
+ ));
+ assert!(matches!(
+ refresh_public_key_package(
+ &old,
+ &[
+ synthetic_proof(1, Scalar::ONE),
+ synthetic_proof(1, Scalar::ONE)
+ ]
+ ),
+ Err(TssError::InvalidConfig(_))
+ ));
+ assert_eq!(
+ refresh_public_key_package(
+ &old,
+ &[
+ synthetic_proof(1, Scalar::ONE),
+ synthetic_proof(3, Scalar::ONE)
+ ]
+ )
+ .unwrap_err(),
+ TssError::InvalidIdentifier
+ );
+ }
+
+ #[test]
+ fn test_refresh_phase1_rejects_empty_session_id_and_shape_mismatch() {
+ let threshold = 2;
+ let share_count = 2;
+ let session_id = vec![7u8; 32];
+ let participants = [id(1), id(2)];
+
+ let mut states = BTreeMap::new();
+ let mut outputs = BTreeMap::new();
+ for self_id in participants {
+ let (state, messages) =
+ dkls_dkg_phase1(threshold, share_count, self_id, session_id.clone()).unwrap();
+ states.insert(self_id, state);
+ outputs.insert(self_id, messages);
+ }
+ for phase in [dkls_dkg_phase2, dkls_dkg_phase3] {
+ let inputs = route_messages(&outputs, &participants);
+ outputs = BTreeMap::new();
+ for self_id in participants {
+ let output = phase(
+ states.get_mut(&self_id).unwrap(),
+ inputs.get(&self_id).unwrap(),
+ )
+ .unwrap();
+ outputs.insert(self_id, output);
+ }
+ }
+ let inputs = route_messages(&outputs, &participants);
+ let (party, package, _) =
+ dkls_dkg_phase4(states.remove(&id(1)).unwrap(), inputs.get(&id(1)).unwrap()).unwrap();
+
+ assert!(matches!(
+ dkls_refresh_phase1(party.clone(), package.clone(), b""),
+ Err(TssError::InvalidConfig(_))
+ ));
+ let other_shape = synthetic_package(&[Scalar::ONE, Scalar::ONE, Scalar::ONE]);
+ assert!(matches!(
+ dkls_refresh_phase1(party, other_shape, b"sid"),
+ Err(TssError::InvalidConfig(_))
+ ));
+ }
+
#[test]
fn test_dkg_phase1_rejects_self_id_outside_share_count() {
assert!(matches!(
diff --git a/libtss/src/keyshare.rs b/libtss/src/keyshare.rs
index 50b501c..ed83d43 100644
--- a/libtss/src/keyshare.rs
+++ b/libtss/src/keyshare.rs
@@ -101,6 +101,43 @@ impl KeyShareHandle {
self.public_key_package.verifying_key().to_vec()
}
+ /// Return the 32-byte BIP-32 chain code this share derives children with.
+ ///
+ /// Pair it with [`group_verifying_key`](Self::group_verifying_key) to build
+ /// a BIP-32 extended public key whose off-MPC child derivation matches the
+ /// child shares each party derives via [`derive_child`](crate::derive_child).
+ /// The chain code is public material.
+ ///
+ /// Supported for exactly the ciphersuites `derive_child` supports:
+ /// - `Secp256k1ECDSA` / `Secp256r1ECDSA` (DKLs23): the chain code established
+ /// at DKG time. Children carry the BIP-32 `IR` chain code, so an external
+ /// CKDpub agrees with `derive_child` at any depth.
+ /// - `Secp256k1Taproot` (FROST): `SHA-256(group verifying key)`. Because the
+ /// chain code is re-derived from each child's key rather than carried as
+ /// `IR`, an external CKDpub agrees with `derive_child` at depth 1 only.
+ ///
+ /// Every other ciphersuite has no BIP-32 derivation and returns
+ /// [`TssError::ProtocolMismatch`].
+ pub fn chain_code(&self) -> Result, TssError> {
+ match self.suite {
+ Ciphersuite::Secp256k1ECDSA | Ciphersuite::Secp256r1ECDSA => REGISTRY
+ .with::(self.id, |inner| match inner {
+ KeyShareInner::DKLs23(party, _) => {
+ Ok(party.derivation_data.chain_code.to_vec())
+ }
+ KeyShareInner::DKLs23Secp256r1(party, _) => {
+ Ok(party.derivation_data.chain_code.to_vec())
+ }
+ _ => Err(TssError::ProtocolMismatch),
+ })?,
+ Ciphersuite::Secp256k1Taproot => Ok(crate::derive::taproot_chain_code(
+ self.public_key_package.verifying_key(),
+ )
+ .to_vec()),
+ _ => Err(TssError::ProtocolMismatch),
+ }
+ }
+
pub fn ciphersuite(&self) -> Ciphersuite {
self.suite
}
@@ -847,6 +884,7 @@ mod tests {
assert!(handle.identifier().as_u16() > 0);
assert!(!handle.verifying_share().is_empty());
assert!(!handle.group_verifying_key().is_empty());
+ assert_eq!(handle.chain_code().unwrap().len(), 32);
assert_eq!(
handle
.public_key_package()
@@ -864,6 +902,7 @@ mod tests {
assert!(handle.identifier().as_u16() > 0);
assert!(!handle.verifying_share().is_empty());
assert!(!handle.group_verifying_key().is_empty());
+ assert_eq!(handle.chain_code().unwrap().len(), 32);
assert_eq!(
handle
.public_key_package()
@@ -897,6 +936,7 @@ mod tests {
assert_eq!(imported.verifying_share(), handle.verifying_share());
assert_eq!(imported.group_verifying_key(), handle.group_verifying_key());
assert_eq!(imported.ciphersuite(), handle.ciphersuite());
+ assert_eq!(imported.chain_code().unwrap(), handle.chain_code().unwrap());
}
#[test]
diff --git a/libtss/src/lib.rs b/libtss/src/lib.rs
index 29cb8bc..9eb0fe0 100644
--- a/libtss/src/lib.rs
+++ b/libtss/src/lib.rs
@@ -8,7 +8,9 @@ pub mod frost;
pub mod handle;
pub mod keyshare;
pub mod message;
+pub mod repair;
pub mod session;
+pub mod tweak;
pub mod types;
pub mod verify;
@@ -24,9 +26,11 @@ 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};
+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/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