diff --git a/src/Neo.CLI/CLI/MainService.Wallet.cs b/src/Neo.CLI/CLI/MainService.Wallet.cs index 7c0c197b3..71df6c3ef 100644 --- a/src/Neo.CLI/CLI/MainService.Wallet.cs +++ b/src/Neo.CLI/CLI/MainService.Wallet.cs @@ -11,8 +11,10 @@ using Akka.Actor; using Neo.ConsoleService; +using Neo.Cryptography; using Neo.Extensions; using Neo.Json; +using Neo.Network.P2P; using Neo.Network.P2P.Payloads; using Neo.Persistence; using Neo.Sign; @@ -23,7 +25,9 @@ using Neo.Wallets.NEP6; using System.Numerics; using System.Security.Cryptography; +using System.Text; using static Neo.SmartContract.Helper; +using ECCurve = Neo.Cryptography.ECC.ECCurve; using ECPoint = Neo.Cryptography.ECC.ECPoint; namespace Neo.CLI; @@ -470,8 +474,8 @@ private void OnListKeyCommand() /// /// Process "sign" command /// - /// Json object to sign - [ConsoleCommand("sign", Category = "Wallet Commands")] + /// The json string that records the transaction information + [ConsoleCommand("sign transaction", Category = "Wallet Commands")] private void OnSignCommand(JObject jsonObjectToSign) { if (NoWallet()) return; @@ -503,6 +507,77 @@ private void OnSignCommand(JObject jsonObjectToSign) } } + /// + /// Process "sign message" command + /// + /// Message to sign + [ConsoleCommand("sign message", Category = "Wallet Commands")] + private void OnSignMessageCommand(string message) + { + if (NoWallet()) return; + + string password = ReadUserInput("password", true); + if (password.Length == 0) + { + ConsoleHelper.Info("Cancelled"); + return; + } + if (!CurrentWallet!.VerifyPassword(password)) + { + ConsoleHelper.Error("Incorrect password"); + return; + } + + var saltBytes = new byte[16]; + RandomNumberGenerator.Fill(saltBytes); + var saltHex = saltBytes.ToHexString().ToLowerInvariant(); + + var paramBytes = Encoding.UTF8.GetBytes(saltHex + message); + + byte[] payload; + using (var ms = new MemoryStream()) + using (var w = new BinaryWriter(ms, Encoding.UTF8, true)) + { + // We add these 4 bytes to prevent the signature from being a valid transaction + w.Write((byte)0x01); + w.Write((byte)0x00); + w.Write((byte)0x01); + w.Write((byte)0xF0); + // Write the actual message to sign + w.WriteVarBytes(paramBytes); + // We add these 2 bytes to prevent the signature from being a valid transaction + w.Write((ushort)0); + w.Flush(); + payload = ms.ToArray(); + } + + ConsoleHelper.Info("Signed Payload: ", $"{Environment.NewLine}{payload.ToHexString()}"); + Console.WriteLine(); + ConsoleHelper.Info(" Curve: ", "secp256r1"); + ConsoleHelper.Info("Algorithm: ", "payload = 010001f0 + VarBytes(Salt + Message) + 0000"); + ConsoleHelper.Info("Algorithm: ", "Sign(SHA256(network || Hash256(payload)))"); + ConsoleHelper.Info(" ", "See the online documentation for details on how to verify this signature."); + ConsoleHelper.Info(" ", "https://developers.neo.org/docs/n3/node/cli/cli#sign_message"); + Console.WriteLine(); + ConsoleHelper.Info("Generated signatures:"); + Console.WriteLine(); + + var hash = new UInt256(Crypto.Hash256(payload)); + var signData = hash.GetSignData(NeoSystem.Settings.Network); + + foreach (WalletAccount account in CurrentWallet.GetAccounts().Where(p => p.HasKey)) + { + var key = account.GetKey(); + var signature = Crypto.Sign(signData, key!.PrivateKey, ECCurve.Secp256r1); + + ConsoleHelper.Info(" Address: ", account.Address); + ConsoleHelper.Info(" PublicKey: ", key.PublicKey.EncodePoint(true).ToHexString()); + ConsoleHelper.Info(" Signature: ", signature.ToHexString()); + ConsoleHelper.Info(" Salt: ", saltHex); + Console.WriteLine(); + } + } + /// /// Process "send" command /// @@ -761,4 +836,5 @@ private void SignAndSendTx(DataCache snapshot, Transaction tx) ConsoleHelper.Info("Incomplete signature:\n", $"{context}"); } } + internal Func ReadUserInput { get; set; } = ConsoleHelper.ReadUserInput; } diff --git a/src/Neo.ConsoleService/ConsoleServiceBase.cs b/src/Neo.ConsoleService/ConsoleServiceBase.cs index a329d1cc0..eb0b46e20 100644 --- a/src/Neo.ConsoleService/ConsoleServiceBase.cs +++ b/src/Neo.ConsoleService/ConsoleServiceBase.cs @@ -125,6 +125,7 @@ internal bool OnCommand(string commandLine) var possibleHelp = ""; var tokens = commandLine.Tokenize(); var availableCommands = new List<(ConsoleCommandMethod Command, object?[] Arguments)>(); + foreach (var entries in _verbs.Values) { foreach (var command in entries) @@ -133,6 +134,7 @@ internal bool OnCommand(string commandLine) if (consumed <= 0) continue; var args = tokens.Skip(consumed).ToList().Trim(); + try { if (args.Any(u => u.IsIndicator)) @@ -170,7 +172,8 @@ internal bool OnCommand(string commandLine) // Show Ambiguous call var ambiguousCommands = availableCommands.Select(u => u.Command.Key).Distinct().ToList(); - throw new ArgumentException($"Ambiguous calls for: {string.Join(',', ambiguousCommands)}"); + var ambiguousCommandsQuoted = ambiguousCommands.Select(u => $"'{u}'").ToList(); + throw new ArgumentException($"Ambiguous calls for: {string.Join(',', ambiguousCommandsQuoted)}"); } private bool TryProcessValue(Type parameterType, IList args, bool consumeAll, out object? value) diff --git a/tests/Neo.CLI.Tests/TestUtils.cs b/tests/Neo.CLI.Tests/TestUtils.cs new file mode 100644 index 000000000..1036a5627 --- /dev/null +++ b/tests/Neo.CLI.Tests/TestUtils.cs @@ -0,0 +1,32 @@ +// Copyright (C) 2015-2025 The Neo Project. +// +// TestUtils.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 Neo.Json; +using Neo.Wallets.NEP6; + +namespace Neo.CLI.Tests; + +public static partial class TestUtils +{ + public static NEP6Wallet GenerateTestWallet(string password) + { + var wallet = new JObject() + { + ["name"] = "noname", + ["version"] = new Version("1.0").ToString(), + ["scrypt"] = new ScryptParameters(2, 1, 1).ToJson(), + ["accounts"] = new JArray(), + ["extra"] = null + }; + Assert.AreEqual("{\"name\":\"noname\",\"version\":\"1.0\",\"scrypt\":{\"n\":2,\"r\":1,\"p\":1},\"accounts\":[],\"extra\":null}", wallet.ToString()); + return new NEP6Wallet(null, password, TestProtocolSettings.Default, wallet); + } +} diff --git a/tests/Neo.CLI.Tests/UT_MainService_Wallet.cs b/tests/Neo.CLI.Tests/UT_MainService_Wallet.cs new file mode 100644 index 000000000..a81b6c55f --- /dev/null +++ b/tests/Neo.CLI.Tests/UT_MainService_Wallet.cs @@ -0,0 +1,189 @@ +// Copyright (C) 2015-2025 The Neo Project. +// +// UT_MainService_Wallet.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 System.Reflection; + +namespace Neo.CLI.Tests; + +[TestClass] +public class UT_MainService_Wallet +{ + private NeoSystem _neoSystem; + + [TestInitialize] + public void TestSetup() + { + _neoSystem = TestBlockchain.GetSystem(); + } + + [TestMethod] + public void TestOnSignMessageCommand() + { + var walletPassword = "test_pwd"; + var output = CreateWalletAngSignMessage(walletPassword); + + // Basic headers + Assert.IsFalse(string.IsNullOrWhiteSpace(output), "Output from sign message command should not be empty"); + Assert.Contains("Signed Payload", output, "Output should containt the signed payload"); + Assert.Contains("Algorithm", output, "Output should describe the algorithm used"); + Assert.Contains("Generated signatures", output, "Output should contain signatures header"); + + // Sign block + Assert.Contains("Address", output, "Output should contain at least one address"); + Assert.Contains("PublicKey", output, "Output should contain the public key"); + Assert.Contains("Signature", output, "Output should contain the signature"); + Assert.Contains("Salt", output, "Output should contain the salt used"); + + // Check Salt + var salt = ExtractHexValue(output, "Salt:"); + Assert.IsNotNull(salt, "Salt hex should be present in the output"); + Assert.AreEqual(32, salt!.Length, "Salt should be 16 bytes (32 hex chars)"); + Assert.IsTrue(IsHexString(salt!), "Salt should be valid hex"); + } + [TestMethod] + public void TestOnSignMessageCommandWithoutPassword() + { + var output = CreateWalletAngSignMessage(string.Empty); + + Assert.IsFalse(string.IsNullOrWhiteSpace(output), "Output should not be empty"); + Assert.Contains("Cancelled", output, "Output should contain cancellation message"); + } + [TestMethod] + public void TestOnSignMessageCommandWrongPassword() + { + var walletPassword = "invalid_pwd"; + var output = CreateWalletAngSignMessage(walletPassword); + + Assert.IsFalse(string.IsNullOrWhiteSpace(output), "Output should not be empty"); + Assert.Contains("Incorrect password", output, "Output should contain incorrect password"); + Assert.DoesNotContain("Signed Payload", output, "Output should not containt signed payload"); + Assert.DoesNotContain("Generated signatures", output, "Output should not containt signatures"); + } + [TestMethod] + public void TestOnSignMessageCommandWithoutAccount() + { + var walletPassword = "test_pwd"; + var output = CreateWalletAngSignMessage(walletPassword, false); + + Assert.IsFalse(string.IsNullOrWhiteSpace(output), "Output should not be empty"); + Assert.Contains("Signed Payload", output, "Output should containt signed payload"); + Assert.Contains("Generated signatures", output, "Output should containt signatures"); + Assert.DoesNotContain("Address:", output, "Output should not containt Address"); + Assert.DoesNotContain("PublicKey:", output, "Output should not containt PublicKey"); + Assert.DoesNotContain("Signature:", output, "Output should not containt Signature"); + Assert.DoesNotContain("Salt:", output, "Output should not containt Salt"); + } + private string CreateWalletAngSignMessage(string userPassword, bool withAccount = true) + { + var walletPassword = "test_pwd"; + var message = "this is a test to sign"; + + var wallet = TestUtils.GenerateTestWallet(walletPassword); + if (withAccount) + { + var account = wallet.CreateAccount(); + Assert.IsNotNull(account, "Wallet.CreateAccount() should create an account"); + } + + var service = new MainService(); + + TrySet(service, "NeoSystem", _neoSystem); + TrySetField(service, "_neoSystem", _neoSystem); + TrySet(service, "CurrentWallet", wallet); + TrySetField(service, "_currentWallet", wallet); + + var readInputProp = service.GetType().GetProperty( + "ReadUserInput", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + + Assert.IsNotNull(readInputProp, "ReadUserInput property not found on MainService"); + + Func fakeReadInput = (label, isPassword) => + { + Assert.AreEqual("password", label); + Assert.IsTrue(isPassword); + return userPassword; + }; + + readInputProp!.SetValue(service, fakeReadInput); + + var originalOut = Console.Out; + using var outputWriter = new StringWriter(); + Console.SetOut(outputWriter); + + try + { + InvokeNonPublic(service, "OnSignMessageCommand", message); + } + finally + { + Console.SetOut(originalOut); + } + + return outputWriter.ToString(); + } + private static string ExtractHexValue(string output, string label) + { + var index = output.IndexOf(label, StringComparison.OrdinalIgnoreCase); + if (index < 0) + return null; + + var start = index + label.Length; + var endOfLine = output.IndexOfAny(new[] { '\r', '\n' }, start); + if (endOfLine < 0) + endOfLine = output.Length; + + var value = output[start..endOfLine].Trim(); + return string.IsNullOrEmpty(value) ? null : value; + } + private static bool IsHexString(string value) + { + foreach (var c in value) + { + var isHex = + (c >= '0' && c <= '9') || + (c >= 'a' && c <= 'f') || + (c >= 'A' && c <= 'F'); + + if (!isHex) return false; + } + + return true; + } + private static void TrySet(object target, string propertyName, object value) + { + var prop = target.GetType().GetProperty( + propertyName, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic + ); + + prop?.SetValue(target, value); + } + private static void TrySetField(object target, string fieldName, object value) + { + var field = target.GetType().GetField( + fieldName, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic + ); + + field?.SetValue(target, value); + } + private static void InvokeNonPublic(object target, string methodName, params object[] args) + { + var method = target.GetType().GetMethod( + methodName, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic + ); + + Assert.IsNotNull(method, $"Method '{methodName}' not found on type '{target.GetType().FullName}'."); + method.Invoke(target, args); + } +}