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>), Complete, } @@ -92,12 +97,83 @@ impl RefreshSession { }) } - /// Apply received refresh data. Called by each participant after receiving - /// the refresh share from the dealer. + /// Create a DKLs23 complete refresh session for a secp256k1 key share. + /// + /// All `n` parties of the group must run this together under the same + /// fresh `session_id`: the refreshed shares are salted with it and cannot + /// co-sign with shares from any other generation. The refresh rebuilds + /// the OT and zero-share state from scratch, which is what turns a hollow + /// share produced by [`repair_assemble`](crate::repair_assemble) into a + /// signing one. Three message rounds follow (`next()` is called three + /// times); the input handle stays valid and the output is a new one. + pub fn new_dkls( + key_share: &KeyShareHandle, + session_id: &[u8], + ) -> Result<(Self, Vec), TssError> { + if key_share.ciphersuite() != Ciphersuite::Secp256k1ECDSA { + return Err(TssError::ProtocolMismatch); + } + + let party = keyshare::clone_dkls_party(key_share)?; + let pubkey = keyshare::clone_dkls_pubkey(key_share)?; + let (state, messages) = dkls_refresh_phase1(party, pubkey, session_id)?; + + Ok(( + Self { + inner: RefreshInner::Dkls(Box::new(Some(state))), + round: 1, + }, + messages, + )) + } + + /// Advance the refresh with the messages received for this round. pub fn next(&mut self, received: &[Message]) -> Result { + match &mut self.inner { + RefreshInner::Complete => Err(TssError::SessionComplete), + RefreshInner::FrostApply { .. } => self.next_frost_apply(received), + RefreshInner::Dkls(_) => self.next_dkls(received), + } + } + + fn next_dkls(&mut self, received: &[Message]) -> Result { + let dkls_ref = match &mut self.inner { + RefreshInner::Dkls(state) => &mut **state, + _ => return Err(TssError::ProtocolMismatch), + }; + + match self.round { + 1 => { + let state = dkls_ref.as_mut().ok_or(TssError::SessionComplete)?; + let messages = dkls_refresh_phase2(state, received)?; + self.round = 2; + Ok(RefreshOutput::Continue(messages)) + } + 2 => { + let state = dkls_ref.as_mut().ok_or(TssError::SessionComplete)?; + let messages = dkls_refresh_phase3(state, received)?; + self.round = 3; + Ok(RefreshOutput::Continue(messages)) + } + 3 => { + let state = dkls_ref.take().ok_or(TssError::SessionComplete)?; + let (party, dkls_pubkey, public_keys) = dkls_refresh_phase4(state, received)?; + let key_share = keyshare::from_dkls_dkg(party, dkls_pubkey)?; + self.inner = RefreshInner::Complete; + self.round = 4; + Ok(RefreshOutput::Complete { + key_share, + public_keys, + }) + } + _ => Err(TssError::SessionComplete), + } + } + + fn next_frost_apply(&mut self, received: &[Message]) -> Result { let state = std::mem::replace(&mut self.inner, RefreshInner::Complete); match state { - RefreshInner::Complete => Err(TssError::SessionComplete), + RefreshInner::Complete | RefreshInner::Dkls(_) => Err(TssError::ProtocolMismatch), RefreshInner::FrostApply { key_share } => { let msg = received.first().ok_or_else(|| { TssError::DeserializeFailed("no refresh data received".into()) @@ -138,6 +214,14 @@ impl RefreshSession { self.round } + pub fn num_rounds(&self) -> u8 { + match &self.inner { + RefreshInner::FrostApply { .. } => 1, + RefreshInner::Dkls(_) => 4, + RefreshInner::Complete => 0, + } + } + pub fn is_complete(&self) -> bool { matches!(self.inner, RefreshInner::Complete) } @@ -145,10 +229,13 @@ impl RefreshSession { #[cfg(test)] mod tests { + use std::collections::BTreeMap; + use super::*; use crate::frost::{frost_aggregate, frost_commit, frost_generate_with_dealer, frost_sign}; use crate::keyshare; - use crate::types::{Ciphersuite, ThresholdConfig}; + use crate::session::dkg::{DkgOutput, DkgSession}; + use crate::types::ThresholdConfig; fn id(value: u16) -> Identifier { Identifier::new(value).unwrap() @@ -219,4 +306,186 @@ mod tests { Ok(()) } + + fn route( + outputs: &BTreeMap>, + recipients: &[Identifier], + ) -> BTreeMap> { + let mut routed: BTreeMap> = + recipients.iter().map(|&r| (r, Vec::new())).collect(); + for (&sender, messages) in outputs { + for msg in messages { + match msg.to { + Some(to) => routed.get_mut(&to).unwrap().push(msg.clone()), + None => { + for &r in recipients { + if r != sender { + routed.get_mut(&r).unwrap().push(msg.clone()); + } + } + } + } + } + } + routed + } + + fn run_dkls_dkg(threshold: u16, share_count: u16) -> BTreeMap { + let config = ThresholdConfig { + min_signers: threshold, + max_signers: share_count, + suite: Ciphersuite::Secp256k1ECDSA, + }; + let participants: Vec = (1..=share_count).map(id).collect(); + let mut sessions = BTreeMap::new(); + let mut outputs = BTreeMap::new(); + for &pid in &participants { + let (session, messages) = DkgSession::new(&config, pid, Some(b"refresh-dkg")).unwrap(); + sessions.insert(pid, session); + outputs.insert(pid, messages); + } + + let mut shares = BTreeMap::new(); + while shares.len() < participants.len() { + let inputs = route(&outputs, &participants); + outputs = BTreeMap::new(); + for &pid in &participants { + match sessions + .get_mut(&pid) + .unwrap() + .next(inputs.get(&pid).unwrap()) + .unwrap() + { + DkgOutput::Continue(messages) => { + outputs.insert(pid, messages); + } + DkgOutput::Complete { key_share, .. } => { + shares.insert(pid, key_share); + } + } + } + } + shares + } + + fn run_dkls_refresh( + shares: &BTreeMap, + sids: &BTreeMap, + ) -> Result, TssError> { + let participants: Vec = shares.keys().copied().collect(); + let mut sessions = BTreeMap::new(); + let mut outputs = BTreeMap::new(); + for &pid in &participants { + let (session, messages) = RefreshSession::new_dkls(&shares[&pid], sids[&pid])?; + assert_eq!(session.round(), 1); + assert_eq!(session.num_rounds(), 4); + sessions.insert(pid, session); + outputs.insert(pid, messages); + } + + let mut refreshed = BTreeMap::new(); + for _ in 0..3 { + let inputs = route(&outputs, &participants); + outputs = BTreeMap::new(); + for &pid in &participants { + match sessions + .get_mut(&pid) + .unwrap() + .next(inputs.get(&pid).unwrap())? + { + RefreshOutput::Continue(messages) => { + outputs.insert(pid, messages); + } + RefreshOutput::Complete { + key_share, + public_keys, + } => { + refreshed.insert(pid, (key_share, public_keys)); + } + } + } + } + assert_eq!(refreshed.len(), participants.len()); + for session in sessions.values() { + assert!(session.is_complete()); + assert_eq!(session.round(), 4); + } + Ok(refreshed) + } + + #[test] + fn dkls_refresh_session_keeps_group_key_and_moves_shares() -> Result<(), TssError> { + let shares = run_dkls_dkg(2, 3); + let sids: BTreeMap = shares + .keys() + .map(|&pid| (pid, b"refresh-sid".as_slice())) + .collect(); + + let refreshed = run_dkls_refresh(&shares, &sids)?; + + let reference = &refreshed[&id(1)].1; + for (pid, (key_share, public_keys)) in &refreshed { + assert_eq!(public_keys, reference); + assert_eq!(key_share.identifier(), *pid); + assert_eq!( + key_share.group_verifying_key(), + shares[pid].group_verifying_key() + ); + assert_eq!(key_share.chain_code()?, shares[pid].chain_code()?); + assert_ne!(key_share.verifying_share(), shares[pid].verifying_share()); + assert_eq!( + Some(key_share.verifying_share().as_slice()), + public_keys.verifying_share(*pid) + ); + assert_eq!(key_share.public_key_package(), public_keys); + } + assert_eq!( + shares[&id(1)].identifier(), + id(1), + "input handle stays valid" + ); + + Ok(()) + } + + #[test] + fn dkls_refresh_rejects_wrong_suite_and_empty_session_id() { + let config = ThresholdConfig { + min_signers: 2, + max_signers: 3, + suite: Ciphersuite::Ed25519, + }; + let (frost_shares, pubkey) = frost_generate_with_dealer(&config).unwrap(); + let frost_share = + keyshare::from_frost_dkg(frost_shares.into_iter().next().unwrap(), &pubkey).unwrap(); + assert!(matches!( + RefreshSession::new_dkls(&frost_share, b"sid"), + Err(TssError::ProtocolMismatch) + )); + + let shares = run_dkls_dkg(2, 2); + assert!(matches!( + RefreshSession::new_dkls(&shares[&id(1)], b""), + Err(TssError::InvalidConfig(_)) + )); + assert!(matches!( + RefreshSession::new_frost_receiver(&shares[&id(1)]), + Err(TssError::ProtocolMismatch) + )); + } + + #[test] + fn dkls_refresh_mixed_session_ids_abort() { + let shares = run_dkls_dkg(2, 3); + let mut sids: BTreeMap = shares + .keys() + .map(|&pid| (pid, b"refresh-sid".as_slice())) + .collect(); + sids.insert(id(3), b"other-sid"); + + assert!(matches!( + run_dkls_refresh(&shares, &sids), + Err(TssError::Abort { .. }) + )); + } } diff --git a/libtss/src/session/sign.rs b/libtss/src/session/sign.rs index 75f5a67..df55c84 100644 --- a/libtss/src/session/sign.rs +++ b/libtss/src/session/sign.rs @@ -154,6 +154,9 @@ impl SignSession { } let party = keyshare::clone_dkls_party(key_share)?; + if party.mul_senders.is_empty() { + return Err(TssError::InvalidShare); + } let (wrapper, messages) = DklsSignWrapper::new(party, sign_id, counterparties, message_hash)?; diff --git a/libtss/src/tweak.rs b/libtss/src/tweak.rs new file mode 100644 index 0000000..7b255f3 --- /dev/null +++ b/libtss/src/tweak.rs @@ -0,0 +1,301 @@ +//! Scalar tweaks on DKLs23 key shares. +//! +//! BIP-32 [`derive_child`](crate::derive_child) applies an HMAC-derived +//! additive tweak. Lightning (LND) instead derives per-commitment keys from +//! caller-supplied scalars: +//! +//! - single tweak (additive): `k' = k + t (mod n)` +//! - revocation / double tweak: `k' = k * a (mod n)`, then an additive step +//! +//! The math lives in DKLs23 (`Party::tweak_add` / `tweak_mul` and the matching +//! `PublicKeyPackage` methods): the secret share, public key, derivation data +//! and address move with the map, as do the group key and every verifying +//! share, so a threshold set of tweaked shares signs under the tweaked group +//! key. OT correlations, zero-share seeds, multiplication state and the chain +//! code are reused unchanged. This module parses the scalar, picks the +//! address function for the ciphersuite and wraps the result in a new handle. +//! Every party must apply the same tweak before signing; a mismatch aborts +//! recoverably in signing phase 3. +//! +//! For BIP-341 Taproot tweaking of FROST shares see +//! [`frost_tweak_key_share`](crate::frost::frost_tweak_key_share). + +use dkls23_secp256k1::curve::DklsCurve; +use dkls23_secp256k1::protocols::tweak::{scalar_from_be_bytes, TweakError}; +use dkls23_secp256k1::protocols::{Party, PublicKeyPackage}; + +use crate::keyshare::{self, KeyShareHandle}; +use crate::types::Ciphersuite; +use crate::TssError; + +/// Return a key share whose secret is `secret + tweak (mod n)`. +/// +/// `tweak` is a 32-byte big-endian scalar, reduced mod n (matching btcec's +/// `ModNScalar` and therefore LND's single-tweak convention). A zero tweak is +/// a no-op. Supported for `Secp256k1ECDSA` and `Secp256r1ECDSA` (DKLs23); +/// other ciphersuites return [`TssError::ProtocolMismatch`]. A tweak whose +/// result is the identity point returns [`TssError::TweakError`]; a scalar of +/// the wrong length returns [`TssError::InvalidConfig`]. The original key +/// share remains valid. +pub fn tweak_add(key_share: &KeyShareHandle, tweak: &[u8]) -> Result { + apply(key_share, tweak, Tweak::Add) +} + +/// Return a key share whose secret is `secret * factor (mod n)`. +/// +/// `factor` is a 32-byte big-endian scalar, reduced mod n. It is the building +/// block of LND's revocation ("double") tweak. A factor that reduces to zero +/// would destroy the key and returns [`TssError::TweakError`]; a factor of one +/// is a no-op. Ciphersuite support and error mapping match [`tweak_add`]. +pub fn tweak_mul(key_share: &KeyShareHandle, factor: &[u8]) -> Result { + apply(key_share, factor, Tweak::Mul) +} + +#[derive(Clone, Copy)] +enum Tweak { + Add, + Mul, +} + +fn apply( + key_share: &KeyShareHandle, + scalar: &[u8], + tweak: Tweak, +) -> Result { + match key_share.ciphersuite() { + Ciphersuite::Secp256k1ECDSA => { + let scalar = scalar_from_be_bytes::(scalar).map_err(tss_error)?; + let (party, pubkey) = tweak_party( + &keyshare::clone_dkls_party(key_share)?, + &keyshare::clone_dkls_pubkey(key_share)?, + tweak, + &scalar, + dkls23_secp256k1::compute_eth_address, + )?; + keyshare::from_dkls_dkg(party, pubkey) + } + Ciphersuite::Secp256r1ECDSA => { + let scalar = scalar_from_be_bytes::(scalar).map_err(tss_error)?; + let (party, pubkey) = tweak_party( + &keyshare::clone_dkls_r1_party(key_share)?, + &keyshare::clone_dkls_r1_pubkey(key_share)?, + tweak, + &scalar, + dkls23_secp256r1::compute_neo3_address, + )?; + keyshare::from_dkls_r1_dkg(party, pubkey) + } + _ => Err(TssError::ProtocolMismatch), + } +} + +fn tweak_party( + party: &Party, + pubkey: &PublicKeyPackage, + tweak: Tweak, + scalar: &C::Scalar, + address_fn: impl Fn(&C::AffinePoint) -> String, +) -> Result<(Party, PublicKeyPackage), TssError> { + match tweak { + Tweak::Add => Ok(( + party.tweak_add(scalar, address_fn).map_err(tss_error)?, + pubkey.tweak_add(scalar).map_err(tss_error)?, + )), + Tweak::Mul => Ok(( + party.tweak_mul(scalar, address_fn).map_err(tss_error)?, + pubkey.tweak_mul(scalar).map_err(tss_error)?, + )), + } +} + +fn tss_error(err: TweakError) -> TssError { + match err { + TweakError::InvalidScalarLength { .. } => TssError::InvalidConfig(err.to_string()), + _ => TssError::TweakError(err.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::address::{ethereum_address, neo3_address}; + use crate::Identifier; + use dkls23_secp256k1::protocols::re_key::re_key; + use dkls23_secp256k1::protocols::Parameters; + use k256::elliptic_curve::sec1::ToSec1Point; + use k256::{ProjectivePoint, PublicKey, Scalar}; + + const SECRET: u64 = 15; + const SHARE_COUNT: u16 = 3; + + fn dkls_handle() -> KeyShareHandle { + let parameters = Parameters::new(2, SHARE_COUNT as u8).unwrap(); + let (parties, pubkeys) = re_key( + ¶meters, + b"session", + &Scalar::from(SECRET), + Some([3u8; 32]), + dkls23_secp256k1::compute_eth_address, + ); + + keyshare::from_dkls_dkg(parties[0].clone(), pubkeys).unwrap() + } + + fn dkls_r1_handle() -> KeyShareHandle { + let parameters = Parameters::new(2, SHARE_COUNT as u8).unwrap(); + let (parties, pubkeys) = dkls23_secp256r1::protocols::re_key::re_key( + ¶meters, + b"session", + &p256::Scalar::from(SECRET), + Some([3u8; 32]), + dkls23_secp256r1::compute_neo3_address, + ); + + keyshare::from_dkls_r1_dkg(parties[0].clone(), pubkeys).unwrap() + } + + fn scalar_bytes(value: u64) -> [u8; 32] { + let mut bytes = [0u8; 32]; + bytes[24..].copy_from_slice(&value.to_be_bytes()); + bytes + } + + fn k1_point(compressed: &[u8]) -> ProjectivePoint { + PublicKey::from_sec1_bytes(compressed) + .unwrap() + .to_projective() + } + + fn compressed(point: ProjectivePoint) -> Vec { + point.to_affine().to_sec1_point(true).as_bytes().to_vec() + } + + #[test] + fn tweak_add_k1_shifts_group_key_and_every_verifying_share() { + let handle = dkls_handle(); + let tweaked = tweak_add(&handle, &scalar_bytes(7)).unwrap(); + let shift = ProjectivePoint::GENERATOR * Scalar::from(7u64); + + assert_eq!(tweaked.ciphersuite(), Ciphersuite::Secp256k1ECDSA); + assert_eq!(tweaked.identifier(), handle.identifier()); + assert_eq!( + tweaked.group_verifying_key(), + compressed(k1_point(&handle.group_verifying_key()) + shift) + ); + assert_eq!( + tweaked.verifying_share(), + compressed(k1_point(&handle.verifying_share()) + shift) + ); + for index in 1..=SHARE_COUNT { + let id = Identifier::new(index).unwrap(); + let before = handle.public_key_package().verifying_share(id).unwrap(); + let after = tweaked.public_key_package().verifying_share(id).unwrap(); + assert_eq!(after.to_vec(), compressed(k1_point(before) + shift)); + } + } + + #[test] + fn tweak_add_updates_ethereum_address() { + let handle = dkls_handle(); + let tweaked = tweak_add(&handle, &scalar_bytes(7)).unwrap(); + let party = keyshare::clone_dkls_party(&tweaked).unwrap(); + + assert_ne!( + ethereum_address(&tweaked).unwrap(), + ethereum_address(&handle).unwrap() + ); + assert_eq!( + ethereum_address(&tweaked).unwrap(), + dkls23_secp256k1::compute_eth_address(&party.pk) + ); + assert_eq!( + party.pk.to_sec1_point(true).as_bytes(), + tweaked.group_verifying_key().as_slice() + ); + } + + #[test] + fn tweak_add_r1_matches_expected_public_key() { + use p256::elliptic_curve::sec1::ToSec1Point; + + let handle = dkls_r1_handle(); + let tweaked = tweak_add(&handle, &scalar_bytes(7)).unwrap(); + let expected = (p256::PublicKey::from_sec1_bytes(&handle.group_verifying_key()) + .unwrap() + .to_projective() + + p256::ProjectivePoint::GENERATOR * p256::Scalar::from(7u64)) + .to_affine(); + + assert_eq!(tweaked.ciphersuite(), Ciphersuite::Secp256r1ECDSA); + assert_eq!( + tweaked.group_verifying_key(), + expected.to_sec1_point(true).as_bytes() + ); + assert_eq!( + neo3_address(&tweaked).unwrap(), + dkls23_secp256r1::compute_neo3_address(&expected) + ); + } + + #[test] + fn tweak_mul_zero_factor_rejected() { + let handle = dkls_handle(); + assert!(matches!( + tweak_mul(&handle, &scalar_bytes(0)), + Err(TssError::TweakError(_)) + )); + assert!(matches!( + tweak_mul(&dkls_r1_handle(), &scalar_bytes(0)), + Err(TssError::TweakError(_)) + )); + } + + #[test] + fn tweak_add_identity_result_rejected() { + let handle = dkls_handle(); + assert!(matches!( + tweak_add(&handle, &(-Scalar::from(SECRET)).to_bytes()), + Err(TssError::TweakError(_)) + )); + } + + #[test] + fn tweak_rejects_wrong_length_scalar() { + let handle = dkls_handle(); + assert!(matches!( + tweak_add(&handle, &[1u8; 31]), + Err(TssError::InvalidConfig(_)) + )); + assert!(matches!( + tweak_mul(&handle, &[1u8; 33]), + Err(TssError::InvalidConfig(_)) + )); + assert!(matches!( + tweak_add(&dkls_r1_handle(), &[]), + Err(TssError::InvalidConfig(_)) + )); + } + + #[test] + fn tweak_unsupported_suite_rejected() { + for suite in [Ciphersuite::Secp256k1Taproot, Ciphersuite::Ed25519] { + let config = crate::types::ThresholdConfig { + min_signers: 2, + max_signers: 3, + suite, + }; + let (shares, pubkey) = crate::frost::frost_generate_with_dealer(&config).unwrap(); + let handle = + keyshare::from_frost_dkg(shares.into_iter().next().unwrap(), &pubkey).unwrap(); + + assert!(matches!( + tweak_add(&handle, &scalar_bytes(7)), + Err(TssError::ProtocolMismatch) + )); + assert!(matches!( + tweak_mul(&handle, &scalar_bytes(3)), + Err(TssError::ProtocolMismatch) + )); + } + } +} diff --git a/libtss/src/types.rs b/libtss/src/types.rs index 3a33ec6..f2d0a85 100644 --- a/libtss/src/types.rs +++ b/libtss/src/types.rs @@ -274,6 +274,10 @@ impl PublicKeyPackage { self.verifying_shares.get(&id).map(Vec::as_slice) } + pub fn verifying_shares(&self) -> &BTreeMap> { + &self.verifying_shares + } + pub fn min_signers(&self) -> u16 { self.min_signers } diff --git a/libtss/tests/dkls_repair_refresh.rs b/libtss/tests/dkls_repair_refresh.rs new file mode 100644 index 0000000..1cd5fb8 --- /dev/null +++ b/libtss/tests/dkls_repair_refresh.rs @@ -0,0 +1,206 @@ +//! End-to-end: a 3-of-5 DKLs23 group loses party 2's share, three survivors +//! repair it into a hollow share, a complete refresh across all five parties +//! turns it back into a signing share, and quorums with and without the +//! original survivors sign under the unchanged group key. + +use std::collections::BTreeMap; + +use libtss::session::dkg::{DkgOutput, DkgSession}; +use libtss::session::refresh::{RefreshOutput, RefreshSession}; +use libtss::session::sign::{SignOutput, SignSession}; +use libtss::types::{Ciphersuite, Identifier, ThresholdConfig}; +use libtss::{repair_assemble, repair_helper_term, KeyShareHandle, Message, TssError}; +use sha2::Digest; + +fn id(value: u16) -> Identifier { + Identifier::new(value).unwrap() +} + +fn route( + outputs: &BTreeMap>, + recipients: &[Identifier], +) -> BTreeMap> { + let mut routed: BTreeMap> = + recipients.iter().map(|&r| (r, Vec::new())).collect(); + for (&sender, messages) in outputs { + for msg in messages { + match msg.to { + Some(to) => routed.get_mut(&to).unwrap().push(msg.clone()), + None => { + for &r in recipients { + if r != sender { + routed.get_mut(&r).unwrap().push(msg.clone()); + } + } + } + } + } + } + routed +} + +fn run_dkg(threshold: u16, share_count: u16) -> BTreeMap { + let config = ThresholdConfig { + min_signers: threshold, + max_signers: share_count, + suite: Ciphersuite::Secp256k1ECDSA, + }; + let participants: Vec = (1..=share_count).map(id).collect(); + let mut sessions = BTreeMap::new(); + let mut outputs = BTreeMap::new(); + for &pid in &participants { + let (session, messages) = DkgSession::new(&config, pid, Some(b"e2e-dkg")).unwrap(); + sessions.insert(pid, session); + outputs.insert(pid, messages); + } + + let mut shares = BTreeMap::new(); + while shares.len() < participants.len() { + let inputs = route(&outputs, &participants); + outputs = BTreeMap::new(); + for &pid in &participants { + match sessions + .get_mut(&pid) + .unwrap() + .next(inputs.get(&pid).unwrap()) + .unwrap() + { + DkgOutput::Continue(messages) => { + outputs.insert(pid, messages); + } + DkgOutput::Complete { key_share, .. } => { + shares.insert(pid, key_share); + } + } + } + } + shares +} + +fn run_refresh( + shares: &BTreeMap, + session_id: &[u8], +) -> BTreeMap { + let participants: Vec = shares.keys().copied().collect(); + let mut sessions = BTreeMap::new(); + let mut outputs = BTreeMap::new(); + for &pid in &participants { + let (session, messages) = RefreshSession::new_dkls(&shares[&pid], session_id).unwrap(); + sessions.insert(pid, session); + outputs.insert(pid, messages); + } + + let mut refreshed = BTreeMap::new(); + while refreshed.len() < participants.len() { + let inputs = route(&outputs, &participants); + outputs = BTreeMap::new(); + for &pid in &participants { + match sessions + .get_mut(&pid) + .unwrap() + .next(inputs.get(&pid).unwrap()) + .unwrap() + { + RefreshOutput::Continue(messages) => { + outputs.insert(pid, messages); + } + RefreshOutput::Complete { key_share, .. } => { + refreshed.insert(pid, key_share); + } + } + } + } + refreshed +} + +fn run_sign( + shares: &BTreeMap, + signers: &[Identifier], + message: &[u8], +) -> Result, TssError> { + let hash: [u8; 32] = sha2::Sha256::digest(message).into(); + let mut sessions = BTreeMap::new(); + let mut outputs = BTreeMap::new(); + for &pid in signers { + let counterparties: Vec = + signers.iter().copied().filter(|c| *c != pid).collect(); + let (session, messages) = + SignSession::new_dkls(&shares[&pid], b"e2e-sign".to_vec(), &counterparties, hash)?; + sessions.insert(pid, session); + outputs.insert(pid, messages); + } + + loop { + let inputs = route(&outputs, signers); + outputs = BTreeMap::new(); + for &pid in signers { + match sessions + .get_mut(&pid) + .unwrap() + .next(inputs.get(&pid).unwrap())? + { + SignOutput::Continue(messages) => { + outputs.insert(pid, messages); + } + SignOutput::Complete(signature) => return Ok(signature.as_bytes().to_vec()), + } + } + } +} + +#[test] +fn repair_then_refresh_restores_a_signing_group() { + let original = run_dkg(3, 5); + let group_key = original[&id(1)].group_verifying_key(); + let chain_code = original[&id(1)].chain_code().unwrap(); + let lost_verifying_share = original[&id(2)].verifying_share(); + + let mut survivors = original; + survivors.remove(&id(2)); + + let helpers = [id(1), id(3), id(4)]; + let repair_sid = b"e2e-repair-sid"; + let terms: Vec<_> = helpers + .iter() + .map(|helper| repair_helper_term(&survivors[helper], &helpers, id(2), repair_sid).unwrap()) + .collect(); + let term_slices: Vec<&[u8]> = terms.iter().map(|term| term.as_slice()).collect(); + let hollow = repair_assemble( + Ciphersuite::Secp256k1ECDSA, + survivors[&id(1)].public_key_package(), + &chain_code, + id(2), + &term_slices, + repair_sid, + ) + .unwrap(); + + assert_eq!(hollow.identifier(), id(2)); + assert_eq!(hollow.verifying_share(), lost_verifying_share); + assert_eq!(hollow.group_verifying_key(), group_key); + + let mut pre_refresh = survivors; + pre_refresh.insert(id(2), hollow); + assert_eq!( + run_sign(&pre_refresh, &[id(1), id(2), id(3)], b"before refresh").unwrap_err(), + TssError::InvalidShare + ); + + let refreshed = run_refresh(&pre_refresh, b"e2e-refresh-sid"); + + for (pid, share) in &refreshed { + assert_eq!(share.identifier(), *pid); + assert_eq!(share.group_verifying_key(), group_key); + assert_eq!(share.chain_code().unwrap(), chain_code); + assert_ne!(share.verifying_share(), pre_refresh[pid].verifying_share()); + } + + let message = b"after refresh"; + for signers in [[id(1), id(2), id(3)], [id(2), id(4), id(5)]] { + let signature = run_sign(&refreshed, &signers, message).unwrap(); + assert!( + libtss::verify(Ciphersuite::Secp256k1ECDSA, message, &signature, &group_key).unwrap(), + "quorum {signers:?} must sign under the original group key" + ); + } +} diff --git a/libtss/tests/protocol_tests.rs b/libtss/tests/protocol_tests.rs index fb74eb2..91d2dec 100644 --- a/libtss/tests/protocol_tests.rs +++ b/libtss/tests/protocol_tests.rs @@ -10,6 +10,7 @@ use libtss::session::dkg::{DkgOutput, DkgSession}; use libtss::session::sign::{SignOutput, SignSession}; use libtss::types::{Ciphersuite, Identifier, Protocol, ThresholdConfig}; use libtss::{Message, TssError}; +use sha2::Digest; // --------------------------------------------------------------------------- // Test harness: message routing @@ -255,6 +256,60 @@ fn run_dkls_sign( } } +fn scalar_bytes(value: u64) -> [u8; 32] { + let mut bytes = [0u8; 32]; + bytes[24..].copy_from_slice(&value.to_be_bytes()); + bytes +} + +fn tweak_all( + shares: &BTreeMap, + scalar: &[u8], + add: bool, +) -> BTreeMap { + shares + .iter() + .map(|(pid, ks)| { + let tweaked = if add { + libtss::tweak_add(ks, scalar) + } else { + libtss::tweak_mul(ks, scalar) + }; + (*pid, tweaked.unwrap()) + }) + .collect() +} + +/// Sign a fixed message with the given DKLs23 shares and verify the signature +/// under `group_key` with the standard (hash-then-verify) ECDSA verifier. +fn dkls_signs_under( + shares: &BTreeMap, + signer_ids: &[Identifier], + group_key: &[u8], +) -> bool { + let message = b"threshold ecdsa under a scalar tweak"; + let msg_hash: [u8; 32] = sha2::Sha256::digest(message).into(); + let sig = run_dkls_sign(shares, signer_ids, msg_hash).unwrap(); + libtss::verify( + Ciphersuite::Secp256k1ECDSA, + message, + sig.as_bytes(), + group_key, + ) + .unwrap() +} + +fn k1_point(compressed: &[u8]) -> k256::ProjectivePoint { + k256::PublicKey::from_sec1_bytes(compressed) + .unwrap() + .to_projective() +} + +fn compressed(point: k256::ProjectivePoint) -> Vec { + use k256::elliptic_curve::sec1::ToSec1Point; + point.to_affine().to_sec1_point(true).as_bytes().to_vec() +} + // =========================================================================== // Protocol Correctness Tests // =========================================================================== @@ -848,7 +903,7 @@ fn concurrent_frost_sign_sessions() { for (&pid, (data, suite)) in exported { local_shares.insert(pid, libtss::import_key_share(data, *suite).unwrap()); } - let msg = format!("concurrent-sign-{}", thread_idx); + let msg = format!("concurrent-sign-{thread_idx}"); run_frost_sign(&local_shares, &[id(1), id(2)], msg.as_bytes()) }) }) @@ -1081,3 +1136,97 @@ fn test_unified_verify_dkls_secp256k1ecdsa() { .unwrap(); assert!(!verifies_wrong); } + +// ---------- DKLs23 scalar tweaks ---------- + +#[test] +fn dkls_tweak_add_shares_sign_under_tweaked_key() { + let shares = run_dkls_dkg_session(2, 2).unwrap(); + let tweaked = tweak_all(&shares, &scalar_bytes(7), true); + + let group_key = tweaked[&id(1)].group_verifying_key(); + assert_eq!(group_key, tweaked[&id(2)].group_verifying_key()); + assert_eq!( + group_key, + compressed( + k1_point(&shares[&id(1)].group_verifying_key()) + + k256::ProjectivePoint::GENERATOR * k256::Scalar::from(7u64) + ) + ); + + assert!(dkls_signs_under(&tweaked, &[id(1), id(2)], &group_key)); + assert!( + dkls_signs_under( + &shares, + &[id(1), id(2)], + &shares[&id(1)].group_verifying_key() + ), + "parent shares must stay valid after tweaking" + ); +} + +#[test] +fn dkls_tweak_mul_shares_sign_under_scaled_key() { + let shares = run_dkls_dkg_session(2, 3).unwrap(); + let tweaked = tweak_all(&shares, &scalar_bytes(3), false); + + let group_key = tweaked[&id(1)].group_verifying_key(); + assert!(tweaked + .values() + .all(|ks| ks.group_verifying_key() == group_key)); + assert_eq!( + group_key, + compressed(k1_point(&shares[&id(1)].group_verifying_key()) * k256::Scalar::from(3u64)) + ); + + assert!(dkls_signs_under(&tweaked, &[id(2), id(3)], &group_key)); +} + +#[test] +fn dkls_double_tweak_shares_sign_under_composed_key() { + let shares = run_dkls_dkg_session(2, 2).unwrap(); + let scaled = tweak_all(&shares, &scalar_bytes(3), false); + let tweaked = tweak_all(&scaled, &scalar_bytes(7), true); + + let group_key = tweaked[&id(1)].group_verifying_key(); + assert_eq!( + group_key, + compressed( + k1_point(&shares[&id(1)].group_verifying_key()) * k256::Scalar::from(3u64) + + k256::ProjectivePoint::GENERATOR * k256::Scalar::from(7u64) + ) + ); + + assert!(dkls_signs_under(&tweaked, &[id(1), id(2)], &group_key)); +} + +#[test] +fn dkls_tweak_then_derive_child_agrees_across_parties_and_signs() { + let shares = run_dkls_dkg_session(2, 3).unwrap(); + let tweaked = tweak_all(&shares, &scalar_bytes(7), true); + let children: BTreeMap<_, _> = tweaked + .iter() + .map(|(pid, ks)| (*pid, libtss::derive_child(ks, 4).unwrap())) + .collect(); + + let child_key = children[&id(1)].group_verifying_key(); + assert!(children + .values() + .all(|ks| ks.group_verifying_key() == child_key)); + assert_ne!(child_key, tweaked[&id(1)].group_verifying_key()); + + assert!(dkls_signs_under(&children, &[id(1), id(3)], &child_key)); +} + +#[test] +fn dkls_mixed_tweaked_and_untweaked_shares_cannot_sign() { + let mut shares = run_dkls_dkg_session(2, 2).unwrap(); + let mut tweaked = tweak_all(&shares, &scalar_bytes(7), true); + + let mut mixed = BTreeMap::new(); + mixed.insert(id(1), tweaked.remove(&id(1)).unwrap()); + mixed.insert(id(2), shares.remove(&id(2)).unwrap()); + let msg_hash = [0xABu8; 32]; + + assert!(run_dkls_sign(&mixed, &[id(1), id(2)], msg_hash).is_err()); +} diff --git a/spec/03-api-design.md b/spec/03-api-design.md index 7f91c85..949b313 100644 --- a/spec/03-api-design.md +++ b/spec/03-api-design.md @@ -14,7 +14,7 @@ libtss/ │ │ ├── dkls.rs # DKLs23 protocol wrappers (advanced API) │ │ ├── types.rs # Shared types (Identifier, Signature, Config) │ │ ├── derive.rs # BIP-32 key derivation -│ │ ├── tweak.rs # BIP-341 Taproot tweaking +│ │ ├── tweak.rs # Scalar tweaks on DKLs23 shares (Lightning per-commitment keys) │ │ ├── handle.rs # Handle registry │ │ └── error.rs # Unified TssError │ └── Cargo.toml @@ -172,7 +172,7 @@ pub enum TssError { ProtocolMismatch, /// Deserialization of protocol data failed. DeserializeFailed(String), - /// BIP-341 tweaking error. + /// Tweak error (BIP-341 Taproot tweak or DKLs23 scalar tweak). TweakError(String), /// Session has already completed. SessionComplete, @@ -222,6 +222,12 @@ impl KeyShareHandle { /// Group public key (combined verification key). pub fn group_verifying_key(&self) -> Vec; + /// 32-byte BIP-32 chain code paired with the group key; together they form + /// an xpub whose off-MPC child derivation matches `derive_child`. Only the + /// BIP-32-capable suites (Secp256k1ECDSA, Secp256r1ECDSA, Secp256k1Taproot); + /// others return `ProtocolMismatch`. + pub fn chain_code(&self) -> Result, TssError>; + /// Full public key package (group key + all verifying shares). pub fn public_key_package(&self) -> &PublicKeyPackage; @@ -459,11 +465,13 @@ pub enum RefreshOutput { } impl RefreshSession { - /// Interactive share refresh (recommended mode). - /// - /// FROST: DKG-based refresh with zero-constant polynomial. - /// DKLs23: Complete refresh (re-initializes OT/multiplication state). - pub fn new(key_share: &KeyShareHandle) -> Result<(Self, Vec), TssError>; + /// FROST dealer refresh: compute and distribute refresh shares to + /// `participants` (one round; the dealer applies its own via `next()`). + /// Returns `TssError::ProtocolMismatch` for DKLs23 key shares. + pub fn new_frost( + key_share: &KeyShareHandle, + participants: &[Identifier], + ) -> Result<(Self, Vec), TssError>; /// Create a FROST refresh receiver session (non-dealer participant). /// The receiver waits for messages from the dealer refresh and does @@ -471,12 +479,29 @@ impl RefreshSession { /// Returns `TssError::ProtocolMismatch` for DKLs23 key shares. pub fn new_frost_receiver(key_share: &KeyShareHandle) -> Result; + /// DKLs23 complete refresh (secp256k1 only): re-runs DKG with a + /// zero-constant polynomial and re-initializes the OT/multiplication + /// state. All `n` parties must run it under the same fresh `session_id`; + /// three message rounds follow. The input handle stays valid and the + /// completed output is a new handle. Returns `ProtocolMismatch` for + /// FROST and secp256r1 shares, `InvalidConfig` for an empty session id. + pub fn new_dkls( + key_share: &KeyShareHandle, + session_id: &[u8], + ) -> Result<(Self, Vec), TssError>; + pub fn next(&mut self, received: &[Message]) -> Result; pub fn round(&self) -> u8; + /// 1 for the FROST dealer/receiver flow, 4 for DKLs23, 0 once complete. pub fn num_rounds(&self) -> u8; } ``` +Refreshed DKLs23 shares carry `session_id` as their new party session id, +which salts signing. Shares from different refresh generations therefore +never co-sign: a partial refresh leaves the group unsignable until the +stragglers are repaired from the majority generation and refreshed again. + ## Common Types ### Signature @@ -522,6 +547,27 @@ pub fn derive_path( ) -> Result; ``` +### Scalar Tweaks (DKLs23) + +```rust +/// Key share whose secret is `secret + tweak (mod n)`; `tweak` is a 32-byte +/// big-endian scalar reduced mod n (LND's per-commitment "single tweak"). +pub fn tweak_add(key_share: &KeyShareHandle, tweak: &[u8]) -> Result; + +/// Key share whose secret is `secret * factor (mod n)`; building block of LND's +/// revocation ("double") tweak. A factor reducing to zero is rejected. +pub fn tweak_mul(key_share: &KeyShareHandle, factor: &[u8]) -> Result; +``` + +Both return a new handle (the original stays valid) whose secret share, public +key, derivation data, address and every verifying share in the public key +package are moved by the same map, so a threshold set of tweaked shares signs +under the tweaked group key. Supported for `Secp256k1ECDSA` and +`Secp256r1ECDSA`; other suites return `ProtocolMismatch`. Wrong-length input +returns `InvalidConfig`; a zero factor or an identity result returns +`TweakError`. Distinct from the FROST BIP-341 tweak below, which hashes a +Merkle root into the tweak rather than taking a caller-supplied scalar. + ## FROST-Specific Operations These operations exist only for FROST and are not part of the unified session API. @@ -615,6 +661,41 @@ pub fn frost_repair_part3( ) -> Result; ``` +### Share Repair (DKLs23 secp256k1) + +One masked term per helper, one round, no new key material: the masks are +additive shares of zero drawn from the pairwise zero-share seeds every party +already holds from DKG, under the domain `b"libtss/dkls23-repair/v1" || repair_sid`. + +```rust +/// Called by each helper: `poly_point · λ_self(target) + mask`, a 32-byte scalar +/// to unicast to the target. `helpers` is the full helper set (self included, +/// target excluded), identical on every helper. Fails with `InvalidShare` if the +/// caller lacks a zero-share pairing with any helper (a hollow share), instead of +/// emitting an unmasked term. +pub fn repair_helper_term( + key_share: &KeyShareHandle, + helpers: &[Identifier], + target: Identifier, + repair_sid: &[u8], +) -> Result>, TssError>; + +/// Called by the target: sums at least `min_signers` terms into a *hollow* +/// share. Checks `G · Σ terms == verifying_share(target)` and fails with +/// `InvalidShare` otherwise — no handle is created for a share that does not +/// match the group. The result holds no OT or zero-share state and cannot sign +/// (`SignSession::new_dkls` returns `InvalidShare`) until a complete refresh +/// (`RefreshSession::new_dkls`) has run across all `n` parties. +pub fn repair_assemble( + suite: Ciphersuite, + pubkey_package: &PublicKeyPackage, + chain_code: &[u8], + target: Identifier, + terms: &[&[u8]], + session_id: &[u8], +) -> Result; +``` + ## Channel Security The library is transport-agnostic but imposes requirements on channels. diff --git a/spec/04-frost-integration.md b/spec/04-frost-integration.md index d088df1..0a55f30 100644 --- a/spec/04-frost-integration.md +++ b/spec/04-frost-integration.md @@ -282,6 +282,8 @@ TssStatus tss_frost_split_key( The `frost-secp256k1-tr` crate provides a `Tweak` trait that applies BIP-341 tweaks to key shares and public key packages. This enables both keypath and scriptpath spends. +(Caller-supplied scalar tweaks on DKLs23 ECDSA shares — Lightning per-commitment +keys — are a separate operation; see [07-key-management.md](07-key-management.md#scalar-tweaks-lightning).) ### Tweak Computation diff --git a/spec/06-ffi-layer.md b/spec/06-ffi-layer.md index 7c79415..2d326e0 100644 --- a/spec/06-ffi-layer.md +++ b/spec/06-ffi-layer.md @@ -255,6 +255,9 @@ void tss_session_free(TssHandle session); TssStatus tss_handle_identifier(TssHandle handle, uint16_t *out_id); TssStatus tss_handle_verifying_share(TssHandle handle, TssBuffer *out); TssStatus tss_handle_group_key(TssHandle handle, TssBuffer *out); +/// 32-byte BIP-32 chain code paired with the group key. BIP-32-capable suites only +/// (Secp256k1ECDSA, Secp256r1ECDSA, Secp256k1Taproot); others -> TSS_ERR_PROTO_MISMATCH. +TssStatus tss_handle_chain_code(TssHandle handle, TssBuffer *out); TssStatus tss_handle_pubkey_package(TssHandle handle, TssBuffer *out); uint8_t tss_handle_ciphersuite(TssHandle handle); @@ -338,6 +341,17 @@ TssStatus tss_refresh_receiver( TssHandle *out_session ); +/// Create a DKLs23 complete refresh session (secp256k1 shares only). +/// Every party of the group runs it under the same fresh session_id and +/// advances with tss_refresh_next three times. key_share stays valid; the +/// completed session yields a new handle. tss_refresh_new keeps returning +/// TSS_ERR_PROTO_MISMATCH for DKLs23 shares; this is the DKLs23 entry point. +TssStatus tss_refresh_new_dkls( + TssHandle key_share, + const uint8_t *session_id, size_t session_id_len, + TssHandle *out_session, TssBuffer *out_messages +); + /// Advance the refresh session with received messages. TssStatus tss_refresh_next( TssHandle session, @@ -429,6 +443,38 @@ TssStatus tss_frost_repair_part3( TssHandle *out_key ); +// --- Share Repair (DKLs23 secp256k1) --- + +/// Helper side: this party's masked term for the target's lost share. +/// helpers is the full helper set (self included, target excluded) and must +/// be identical on every helper, as must session_id. out_term is a 32-byte +/// scalar to unicast to the target (free with tss_buffer_free). A share with +/// no zero-share pairing to some helper (a hollow share) returns +/// TSS_ERR_INVALID_SHARE instead of an unmasked term. +TssStatus tss_repair_helper_term( + TssHandle key_share, + const uint16_t *helpers, size_t helpers_len, + uint16_t target, + const uint8_t *session_id, size_t session_id_len, + TssBuffer *out_term +); + +/// Target side: sum at least min_signers terms into a hollow share. +/// pubkey_package is the serialized group package and chain_code its 32-byte +/// BIP-32 chain code, as the helpers report them. G·Σterms is checked against +/// the target's verifying share; a mismatch returns TSS_ERR_INVALID_SHARE and +/// creates no handle. The result cannot sign (tss_sign_new returns +/// TSS_ERR_INVALID_SHARE) until tss_refresh_new_dkls has run across the group. +TssStatus tss_repair_assemble( + uint8_t suite, + TssSlice pubkey_package, + TssSlice chain_code, + uint16_t target, + const TssSlice *terms, size_t terms_len, + const uint8_t *session_id, size_t session_id_len, + TssHandle *out_key_share +); + // ============================================================ // Verification & Key Management // ============================================================ @@ -439,6 +485,11 @@ bool tss_verify(uint8_t suite, TssSlice message, TssSlice signature, TssSlice pu TssStatus tss_derive_child(TssHandle key_share, uint32_t child_number, TssHandle *out); TssStatus tss_derive_path(TssHandle key_share, const char *path, TssHandle *out); +/// Scalar tweaks on DKLs23 shares: k' = k + tweak, k' = k * factor (mod n). +/// 32-byte big-endian scalars reduced mod n; DKLs23 suites only. +TssStatus tss_tweak_add(TssHandle key_share, const uint8_t *tweak, size_t tweak_len, TssHandle *out_handle); +TssStatus tss_tweak_mul(TssHandle key_share, const uint8_t *factor, size_t factor_len, TssHandle *out_handle); + // --- Export / Import --- TssStatus tss_handle_export(TssHandle handle, TssBuffer *out); TssStatus tss_handle_import(const uint8_t *data, size_t data_len, uint8_t suite, TssHandle *out_handle); @@ -454,13 +505,14 @@ const char* tss_version(void); | Category | Functions | Notes | |----------|-----------|-------| | Initialization | 1 | `tss_init` | -| Unified sessions | 8 | dkg (2) + sign (2) + refresh (2) + refresh_receiver (1) + session_free (1) | +| Unified sessions | 9 | dkg (2) + sign (2) + refresh (2) + refresh_receiver (1) + refresh_new_dkls (1) + session_free (1) | | Message helpers | 3 | count, at, build | | FROST-specific | 9 | aggregate, tweak (2), dealer (2), refresh (2), repair (3) | -| Verification + key mgmt | 5 | verify, derive (2), export, import | -| Handle + memory | 8 | handle queries (4) + handle_free + buffer_free + session_free + version | +| DKLs23-specific | 2 | repair (helper_term, assemble) | +| Verification + key mgmt | 7 | verify, derive (2), tweak (2), export, import | +| Handle + memory | 9 | handle queries (5) + handle_free + buffer_free + session_free + version | | Error | 6 | last_error, last_error_len, last_error_copy, culprit_count, culprit, banned_party | -| **Total** | **~40** | **Core unified path: 11 functions (sessions + messages)** | +| **Total** | **~43** | **Core unified path: 11 functions (sessions + messages)** | The core DKG + signing workflow uses only 7 functions: `tss_dkg_new`, `tss_dkg_next`, `tss_sign_new`, `tss_sign_next`, `tss_message_count`, `tss_message_at`, `tss_message_build`. diff --git a/spec/07-key-management.md b/spec/07-key-management.md index 59fc680..b482386 100644 --- a/spec/07-key-management.md +++ b/spec/07-key-management.md @@ -95,7 +95,7 @@ fn frost_derive_child(key_handle: u64, child_number: u32) -> HandleResult { // Compute BIP-32 tweak let parent_pubkey = key_package.verifying_key().serialize(); - let chain_code = get_chain_code(key_handle)?; + let chain_code = key_handle.chain_code()?; let (il, ir) = hmac_sha512(&chain_code, &[&parent_pubkey, &child_number.to_be_bytes()]); let delta = Scalar::deserialize(&il)?; @@ -151,6 +151,21 @@ The `Party::derive_child` method: | Hardened derivation | Not supported | Requires MPC hash (future work) | | Path format | `m/N/N/N/...` | Standard BIP-32 path notation | +### Exposing the Chain Code (xpub Construction) + +`KeyShareHandle::chain_code()` (C ABI `tss_handle_chain_code`) returns the 32-byte +chain code paired with `group_verifying_key()`. Wrapping the two into a BIP-32 +extended public key lets a host derive child public keys off-MPC that match the +child shares every party derives with `derive_child` / `derive_path`. + +| Ciphersuite | Chain code | Agreement with an external CKDpub | +|-------------|------------|-----------------------------------| +| Secp256k1ECDSA / Secp256r1ECDSA (DKLs23) | Established at DKG; children carry the BIP-32 `IR` | Any depth | +| Secp256k1Taproot (FROST) | `SHA-256(group verifying key)`, re-derived from each child's key | Depth 1 only — FROST derivation never carries `IR` (follow-up) | +| All others | Not available | `ProtocolMismatch` — these suites have no BIP-32 derivation | + +The chain code is public material; exposing it reveals nothing about the shares. + ### Security Note: Derivation + Refresh Deriving a child key and then refreshing its shares is safe but has a subtlety: @@ -159,6 +174,49 @@ related by the same additive tweak. This is expected and does not affect securit Recommendation: derive keys as needed, refresh the master (root) shares periodically. +## Scalar Tweaks (Lightning) + +Lightning derives per-commitment keys from caller-supplied scalars rather than +BIP-32 indices: LND's single tweak is `k' = k + t (mod n)` and its revocation +("double") tweak composes `k' = k * a (mod n)` with an additive step. libtss +exposes both on DKLs23 shares: + +```rust +pub fn tweak_add(key_share: &KeyShareHandle, tweak: &[u8]) -> Result; +pub fn tweak_mul(key_share: &KeyShareHandle, factor: &[u8]) -> Result; +``` + +```c +TssStatus tss_tweak_add(TssHandle key_share, const uint8_t *tweak, size_t tweak_len, TssHandle *out_handle); +TssStatus tss_tweak_mul(TssHandle key_share, const uint8_t *factor, size_t factor_len, TssHandle *out_handle); +``` + +Every party applies the same tweak to its share and the public key package is +moved by the same map (group key and every verifying share). Because Lagrange +coefficients sum to one, `Σ lᵢ·(xᵢ + t) = sk + t` and `Σ lᵢ·(a·xᵢ) = a·sk`, so +the tweaked shares sign under the tweaked group key. The DKLs23 base-OT +correlations, zero-share seeds and multiplication state never depend on the +share value — the same property BIP-32 derivation relies on — and are reused +unchanged. The map itself is DKLs23's (`Party::tweak_add` / `tweak_mul` and the +matching `PublicKeyPackage` methods); libtss parses the scalar, picks the +address function and wraps the result in a new handle. + +| Field | After `tweak_add(t)` | After `tweak_mul(a)` | +|-------|----------------------|----------------------| +| `poly_point`, `derivation_data.poly_point` | `x + t` | `a·x` | +| `pk`, `derivation_data.pk`, group key, verifying shares | `P + t·G` | `a·P` | +| `address` | recomputed | recomputed | +| `chain_code`, `depth`, `child_number`, `parent_fingerprint`, `session_id`, OT state | unchanged | unchanged | + +Rules: scalars are 32-byte big-endian and reduced mod n (btcec `ModNScalar` +semantics, so LND tweaks round-trip); a factor that reduces to zero or a result +equal to the identity point is rejected with `TweakError`; wrong-length input is +`InvalidConfig`; non-DKLs23 suites return `ProtocolMismatch`. All signers must +apply the same public tweak before a signing session — a mismatch aborts +recoverably in signing phase 3. A tweak is not a BIP-32 edge: the derivation +metadata still describes the un-tweaked ancestor, and `derive_child` on a +tweaked share is a further additive shift under the unchanged chain code. + ## Share Refresh Share refresh replaces all participants' shares with new shares of the same secret key. @@ -211,9 +269,30 @@ pub fn frost_apply_refresh( ### DKLs23 Complete Refresh -DKLs23 refresh uses `RefreshSession::new`, which re-runs the full DKG -(including OT re-initialization). This is the recommended mode when OT -state may have been partially compromised. +DKLs23 refresh uses `RefreshSession::new_dkls(key_share, session_id)` (C ABI: +`tss_refresh_new_dkls`), which re-runs DKG with a zero-constant polynomial and +re-initializes the OT and zero-share state from scratch. Three message rounds +follow the same shape as DKG phases 2–4, minus the BIP-32 derivation +broadcasts; the chain code is carried over unchanged. dkls23's +`refresh_complete_phase4` returns only the refreshed `Party`, so libtss rebuilds +the public key package itself: every verifying share moves by its party's +correction point (`share'(j) = share(j) + G·correction_j`, read from the +broadcast proof commitments) while the group key stays put, and the result is +self-checked against the new share before a handle is created. + +Two properties bind callers: + +- **All `n` parties must refresh together under one fresh `session_id`.** The + refreshed party records it as its session id, and signing salts the + two-party multiplication transcript with it, so pre- and post-refresh shares + never co-sign. A partial refresh leaves the group unsignable until the + stragglers are repaired from the majority generation and refreshed again. +- **Only secp256k1 shares are supported.** FROST and secp256r1 shares return + `ProtocolMismatch`; a hollow share from [`repair_assemble`](#dkls23-share-repair) + is accepted — turning it back into a signing share is the point. + +This is also the recommended mode when OT state may have been partially +compromised. ### FROST Dealer/Receiver Refresh @@ -287,14 +366,46 @@ pub fn frost_repair_part3( ### DKLs23 Share Repair -DKLs23 does not have a built-in repair mechanism equivalent to FROST's RTS. For DKLs23, -share recovery requires: +dkls23 has no built-in repair; libtss implements one for secp256k1 as a +two-step recovery (`repair_helper_term` / `repair_assemble`, C ABI +`tss_repair_helper_term` / `tss_repair_assemble`) followed by a complete +refresh. The OT correlations in a `Party` cannot be reconstructed by helpers, +so repair rebuilds only the *secret share* and leaves the refresh to rebuild +the rest. + +**Step 1 — helpers (one round, unicast to the target).** A set of at least +`t` surviving parties agree on the helper set and a fresh `repair_sid`. Each +helper `i` sends the target -1. Re-running the complete DKG with the recovering party -2. Or using the re-key function with the reconstructed secret (requires trusted entity) +```text +term_i = poly_point_i · λ_i(target) + ZeroShare::compute(helpers \ {i}, "libtss/dkls23-repair/v1" || repair_sid) +``` -This is a limitation of the OT-based approach where the Party state includes OT -correlations that cannot be independently reconstructed. +`λ_i(target)` is the Lagrange coefficient over the helper indices, so the +unmasked terms sum to `f(target)`. The masks are the same pairwise zero-share +seeds signing uses, keyed by a domain-separated session id, and sum to zero +over the helper set — no new key material and no mask exchange. Any proper +subset of the terms is uniformly random, so no helper learns the share. +`repair_helper_term` fails closed with `InvalidShare` if the caller lacks a +seed pair with any helper counterparty (`ZeroShare::compute` would otherwise +skip it silently and leave `poly_point · λ` unmasked). + +**Step 2 — target.** `repair_assemble` sums the terms, checks +`G · Σ terms == verifying_share(target)` from the helpers' public key package +(`InvalidShare` otherwise — no handle is created) and builds a *hollow* +`Party`: correct `poly_point`, `pk`, address and root-level derivation data +(`depth 0`, the group chain code), but an empty `ZeroShare` and empty +multiplication maps. The handle passes import validation, exports, derives +addresses and refreshes; `SignSession::new_dkls` refuses it with +`InvalidShare` and it cannot act as a repair helper. + +**Step 3 — complete refresh across all `n`.** `RefreshSession::new_dkls` +rebuilds the OT and zero-share state for every party and re-randomises every +share under the unchanged group key. Only after this can the repaired party +sign. + +Only master (depth-0) shares are repaired; derived shares are re-derived from +the refreshed master, per dkls23's own guidance on refreshing derivations. ## Key Share Persistence diff --git a/spec/10-testing.md b/spec/10-testing.md index d7055ac..39d6887 100644 --- a/spec/10-testing.md +++ b/spec/10-testing.md @@ -73,6 +73,11 @@ cd libtss-ffi && cargo test --test integration | `TestDeriveChild` | BIP-32 child derivation produces correct public key | | `TestDerivePath` | Multi-level path derivation matches reference | | `TestDeriveChild_AllParties` | All parties derive same child public key independently | +| `TestChainCode_DKLs_MatchesBIP32` | Chain code is equal across parties; child chain code / key match an independent CKDpub | +| `TestChainCode_UnsupportedSuite` | `chain_code()` on a non-BIP-32 suite returns `ProtocolMismatch` | +| `TestTweakAdd_SignsUnderTweakedKey` | Additively tweaked DKLs23 shares sign; signature verifies under `pk + t·G` only | +| `TestTweakMul_ByOneIsIdentity` | Multiplicative tweak by one leaves the group key unchanged; zero factor rejected | +| `TestTweak_UnsupportedSuite` | `tweak_add` / `tweak_mul` on a non-DKLs23 suite return `ProtocolMismatch` | | `TestRefresh_FROST` | Share refresh preserves signing ability | | `TestRefresh_DKLs` | Complete and quick refresh preserve signing | | `TestRefresh_OldSharesInvalid` | Old shares cannot produce valid signatures after refresh |