diff --git a/neo.sln b/neo.sln index 3779ae8d78..e1de480f6e 100644 --- a/neo.sln +++ b/neo.sln @@ -1,4 +1,5 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 + +Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.2.32516.85 MinimumVisualStudioVersion = 10.0.40219.1 @@ -88,6 +89,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Neo.Cryptography.MPTTrie.Te EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Neo.Network.RPC.Tests", "tests\Neo.Network.RPC.Tests\Neo.Network.RPC.Tests.csproj", "{19B1CF1A-17F4-4E04-AB9C-55CE74952E11}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SignClient", "src\Plugins\SignClient\SignClient.csproj", "{CAD55942-48A3-4526-979D-7519FADF19FE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Neo.Plugins.SignClient.Tests", "tests\Neo.Plugins.SignClient.Tests\Neo.Plugins.SignClient.Tests.csproj", "{E2CFEAA1-45F2-4075-94ED-866862C6863F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -214,6 +219,10 @@ Global {8C866DC8-2E55-4399-9563-2F47FD4602EC}.Debug|Any CPU.Build.0 = Debug|Any CPU {8C866DC8-2E55-4399-9563-2F47FD4602EC}.Release|Any CPU.ActiveCfg = Release|Any CPU {8C866DC8-2E55-4399-9563-2F47FD4602EC}.Release|Any CPU.Build.0 = Release|Any CPU + {72997EAB-9B0C-4BC8-B797-955C219C2C97}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {72997EAB-9B0C-4BC8-B797-955C219C2C97}.Debug|Any CPU.Build.0 = Debug|Any CPU + {72997EAB-9B0C-4BC8-B797-955C219C2C97}.Release|Any CPU.ActiveCfg = Release|Any CPU + {72997EAB-9B0C-4BC8-B797-955C219C2C97}.Release|Any CPU.Build.0 = Release|Any CPU {77FDEE2E-9381-4BFC-B9E6-741EDBD6B90F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {77FDEE2E-9381-4BFC-B9E6-741EDBD6B90F}.Debug|Any CPU.Build.0 = Debug|Any CPU {77FDEE2E-9381-4BFC-B9E6-741EDBD6B90F}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -226,10 +235,6 @@ Global {5F984D2B-793F-4683-B53A-80050E6E0286}.Debug|Any CPU.Build.0 = Debug|Any CPU {5F984D2B-793F-4683-B53A-80050E6E0286}.Release|Any CPU.ActiveCfg = Release|Any CPU {5F984D2B-793F-4683-B53A-80050E6E0286}.Release|Any CPU.Build.0 = Release|Any CPU - {72997EAB-9B0C-4BC8-B797-955C219C2C97}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {72997EAB-9B0C-4BC8-B797-955C219C2C97}.Debug|Any CPU.Build.0 = Debug|Any CPU - {72997EAB-9B0C-4BC8-B797-955C219C2C97}.Release|Any CPU.ActiveCfg = Release|Any CPU - {72997EAB-9B0C-4BC8-B797-955C219C2C97}.Release|Any CPU.Build.0 = Release|Any CPU {9ADB4E11-8655-42C2-8A75-E4436F56F17A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9ADB4E11-8655-42C2-8A75-E4436F56F17A}.Debug|Any CPU.Build.0 = Debug|Any CPU {9ADB4E11-8655-42C2-8A75-E4436F56F17A}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -246,6 +251,14 @@ Global {19B1CF1A-17F4-4E04-AB9C-55CE74952E11}.Debug|Any CPU.Build.0 = Debug|Any CPU {19B1CF1A-17F4-4E04-AB9C-55CE74952E11}.Release|Any CPU.ActiveCfg = Release|Any CPU {19B1CF1A-17F4-4E04-AB9C-55CE74952E11}.Release|Any CPU.Build.0 = Release|Any CPU + {CAD55942-48A3-4526-979D-7519FADF19FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CAD55942-48A3-4526-979D-7519FADF19FE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CAD55942-48A3-4526-979D-7519FADF19FE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CAD55942-48A3-4526-979D-7519FADF19FE}.Release|Any CPU.Build.0 = Release|Any CPU + {E2CFEAA1-45F2-4075-94ED-866862C6863F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E2CFEAA1-45F2-4075-94ED-866862C6863F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E2CFEAA1-45F2-4075-94ED-866862C6863F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E2CFEAA1-45F2-4075-94ED-866862C6863F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -282,14 +295,16 @@ Global {FF76D8A4-356B-461A-8471-BC1B83E57BBC} = {C2DC830A-327A-42A7-807D-295216D30DBB} {5E4947F3-05D3-4806-B0F3-30DAC71B5986} = {C2DC830A-327A-42A7-807D-295216D30DBB} {8C866DC8-2E55-4399-9563-2F47FD4602EC} = {7F257712-D033-47FF-B439-9D4320D06599} + {72997EAB-9B0C-4BC8-B797-955C219C2C97} = {7F257712-D033-47FF-B439-9D4320D06599} {77FDEE2E-9381-4BFC-B9E6-741EDBD6B90F} = {EDE05FA8-8E73-4924-BC63-DD117127EEE1} {B6CB2559-10F9-41AC-8D58-364BFEF9688B} = {C25EB0B0-0CAC-4CC1-8F36-F9229EFB99EC} {5F984D2B-793F-4683-B53A-80050E6E0286} = {C25EB0B0-0CAC-4CC1-8F36-F9229EFB99EC} - {72997EAB-9B0C-4BC8-B797-955C219C2C97} = {7F257712-D033-47FF-B439-9D4320D06599} {9ADB4E11-8655-42C2-8A75-E4436F56F17A} = {B5339DF7-5D1D-43BA-B332-74B825E1770E} {E384C5EF-493E-4ED6-813C-6364F968CEE8} = {B5339DF7-5D1D-43BA-B332-74B825E1770E} {40A23D45-1E81-41A4-B587-16AF26630103} = {EDE05FA8-8E73-4924-BC63-DD117127EEE1} {19B1CF1A-17F4-4E04-AB9C-55CE74952E11} = {EDE05FA8-8E73-4924-BC63-DD117127EEE1} + {CAD55942-48A3-4526-979D-7519FADF19FE} = {C2DC830A-327A-42A7-807D-295216D30DBB} + {E2CFEAA1-45F2-4075-94ED-866862C6863F} = {7F257712-D033-47FF-B439-9D4320D06599} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {BCBA19D9-F868-4C6D-8061-A2B91E06E3EC} diff --git a/src/Neo/Sign/SignException.cs b/src/Neo/Sign/SignException.cs index 36bb8cc504..e734b6fb66 100644 --- a/src/Neo/Sign/SignException.cs +++ b/src/Neo/Sign/SignException.cs @@ -22,6 +22,7 @@ public class SignException : Exception /// Initializes a new instance of the class. /// /// The message that describes the error. - public SignException(string message) : base(message) { } + /// The cause of the exception. + public SignException(string message, Exception cause = null) : base(message, cause) { } } } diff --git a/src/Plugins/DBFTPlugin/Consensus/ConsensusContext.MakePayload.cs b/src/Plugins/DBFTPlugin/Consensus/ConsensusContext.MakePayload.cs index 769174ffa5..9849082a87 100644 --- a/src/Plugins/DBFTPlugin/Consensus/ConsensusContext.MakePayload.cs +++ b/src/Plugins/DBFTPlugin/Consensus/ConsensusContext.MakePayload.cs @@ -14,7 +14,6 @@ using Neo.Network.P2P.Payloads; using Neo.Plugins.DBFTPlugin.Messages; using Neo.Plugins.DBFTPlugin.Types; -using Neo.Sign; using System; using System.Buffers.Binary; using System.Collections.Generic; diff --git a/src/Plugins/SignClient/Settings.cs b/src/Plugins/SignClient/Settings.cs new file mode 100644 index 0000000000..7660c76e87 --- /dev/null +++ b/src/Plugins/SignClient/Settings.cs @@ -0,0 +1,55 @@ +// Copyright (C) 2015-2025 The Neo Project. +// +// Settings.cs file belongs to the neo project and is free +// software distributed under the MIT software license, see the +// accompanying file LICENSE in the main directory of the +// repository or http://www.opensource.org/licenses/mit-license.php +// for more details. +// +// Redistribution and use in source and binary forms with or without +// modifications are permitted. + +using Microsoft.Extensions.Configuration; + +namespace Neo.Plugins.SignClient +{ + public class Settings : PluginSettings + { + public const string SectionName = "PluginConfiguration"; + private const string DefaultEndpoint = "http://127.0.0.1:9991"; + + /// + /// The name of the sign client(i.e. Signer). + /// + public readonly string Name; + + /// + /// The host of the sign client(i.e. Signer). + /// + public readonly string Endpoint; + + public Settings(IConfigurationSection section) : base(section) + { + Name = section.GetValue("Name", "SignClient"); + + // Only support local host at present, so host always is "127.0.0.1" or "::1" now. + Endpoint = section.GetValue("Endpoint", DefaultEndpoint); + } + + public static Settings Default + { + get + { + var section = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [SectionName + ":Name"] = "SignClient", + [SectionName + ":Endpoint"] = DefaultEndpoint + }) + .Build() + .GetSection(SectionName); + return new Settings(section); + } + } + } +} diff --git a/src/Plugins/SignClient/SignClient.cs b/src/Plugins/SignClient/SignClient.cs new file mode 100644 index 0000000000..8667aaba5d --- /dev/null +++ b/src/Plugins/SignClient/SignClient.cs @@ -0,0 +1,333 @@ +// Copyright (C) 2015-2025 The Neo Project. +// +// SignClient.cs file belongs to the neo project and is free +// software distributed under the MIT software license, see the +// accompanying file LICENSE in the main directory of the +// repository or http://www.opensource.org/licenses/mit-license.php +// for more details. +// +// Redistribution and use in source and binary forms with or without +// modifications are permitted. + +using Google.Protobuf; +using Grpc.Core; +using Grpc.Net.Client; +using Grpc.Net.Client.Configuration; +using Neo.ConsoleService; +using Neo.Cryptography.ECC; +using Neo.Extensions; +using Neo.Network.P2P.Payloads; +using Neo.Persistence; +using Neo.Sign; +using Neo.SmartContract; +using Servicepb; +using Signpb; +using System.Diagnostics.CodeAnalysis; +using static Neo.SmartContract.Helper; + +using ExtensiblePayload = Neo.Network.P2P.Payloads.ExtensiblePayload; + +namespace Neo.Plugins.SignClient +{ + /// + /// A signer that uses a client to sign transactions. + /// + public class SignClient : Plugin, ISigner + { + private GrpcChannel? _channel; + + private SecureSign.SecureSignClient? _client; + + private string _name = string.Empty; + + public override string Description => "Signer plugin for signer service."; + + public override string ConfigFile => System.IO.Path.Combine(RootPath, "SignClient.json"); + + public SignClient() { } + + public SignClient(Settings settings) + { + Reset(settings); + } + + // It's for test now. + internal SignClient(string name, SecureSign.SecureSignClient client) + { + Reset(name, client); + } + + private void Reset(string name, SecureSign.SecureSignClient? client) + { + if (_client is not null) SignerManager.UnregisterSigner(_name); + + _name = name; + _client = client; + if (!string.IsNullOrEmpty(_name)) SignerManager.RegisterSigner(_name, this); + } + + private void Reset(Settings settings) + { + // _settings = settings; + var methodConfig = new MethodConfig + { + Names = { MethodName.Default }, + RetryPolicy = new RetryPolicy + { + MaxAttempts = 3, + InitialBackoff = TimeSpan.FromMilliseconds(50), + MaxBackoff = TimeSpan.FromMilliseconds(200), + BackoffMultiplier = 1.5, + RetryableStatusCodes = { + StatusCode.Cancelled, + StatusCode.DeadlineExceeded, + StatusCode.ResourceExhausted, + StatusCode.Unavailable, + StatusCode.Aborted, + StatusCode.Internal, + StatusCode.DataLoss, + StatusCode.Unknown + } + } + }; + + var channel = GrpcChannel.ForAddress(settings.Endpoint, new GrpcChannelOptions + { + ServiceConfig = new ServiceConfig { MethodConfigs = { methodConfig } } + }); + + _channel?.Dispose(); + _channel = channel; + Reset(settings.Name, new SecureSign.SecureSignClient(_channel)); + } + + /// + /// Get account status command + /// + /// The hex public key, compressed or uncompressed + [ConsoleCommand("get account status", Category = "Signer Commands", Description = "Get account status")] + public void AccountStatusCommand(string hexPublicKey) + { + if (_client is null) + { + ConsoleHelper.Error("No signer service is connected"); + return; + } + + try + { + var publicKey = ECPoint.DecodePoint(hexPublicKey.HexToBytes(), ECCurve.Secp256r1); + var output = _client.GetAccountStatus(new() + { + PublicKey = ByteString.CopyFrom(publicKey.EncodePoint(true)) + }); + ConsoleHelper.Info($"Account status: {output.Status}"); + } + catch (RpcException rpcEx) + { + var message = rpcEx.StatusCode == StatusCode.Unavailable ? + "No available signer service" : + $"Failed to get account status: {rpcEx.StatusCode}: {rpcEx.Status.Detail}"; + ConsoleHelper.Error(message); + } + catch (FormatException formatEx) + { + ConsoleHelper.Error($"Invalid public key: {formatEx.Message}"); + } + } + + private AccountStatus GetAccountStatus(ECPoint publicKey) + { + if (_client is null) throw new SignException("No signer service is connected"); + + try + { + var output = _client.GetAccountStatus(new() + { + PublicKey = ByteString.CopyFrom(publicKey.EncodePoint(true)) + }); + return output.Status; + } + catch (RpcException ex) + { + throw new SignException($"Get account status: {ex.Status}", ex); + } + } + + /// + /// Check if the account is signable + /// + /// The public key + /// True if the account is signable, false otherwise + /// If no signer service is available, or other rpc error occurs. + public bool ContainsSignable(ECPoint publicKey) + { + var status = GetAccountStatus(publicKey); + return status == AccountStatus.Single || status == AccountStatus.Multiple; + } + + private static bool TryDecodePublicKey(ByteString publicKey, [NotNullWhen(true)] out ECPoint? point) + { + try + { + point = ECPoint.DecodePoint(publicKey.Span, ECCurve.Secp256r1); + } + catch (FormatException) // add log later + { + point = null; + } + return point is not null; + } + + internal Witness[] SignContext(ContractParametersContext context, IEnumerable signs) + { + var succeed = false; + foreach (var (accountSigns, scriptHash) in signs.Zip(context.ScriptHashes)) + { + var accountStatus = accountSigns.Status; + if (accountStatus == AccountStatus.NoSuchAccount || accountStatus == AccountStatus.NoPrivateKey) + { + succeed |= context.AddWithScriptHash(scriptHash); // Same as Wallet.Sign(context) + continue; + } + + var contract = accountSigns.Contract; + var accountContract = Contract.Create( + contract?.Parameters?.Select(p => (ContractParameterType)p).ToArray() ?? [], + contract?.Script?.ToByteArray() ?? []); + if (accountStatus == AccountStatus.Multiple) + { + if (!IsMultiSigContract(accountContract.Script, out int m, out ECPoint[] publicKeys)) + throw new SignException("Sign context: multi-sign account but not multi-sign contract"); + + foreach (var sign in accountSigns.Signs) + { + if (!TryDecodePublicKey(sign.PublicKey, out var publicKey)) continue; + + if (!publicKeys.Contains(publicKey)) + throw new SignException($"Sign context: public key {publicKey} not in multi-sign contract"); + + var ok = context.AddSignature(accountContract, publicKey, sign.Signature.ToByteArray()); + if (ok) m--; + + succeed |= ok; + if (context.Completed || m <= 0) break; + } + } + else if (accountStatus == AccountStatus.Single) + { + if (accountSigns.Signs is null || accountSigns.Signs.Count != 1) + throw new SignException($"Sign context: single account but {accountSigns.Signs?.Count} signs"); + + var sign = accountSigns.Signs[0]; + if (!TryDecodePublicKey(sign.PublicKey, out var publicKey)) continue; + succeed |= context.AddSignature(accountContract, publicKey, sign.Signature.ToByteArray()); + } + } + + if (!succeed) throw new SignException("Sign context: failed to sign"); + return context.GetWitnesses(); + } + + /// + /// Signs the with the signer. + /// + /// The payload to sign + /// The data cache + /// The network + /// The witnesses + /// If no signer service is available, or other rpc error occurs. + public Witness SignExtensiblePayload(ExtensiblePayload payload, DataCache dataCache, uint network) + { + if (_client is null) throw new SignException("No signer service is connected"); + + try + { + var context = new ContractParametersContext(dataCache, payload, network); + var output = _client.SignExtensiblePayload(new() + { + Payload = new() + { + Category = payload.Category, + ValidBlockStart = payload.ValidBlockStart, + ValidBlockEnd = payload.ValidBlockEnd, + Sender = ByteString.CopyFrom(payload.Sender.GetSpan()), + Data = ByteString.CopyFrom(payload.Data.Span), + }, + ScriptHashes = { context.ScriptHashes.Select(h160 => ByteString.CopyFrom(h160.GetSpan())) }, + Network = network, + }); + + int signCount = output.Signs.Count, hashCount = context.ScriptHashes.Count; + if (signCount != hashCount) + { + throw new SignException($"Sign context: Signs.Count({signCount}) != Hashes.Count({hashCount})"); + } + + return SignContext(context, output.Signs)[0]; + } + catch (RpcException ex) + { + throw new SignException($"Sign context: {ex.Status}", ex); + } + } + + /// + /// Signs the specified data with the corresponding private key of the specified public key. + /// + /// The block to sign + /// The public key + /// The network + /// The signature + /// If no signer service is available, or other rpc error occurs. + public ReadOnlyMemory SignBlock(Block block, ECPoint publicKey, uint network) + { + if (_client is null) throw new SignException("No signer service is connected"); + + try + { + var output = _client.SignBlock(new() + { + Block = new() + { + Header = new() + { + Version = block.Version, + PrevHash = ByteString.CopyFrom(block.PrevHash.GetSpan()), + MerkleRoot = ByteString.CopyFrom(block.MerkleRoot.GetSpan()), + Timestamp = block.Timestamp, + Nonce = block.Nonce, + Index = block.Index, + PrimaryIndex = block.PrimaryIndex, + NextConsensus = ByteString.CopyFrom(block.NextConsensus.GetSpan()), + }, + TxHashes = { block.Transactions.Select(tx => ByteString.CopyFrom(tx.Hash.GetSpan())) }, + }, + PublicKey = ByteString.CopyFrom(publicKey.EncodePoint(true)), + Network = network, + }); + + return output.Signature.Memory; + } + catch (RpcException ex) + { + throw new SignException($"Sign with public key: {ex.Status}", ex); + } + } + + /// + protected override void Configure() + { + var config = GetConfiguration(); + if (config is not null) Reset(new Settings(config)); + } + + /// + public override void Dispose() + { + Reset(string.Empty, null); + _channel?.Dispose(); + base.Dispose(); + } + } +} diff --git a/src/Plugins/SignClient/SignClient.csproj b/src/Plugins/SignClient/SignClient.csproj new file mode 100644 index 0000000000..dce5c80e39 --- /dev/null +++ b/src/Plugins/SignClient/SignClient.csproj @@ -0,0 +1,34 @@ + + + + net9.0 + latest + enable + enable + + + + + PreserveNewest + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Plugins/SignClient/SignClient.json b/src/Plugins/SignClient/SignClient.json new file mode 100644 index 0000000000..dd9be7eb3e --- /dev/null +++ b/src/Plugins/SignClient/SignClient.json @@ -0,0 +1,6 @@ +{ + "PluginConfiguration": { + "Name": "SignClient", + "Endpoint": "http://127.0.0.1:9991" + } +} \ No newline at end of file diff --git a/src/Plugins/SignClient/proto/servicepb.proto b/src/Plugins/SignClient/proto/servicepb.proto new file mode 100644 index 0000000000..edc836b8dc --- /dev/null +++ b/src/Plugins/SignClient/proto/servicepb.proto @@ -0,0 +1,64 @@ +// Copyright (C) 2015-2025 The Neo Project. + // + // servicepb.proto file belongs to the neo project and is free + // software distributed under the MIT software license, see the + // accompanying file LICENSE in the main directory of the + // repository or http://www.opensource.org/licenses/mit-license.php + // for more details. + // + // Redistribution and use in source and binary forms with or without + // modifications are permitted. + +syntax = "proto3"; + +import "signpb.proto"; + +package servicepb; + +message SignExtensiblePayloadRequest { + // the payload to be signed + signpb.ExtensiblePayload payload = 1; + + // script hashes, H160 list + repeated bytes script_hashes = 2; + + // the network id + uint32 network = 3; +} + +message SignExtensiblePayloadResponse { + // script hash -> account signs, one to one mapping + repeated signpb.AccountSigns signs = 1; +} + +message SignBlockRequest { + // the block header to be signed + signpb.TrimmedBlock block = 1; + + // compressed or uncompressed public key + bytes public_key = 2; + + // the network id + uint32 network = 3; +} + +message SignBlockResponse { + bytes signature = 1; +} + +message GetAccountStatusRequest { + // compressed or uncompressed public key + bytes public_key = 1; +} + +message GetAccountStatusResponse { + signpb.AccountStatus status = 1; +} + +service SecureSign { + rpc SignExtensiblePayload(SignExtensiblePayloadRequest) returns (SignExtensiblePayloadResponse) {} + + rpc SignBlock(SignBlockRequest) returns (SignBlockResponse) {} + + rpc GetAccountStatus(GetAccountStatusRequest) returns (GetAccountStatusResponse) {} +} diff --git a/src/Plugins/SignClient/proto/signpb.proto b/src/Plugins/SignClient/proto/signpb.proto new file mode 100644 index 0000000000..5d73521901 --- /dev/null +++ b/src/Plugins/SignClient/proto/signpb.proto @@ -0,0 +1,92 @@ +// Copyright (C) 2015-2025 The Neo Project. + // + // signpb.proto file belongs to the neo project and is free + // software distributed under the MIT software license, see the + // accompanying file LICENSE in the main directory of the + // repository or http://www.opensource.org/licenses/mit-license.php + // for more details. + // + // Redistribution and use in source and binary forms with or without + // modifications are permitted. + +syntax = "proto3"; + +package signpb; + +message BlockHeader { + uint32 version = 1; + bytes prev_hash = 2; // H256 + bytes merkle_root = 3; // H256 + uint64 timestamp = 4; // i.e unix milliseconds + uint64 nonce = 5; + uint32 index = 6; + uint32 primary_index = 7; + bytes next_consensus = 8; // H160 +} + +message TrimmedBlock { + BlockHeader header = 1; + repeated bytes tx_hashes = 2; // H256 list, tx hash list +} + +message ExtensiblePayload { + string category = 1; + uint32 valid_block_start = 2; + uint32 valid_block_end = 3; + bytes sender = 4; // H160 + bytes data = 5; +} + +message AccountSign { + // the signature + bytes signature = 1; + + // the compressed or uncompressed public key + bytes public_key = 2; +} + +message AccountContract { + // the contract script + bytes script = 1; + + // the contract parameters + repeated uint32 parameters = 2; + + // if the contract is deployed + bool deployed = 3; +} + +enum AccountStatus { + /// no such account + NoSuchAccount = 0; + + /// no private key + NoPrivateKey = 1; + + /// single sign + Single = 2; + + /// multiple signs, aka. multisig + Multiple = 3; + + /// this key-pair is locked + Locked = 4; +} + +message AccountSigns { + // if the status is Single, there is only one sign + // if the status is Multiple, there are multiple signs + // if the status is NoSuchAccount, NoPrivateKey or Locked, there are no signs + repeated AccountSign signs = 1; + + // the account contract + // If the account hasn't a contract, the contract is null + AccountContract contract = 2; + + // the account status + AccountStatus status = 3; +} + +message MultiAccountSigns { + repeated AccountSigns signs = 1; +} diff --git a/tests/Neo.Plugins.SignClient.Tests/Neo.Plugins.SignClient.Tests.csproj b/tests/Neo.Plugins.SignClient.Tests/Neo.Plugins.SignClient.Tests.csproj new file mode 100644 index 0000000000..72e838c153 --- /dev/null +++ b/tests/Neo.Plugins.SignClient.Tests/Neo.Plugins.SignClient.Tests.csproj @@ -0,0 +1,21 @@ + + + + Exe + net9.0 + latest + enable + enable + + + + + + + + + + + + + diff --git a/tests/Neo.Plugins.SignClient.Tests/UT_SignClient.cs b/tests/Neo.Plugins.SignClient.Tests/UT_SignClient.cs new file mode 100644 index 0000000000..044be50570 --- /dev/null +++ b/tests/Neo.Plugins.SignClient.Tests/UT_SignClient.cs @@ -0,0 +1,206 @@ +// Copyright (C) 2015-2025 The Neo Project. +// +// UT_SignClient.cs file belongs to the neo project and is free +// software distributed under the MIT software license, see the +// accompanying file LICENSE in the main directory of the +// repository or http://www.opensource.org/licenses/mit-license.php +// for more details. +// +// Redistribution and use in source and binary forms with or without +// modifications are permitted. + +using Google.Protobuf; +using Grpc.Core; +using Microsoft.Extensions.Configuration; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using Neo.Cryptography; +using Neo.Cryptography.ECC; +using Neo.Extensions; +using Neo.Network.P2P; +using Neo.Network.P2P.Payloads; +using Neo.Persistence; +using Neo.Persistence.Providers; +using Neo.Sign; +using Neo.SmartContract; +using Neo.UnitTests; +using Neo.Wallets; +using Servicepb; +using Signpb; + +using ExtensiblePayload = Neo.Network.P2P.Payloads.ExtensiblePayload; + +namespace Neo.Plugins.SignClient.Tests +{ + [TestClass] + public class UT_SignClient + { + const string PrivateKey = "0101010101010101010101010101010101010101010101010101010101010101"; + const string PublicKey = "026ff03b949241ce1dadd43519e6960e0a85b41a69a05c328103aa2bce1594ca16"; + + private static readonly uint s_testNetwork = TestProtocolSettings.Default.Network; + + private static readonly ECPoint s_publicKey = ECPoint.DecodePoint(PublicKey.HexToBytes(), ECCurve.Secp256r1); + + private static SignClient NewClient(Block? block, ExtensiblePayload? payload) + { + // for test sign service, set SIGN_SERVICE_ENDPOINT env + var endpoint = Environment.GetEnvironmentVariable("SIGN_SERVICE_ENDPOINT"); + if (endpoint is not null) + { + var section = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [Settings.SectionName + ":Name"] = "SignClient", + [Settings.SectionName + ":Endpoint"] = endpoint + }) + .Build() + .GetSection(Settings.SectionName); + return new SignClient(new Settings(section)); + } + + var mockClient = new Mock(); + + // setup GetAccountStatus + mockClient.Setup(c => c.GetAccountStatus( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()) + ) + .Returns((req, _, _, _) => + { + if (req.PublicKey.ToByteArray().ToHexString() == PublicKey) + return new() { Status = AccountStatus.Single }; + return new() { Status = AccountStatus.NoSuchAccount }; + }); + + // setup SignBlock + mockClient.Setup(c => c.SignBlock( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()) + ) + .Returns((req, _, _, _) => + { + if (req.PublicKey.ToByteArray().ToHexString() == PublicKey) + { + var sign = Crypto.Sign(block.GetSignData(s_testNetwork), PrivateKey.HexToBytes(), ECCurve.Secp256r1); + return new() { Signature = ByteString.CopyFrom(sign) }; + } + throw new RpcException(new Status(StatusCode.NotFound, "no such account")); + }); + + // setup SignExtensiblePayload + mockClient.Setup(c => c.SignExtensiblePayload( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()) + ) + .Returns((req, _, _, _) => + { + var script = Contract.CreateSignatureRedeemScript(s_publicKey); + var res = new SignExtensiblePayloadResponse(); + foreach (var scriptHash in req.ScriptHashes) + { + if (scriptHash.ToByteArray().ToHexString() == script.ToScriptHash().GetSpan().ToHexString()) + { + var contract = new AccountContract() { Script = ByteString.CopyFrom(script) }; + contract.Parameters.Add((uint)ContractParameterType.Signature); + + var sign = Crypto.Sign(payload.GetSignData(s_testNetwork), PrivateKey.HexToBytes(), ECCurve.Secp256r1); + var signs = new AccountSigns() { Status = AccountStatus.Single, Contract = contract }; + signs.Signs.Add(new AccountSign() + { + PublicKey = ByteString.CopyFrom(s_publicKey.EncodePoint(false).ToArray()), + Signature = ByteString.CopyFrom(sign) + }); + + res.Signs.Add(signs); + } + else + { + res.Signs.Add(new AccountSigns() { Status = AccountStatus.NoSuchAccount }); + } + } + return res; + }); + + return new SignClient("TestSignClient", mockClient.Object); + } + + [TestMethod] + public void TestSignBlock() + { + var snapshotCache = TestBlockchain.GetTestSnapshotCache(); + var block = TestUtils.MakeBlock(snapshotCache, UInt256.Zero, 0); + using var signClient = NewClient(block, null); + + // sign with public key + var signature = signClient.SignBlock(block, s_publicKey, s_testNetwork); + Assert.IsNotNull(signature); + + // verify signature + var signData = block.GetSignData(s_testNetwork); + var verified = Crypto.VerifySignature(signData, signature.Span, s_publicKey); + Assert.IsTrue(verified); + + var privateKey = Enumerable.Repeat((byte)0x0f, 32).ToArray(); + var keypair = new KeyPair(privateKey); + + // sign with a not exists private key + var action = () => { _ = signClient.SignBlock(block, keypair.PublicKey, s_testNetwork); }; + Assert.ThrowsExactly(action); + } + + [TestMethod] + public void TestSignExtensiblePayload() + { + var script = Contract.CreateSignatureRedeemScript(s_publicKey); + var signer = script.ToScriptHash(); + var payload = new ExtensiblePayload() + { + Category = "test", + ValidBlockStart = 1, + ValidBlockEnd = 100, + Sender = signer, + Data = new byte[] { 1, 2, 3 }, + }; + using var signClient = NewClient(null, payload); + using var store = new MemoryStore(); + using var snapshot = new StoreCache(store, false); + + var witness = signClient.SignExtensiblePayload(payload, snapshot, s_testNetwork); + Assert.AreEqual(witness.VerificationScript.Span.ToHexString(), script.ToHexString()); + + var signature = witness.InvocationScript[^64..].ToArray(); + var verified = Crypto.VerifySignature(payload.GetSignData(s_testNetwork), signature, s_publicKey); + Assert.IsTrue(verified); + } + + [TestMethod] + public void TestGetAccountStatus() + { + using var signClient = NewClient(null, null); + + // exists + var contains = signClient.ContainsSignable(s_publicKey); + Assert.IsTrue(contains); + + var privateKey = Enumerable.Repeat((byte)0x0f, 32).ToArray(); + var keypair = new KeyPair(privateKey); + + // not exists + contains = signClient.ContainsSignable(keypair.PublicKey); + Assert.IsFalse(contains); + + // exists + signClient.AccountStatusCommand(PublicKey); + + // not exists + signClient.AccountStatusCommand(keypair.PublicKey.EncodePoint(true).ToHexString()); + } + } +}