From cd219c6018d10b7f138f997e338c413db0620f6f Mon Sep 17 00:00:00 2001 From: Alvaro Date: Wed, 26 Nov 2025 07:46:11 +0100 Subject: [PATCH 01/13] port message sign to master-n3 --- src/Neo.CLI/CLI/MainService.Wallet.cs | 76 +++++++++++++++++++- src/Neo.ConsoleService/ConsoleServiceBase.cs | 26 +++---- 2 files changed, 83 insertions(+), 19 deletions(-) diff --git a/src/Neo.CLI/CLI/MainService.Wallet.cs b/src/Neo.CLI/CLI/MainService.Wallet.cs index 7c0c197b3..61e9236cc 100644 --- a/src/Neo.CLI/CLI/MainService.Wallet.cs +++ b/src/Neo.CLI/CLI/MainService.Wallet.cs @@ -11,7 +11,8 @@ using Akka.Actor; using Neo.ConsoleService; -using Neo.Extensions; +using Neo.Cryptography; +using Neo.Extensions.IO; using Neo.Json; using Neo.Network.P2P.Payloads; using Neo.Persistence; @@ -23,8 +24,10 @@ using Neo.Wallets.NEP6; using System.Numerics; using System.Security.Cryptography; +using System.Text; using static Neo.SmartContract.Helper; using ECPoint = Neo.Cryptography.ECC.ECPoint; +using ECCurve = Neo.Cryptography.ECC.ECCurve; namespace Neo.CLI; @@ -470,8 +473,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 +506,73 @@ 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 = ConsoleHelper.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: ", "010001f0 + VarBytes(Salt + Message) + 0000"); + 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(); + + foreach (WalletAccount account in CurrentWallet.GetAccounts().Where(p => p.HasKey)) + { + var key = account.GetKey(); + var signature = Crypto.Sign(payload, 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 /// diff --git a/src/Neo.ConsoleService/ConsoleServiceBase.cs b/src/Neo.ConsoleService/ConsoleServiceBase.cs index a329d1cc0..2e05f7954 100644 --- a/src/Neo.ConsoleService/ConsoleServiceBase.cs +++ b/src/Neo.ConsoleService/ConsoleServiceBase.cs @@ -124,7 +124,7 @@ internal bool OnCommand(string commandLine) var possibleHelp = ""; var tokens = commandLine.Tokenize(); - var availableCommands = new List<(ConsoleCommandMethod Command, object?[] Arguments)>(); + var availableCommands = new List<(ConsoleCommandMethod Command, Func Arguments)>(); foreach (var entries in _verbs.Values) { foreach (var command in entries) @@ -133,19 +133,11 @@ internal bool OnCommand(string commandLine) if (consumed <= 0) continue; var args = tokens.Skip(consumed).ToList().Trim(); - try - { - if (args.Any(u => u.IsIndicator)) - availableCommands.Add((command, ParseIndicatorArguments(command.Method, args))); - else - availableCommands.Add((command, ParseSequentialArguments(command.Method, args))); - } - catch (Exception ex) - { - // Skip parse errors - possibleHelp = command.Key; - ConsoleHelper.Error($"{ex.InnerException?.Message ?? ex.Message}"); - } + + if (args.Any(u => u.IsIndicator)) + availableCommands.Add((command, () => ParseIndicatorArguments(command.Method, args))); + else + availableCommands.Add((command, () => ParseSequentialArguments(command.Method, args))); } } @@ -161,7 +153,8 @@ internal bool OnCommand(string commandLine) if (availableCommands.Count == 1) { - var (command, arguments) = availableCommands[0]; + var (command, getArguments) = availableCommands[0]; + var arguments = getArguments(); object? result = command.Method.Invoke(command.Instance, arguments); if (result is Task task) task.Wait(); @@ -170,7 +163,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) From c57e6e7cd1646070e01c0bbb047cd40364543418 Mon Sep 17 00:00:00 2001 From: Alvaro Date: Wed, 26 Nov 2025 07:56:52 +0100 Subject: [PATCH 02/13] reordering using due to format --- src/Neo.CLI/CLI/MainService.Wallet.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Neo.CLI/CLI/MainService.Wallet.cs b/src/Neo.CLI/CLI/MainService.Wallet.cs index 61e9236cc..7351a6101 100644 --- a/src/Neo.CLI/CLI/MainService.Wallet.cs +++ b/src/Neo.CLI/CLI/MainService.Wallet.cs @@ -9,6 +9,9 @@ // Redistribution and use in source and binary forms with or without // modifications are permitted. +using System.Numerics; +using System.Security.Cryptography; +using System.Text; using Akka.Actor; using Neo.ConsoleService; using Neo.Cryptography; @@ -22,9 +25,6 @@ using Neo.VM; using Neo.Wallets; using Neo.Wallets.NEP6; -using System.Numerics; -using System.Security.Cryptography; -using System.Text; using static Neo.SmartContract.Helper; using ECPoint = Neo.Cryptography.ECC.ECPoint; using ECCurve = Neo.Cryptography.ECC.ECCurve; From f050e7a972ede65b44e1d66d33100efe519b470d Mon Sep 17 00:00:00 2001 From: Alvaro Date: Wed, 26 Nov 2025 08:16:29 +0100 Subject: [PATCH 03/13] fix format using --- src/Neo.CLI/CLI/MainService.Wallet.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Neo.CLI/CLI/MainService.Wallet.cs b/src/Neo.CLI/CLI/MainService.Wallet.cs index 7351a6101..ae7556f42 100644 --- a/src/Neo.CLI/CLI/MainService.Wallet.cs +++ b/src/Neo.CLI/CLI/MainService.Wallet.cs @@ -9,9 +9,6 @@ // Redistribution and use in source and binary forms with or without // modifications are permitted. -using System.Numerics; -using System.Security.Cryptography; -using System.Text; using Akka.Actor; using Neo.ConsoleService; using Neo.Cryptography; @@ -25,9 +22,12 @@ using Neo.VM; using Neo.Wallets; using Neo.Wallets.NEP6; +using System.Numerics; +using System.Security.Cryptography; +using System.Text; using static Neo.SmartContract.Helper; -using ECPoint = Neo.Cryptography.ECC.ECPoint; using ECCurve = Neo.Cryptography.ECC.ECCurve; +using ECPoint = Neo.Cryptography.ECC.ECPoint; namespace Neo.CLI; From 5bfdbff6d005d564bd0c607113d30b0f663096fe Mon Sep 17 00:00:00 2001 From: Alvaro Date: Wed, 26 Nov 2025 09:41:05 +0100 Subject: [PATCH 04/13] add trycatch --- src/Neo.ConsoleService/ConsoleServiceBase.cs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/Neo.ConsoleService/ConsoleServiceBase.cs b/src/Neo.ConsoleService/ConsoleServiceBase.cs index 2e05f7954..a925833aa 100644 --- a/src/Neo.ConsoleService/ConsoleServiceBase.cs +++ b/src/Neo.ConsoleService/ConsoleServiceBase.cs @@ -134,10 +134,19 @@ internal bool OnCommand(string commandLine) var args = tokens.Skip(consumed).ToList().Trim(); - if (args.Any(u => u.IsIndicator)) - availableCommands.Add((command, () => ParseIndicatorArguments(command.Method, args))); - else - availableCommands.Add((command, () => ParseSequentialArguments(command.Method, args))); + try + { + if (args.Any(u => u.IsIndicator)) + availableCommands.Add((command, () => ParseIndicatorArguments(command.Method, args))); + else + availableCommands.Add((command, () => ParseSequentialArguments(command.Method, args))); + } + catch (Exception ex) + { + // Skip parse errors + possibleHelp = command.Key; + ConsoleHelper.Error($"{ex.InnerException?.Message ?? ex.Message}"); + } } } From 95202778c024d1ebcfc2ee5409971a3712d79283 Mon Sep 17 00:00:00 2001 From: Alvaro Date: Wed, 26 Nov 2025 16:28:27 +0100 Subject: [PATCH 05/13] Use of GetSignData --- src/Neo.CLI/CLI/MainService.Wallet.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Neo.CLI/CLI/MainService.Wallet.cs b/src/Neo.CLI/CLI/MainService.Wallet.cs index ae7556f42..a3cd625f8 100644 --- a/src/Neo.CLI/CLI/MainService.Wallet.cs +++ b/src/Neo.CLI/CLI/MainService.Wallet.cs @@ -14,6 +14,7 @@ using Neo.Cryptography; using Neo.Extensions.IO; using Neo.Json; +using Neo.Network.P2P; using Neo.Network.P2P.Payloads; using Neo.Persistence; using Neo.Sign; @@ -511,7 +512,7 @@ private void OnSignCommand(JObject jsonObjectToSign) /// /// Message to sign [ConsoleCommand("sign message", Category = "Wallet Commands")] - private void OnSignMessageCommand(string message) + internal void OnSignMessageCommand(string message) { if (NoWallet()) return; @@ -553,7 +554,8 @@ private void OnSignMessageCommand(string message) ConsoleHelper.Info("Signed Payload: ", $"{Environment.NewLine}{payload.ToHexString()}"); Console.WriteLine(); ConsoleHelper.Info(" Curve: ", "secp256r1"); - ConsoleHelper.Info("Algorithm: ", "010001f0 + VarBytes(Salt + Message) + 0000"); + 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(); @@ -563,7 +565,9 @@ private void OnSignMessageCommand(string message) foreach (WalletAccount account in CurrentWallet.GetAccounts().Where(p => p.HasKey)) { var key = account.GetKey(); - var signature = Crypto.Sign(payload, key.PrivateKey, ECCurve.Secp256r1); + var hash = new UInt256(Crypto.Hash256(payload)); + var signData = hash.GetSignData(NeoSystem.Settings.Network); + var signature = Crypto.Sign(signData, key!.PrivateKey, ECCurve.Secp256r1); ConsoleHelper.Info(" Address: ", account.Address); ConsoleHelper.Info(" PublicKey: ", key.PublicKey.EncodePoint(true).ToHexString()); From 18bf56a55b17d5f2b36e69f11778cee24c9acbb9 Mon Sep 17 00:00:00 2001 From: Alvaro Date: Thu, 27 Nov 2025 10:17:09 +0100 Subject: [PATCH 06/13] modify consoleservicebase --- src/Neo.CLI/CLI/MainService.Wallet.cs | 12 +++++++----- src/Neo.ConsoleService/ConsoleServiceBase.cs | 10 +++++----- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/Neo.CLI/CLI/MainService.Wallet.cs b/src/Neo.CLI/CLI/MainService.Wallet.cs index a3cd625f8..18a0142a5 100644 --- a/src/Neo.CLI/CLI/MainService.Wallet.cs +++ b/src/Neo.CLI/CLI/MainService.Wallet.cs @@ -512,11 +512,11 @@ private void OnSignCommand(JObject jsonObjectToSign) /// /// Message to sign [ConsoleCommand("sign message", Category = "Wallet Commands")] - internal void OnSignMessageCommand(string message) + private void OnSignMessageCommand(string message) { if (NoWallet()) return; - string password = ConsoleHelper.ReadUserInput("password", true); + string password = ReadUserInput("password", true); // ConsoleHelper.ReadUserInput("password", true); if (password.Length == 0) { ConsoleHelper.Info("Cancelled"); @@ -555,18 +555,19 @@ internal void OnSignMessageCommand(string message) 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("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 hash = new UInt256(Crypto.Hash256(payload)); - var signData = hash.GetSignData(NeoSystem.Settings.Network); var signature = Crypto.Sign(signData, key!.PrivateKey, ECCurve.Secp256r1); ConsoleHelper.Info(" Address: ", account.Address); @@ -835,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 a925833aa..eb0b46e20 100644 --- a/src/Neo.ConsoleService/ConsoleServiceBase.cs +++ b/src/Neo.ConsoleService/ConsoleServiceBase.cs @@ -124,7 +124,8 @@ internal bool OnCommand(string commandLine) var possibleHelp = ""; var tokens = commandLine.Tokenize(); - var availableCommands = new List<(ConsoleCommandMethod Command, Func Arguments)>(); + var availableCommands = new List<(ConsoleCommandMethod Command, object?[] Arguments)>(); + foreach (var entries in _verbs.Values) { foreach (var command in entries) @@ -137,9 +138,9 @@ internal bool OnCommand(string commandLine) try { if (args.Any(u => u.IsIndicator)) - availableCommands.Add((command, () => ParseIndicatorArguments(command.Method, args))); + availableCommands.Add((command, ParseIndicatorArguments(command.Method, args))); else - availableCommands.Add((command, () => ParseSequentialArguments(command.Method, args))); + availableCommands.Add((command, ParseSequentialArguments(command.Method, args))); } catch (Exception ex) { @@ -162,8 +163,7 @@ internal bool OnCommand(string commandLine) if (availableCommands.Count == 1) { - var (command, getArguments) = availableCommands[0]; - var arguments = getArguments(); + var (command, arguments) = availableCommands[0]; object? result = command.Method.Invoke(command.Instance, arguments); if (result is Task task) task.Wait(); From 62d9cc352b4778946d877425598798bacdee1e8d Mon Sep 17 00:00:00 2001 From: Alvaro Date: Thu, 27 Nov 2025 10:26:12 +0100 Subject: [PATCH 07/13] UT for OnSignMessageCommand --- tests/Neo.CLI.Tests/TestUtils.cs | 31 ++++ tests/Neo.CLI.Tests/UT_MainService_Wallet.cs | 144 +++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 tests/Neo.CLI.Tests/TestUtils.cs create mode 100644 tests/Neo.CLI.Tests/UT_MainService_Wallet.cs diff --git a/tests/Neo.CLI.Tests/TestUtils.cs b/tests/Neo.CLI.Tests/TestUtils.cs new file mode 100644 index 000000000..2d859fc5e --- /dev/null +++ b/tests/Neo.CLI.Tests/TestUtils.cs @@ -0,0 +1,31 @@ +// 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..4cf88c529 --- /dev/null +++ b/tests/Neo.CLI.Tests/UT_MainService_Wallet.cs @@ -0,0 +1,144 @@ +// 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 message = "this is a test to sign"; + + var wallet = TestUtils.GenerateTestWallet(walletPassword); + 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 walletPassword; + }; + + 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); + } + + var output = outputWriter.ToString(); + // 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"); + } + 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); + } +} From 995577fa08ad8fc2607cc10c8e67499d5414b1a0 Mon Sep 17 00:00:00 2001 From: Alvaro Date: Thu, 27 Nov 2025 10:51:54 +0100 Subject: [PATCH 08/13] format review --- tests/Neo.CLI.Tests/TestUtils.cs | 1 + tests/Neo.CLI.Tests/UT_MainService_Wallet.cs | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/Neo.CLI.Tests/TestUtils.cs b/tests/Neo.CLI.Tests/TestUtils.cs index 2d859fc5e..1036a5627 100644 --- a/tests/Neo.CLI.Tests/TestUtils.cs +++ b/tests/Neo.CLI.Tests/TestUtils.cs @@ -13,6 +13,7 @@ using Neo.Wallets.NEP6; namespace Neo.CLI.Tests; + public static partial class TestUtils { public static NEP6Wallet GenerateTestWallet(string password) diff --git a/tests/Neo.CLI.Tests/UT_MainService_Wallet.cs b/tests/Neo.CLI.Tests/UT_MainService_Wallet.cs index 4cf88c529..e72062991 100644 --- a/tests/Neo.CLI.Tests/UT_MainService_Wallet.cs +++ b/tests/Neo.CLI.Tests/UT_MainService_Wallet.cs @@ -12,6 +12,7 @@ using System.Reflection; namespace Neo.CLI.Tests; + [TestClass] public class UT_MainService_Wallet { From 87b6864e232d7875b40b535efa782f29713ec7c2 Mon Sep 17 00:00:00 2001 From: Alvaro Date: Sat, 29 Nov 2025 12:44:25 +0100 Subject: [PATCH 09/13] update using --- src/Neo.CLI/CLI/MainService.Wallet.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Neo.CLI/CLI/MainService.Wallet.cs b/src/Neo.CLI/CLI/MainService.Wallet.cs index 18a0142a5..ad17301b3 100644 --- a/src/Neo.CLI/CLI/MainService.Wallet.cs +++ b/src/Neo.CLI/CLI/MainService.Wallet.cs @@ -12,7 +12,7 @@ using Akka.Actor; using Neo.ConsoleService; using Neo.Cryptography; -using Neo.Extensions.IO; +using Neo.Extensions; using Neo.Json; using Neo.Network.P2P; using Neo.Network.P2P.Payloads; From 295ccd83c01883637544f6f17714155bd851724e Mon Sep 17 00:00:00 2001 From: Alvaro Date: Tue, 2 Dec 2025 22:42:37 +0100 Subject: [PATCH 10/13] add UT --- tests/Neo.CLI.Tests/UT_MainService_Wallet.cs | 82 +++++++++++++++----- 1 file changed, 63 insertions(+), 19 deletions(-) diff --git a/tests/Neo.CLI.Tests/UT_MainService_Wallet.cs b/tests/Neo.CLI.Tests/UT_MainService_Wallet.cs index e72062991..a81b6c55f 100644 --- a/tests/Neo.CLI.Tests/UT_MainService_Wallet.cs +++ b/tests/Neo.CLI.Tests/UT_MainService_Wallet.cs @@ -26,13 +26,72 @@ public void TestSetup() [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); - var account = wallet.CreateAccount(); - Assert.IsNotNull(account, "Wallet.CreateAccount() should create an account"); + if (withAccount) + { + var account = wallet.CreateAccount(); + Assert.IsNotNull(account, "Wallet.CreateAccount() should create an account"); + } var service = new MainService(); @@ -51,7 +110,7 @@ public void TestOnSignMessageCommand() { Assert.AreEqual("password", label); Assert.IsTrue(isPassword); - return walletPassword; + return userPassword; }; readInputProp!.SetValue(service, fakeReadInput); @@ -69,22 +128,7 @@ public void TestOnSignMessageCommand() Console.SetOut(originalOut); } - var output = outputWriter.ToString(); - // 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"); + return outputWriter.ToString(); } private static string ExtractHexValue(string output, string label) { From 58836bbfde457a39372d9b131e7ee4b8969d0b3b Mon Sep 17 00:00:00 2001 From: Alvaro Date: Tue, 2 Dec 2025 22:49:05 +0100 Subject: [PATCH 11/13] remove comments --- src/Neo.CLI/CLI/MainService.Wallet.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Neo.CLI/CLI/MainService.Wallet.cs b/src/Neo.CLI/CLI/MainService.Wallet.cs index ad17301b3..71df6c3ef 100644 --- a/src/Neo.CLI/CLI/MainService.Wallet.cs +++ b/src/Neo.CLI/CLI/MainService.Wallet.cs @@ -516,7 +516,7 @@ private void OnSignMessageCommand(string message) { if (NoWallet()) return; - string password = ReadUserInput("password", true); // ConsoleHelper.ReadUserInput("password", true); + string password = ReadUserInput("password", true); if (password.Length == 0) { ConsoleHelper.Info("Cancelled"); From 5fdeeec50034826f325c96ece433579d459f1e71 Mon Sep 17 00:00:00 2001 From: Alvaro Date: Wed, 10 Dec 2025 22:15:10 +0100 Subject: [PATCH 12/13] Apply suggestion from @superboyiii Co-authored-by: Owen <38493437+superboyiii@users.noreply.github.com> --- src/Neo.CLI/CLI/MainService.Wallet.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Neo.CLI/CLI/MainService.Wallet.cs b/src/Neo.CLI/CLI/MainService.Wallet.cs index 71df6c3ef..ab64b4051 100644 --- a/src/Neo.CLI/CLI/MainService.Wallet.cs +++ b/src/Neo.CLI/CLI/MainService.Wallet.cs @@ -528,6 +528,14 @@ private void OnSignMessageCommand(string message) return; } + if (message.Length >= 2) + { + if ((message[0] == '"' && message[^1] == '"') || (message[0] == '\'' && message[^1] == '\'')) + { + message = message[1..^1]; + } + } + var saltBytes = new byte[16]; RandomNumberGenerator.Fill(saltBytes); var saltHex = saltBytes.ToHexString().ToLowerInvariant(); From cd981830eb64c05f3e8cd39f494ed5cb5f75bca6 Mon Sep 17 00:00:00 2001 From: Alvaro Date: Wed, 10 Dec 2025 23:15:45 +0100 Subject: [PATCH 13/13] Add UT for quotes and normalize message --- src/Neo.CLI/CLI/MainService.Wallet.cs | 24 ++++-- tests/Neo.CLI.Tests/UT_MainService_Wallet.cs | 83 +++++++++++++++++++- 2 files changed, 99 insertions(+), 8 deletions(-) diff --git a/src/Neo.CLI/CLI/MainService.Wallet.cs b/src/Neo.CLI/CLI/MainService.Wallet.cs index ab64b4051..ea10dbf02 100644 --- a/src/Neo.CLI/CLI/MainService.Wallet.cs +++ b/src/Neo.CLI/CLI/MainService.Wallet.cs @@ -516,24 +516,25 @@ private void OnSignMessageCommand(string message) { if (NoWallet()) return; + message = NormalizeMessage(message); + string password = ReadUserInput("password", true); if (password.Length == 0) { ConsoleHelper.Info("Cancelled"); return; } + if (!CurrentWallet!.VerifyPassword(password)) { ConsoleHelper.Error("Incorrect password"); return; } - if (message.Length >= 2) + if (message == null) { - if ((message[0] == '"' && message[^1] == '"') || (message[0] == '\'' && message[^1] == '\'')) - { - message = message[1..^1]; - } + ConsoleHelper.Error("Null message"); + return; } var saltBytes = new byte[16]; @@ -559,7 +560,7 @@ private void OnSignMessageCommand(string message) payload = ms.ToArray(); } - ConsoleHelper.Info("Signed Payload: ", $"{Environment.NewLine}{payload.ToHexString()}"); + ConsoleHelper.Info("Signed Payload: ", $"{payload.ToHexString()}"); Console.WriteLine(); ConsoleHelper.Info(" Curve: ", "secp256r1"); ConsoleHelper.Info("Algorithm: ", "payload = 010001f0 + VarBytes(Salt + Message) + 0000"); @@ -817,7 +818,18 @@ private void OnChangePasswordCommand() ConsoleHelper.Error("Failed to change password"); } } + private string NormalizeMessage(string message) + { + if (string.IsNullOrEmpty(message) || message.Length < 2) return message; + + var first = message[0]; + var last = message[^1]; + if (first == last && (first == '"' || first == '\'')) + return message[1..^1]; + + return message; + } private void SignAndSendTx(DataCache snapshot, Transaction tx) { if (NoWallet()) return; diff --git a/tests/Neo.CLI.Tests/UT_MainService_Wallet.cs b/tests/Neo.CLI.Tests/UT_MainService_Wallet.cs index a81b6c55f..fe36d92f4 100644 --- a/tests/Neo.CLI.Tests/UT_MainService_Wallet.cs +++ b/tests/Neo.CLI.Tests/UT_MainService_Wallet.cs @@ -10,6 +10,7 @@ // modifications are permitted. using System.Reflection; +using System.Text; namespace Neo.CLI.Tests; @@ -81,10 +82,46 @@ public void TestOnSignMessageCommandWithoutAccount() 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) + [TestMethod] + public void TestOnSignMessageCommandWithNullMessage() + { + var walletPassword = "test_pwd"; + var output = CreateWalletAngSignMessage(walletPassword, true, null); + Assert.IsFalse(string.IsNullOrWhiteSpace(output), "Output should not be empty"); + Assert.Contains("Null message", output, "Output should contain null message"); + } + [TestMethod] + public void TestOnSignMessageCommandWithQuotes() { var walletPassword = "test_pwd"; - var message = "this is a test to sign"; + string message = "this is a test to sign"; + var outputWithoutQuotes = CreateWalletAngSignMessage(walletPassword, true, message); + var outputWithDoubleQuotes = CreateWalletAngSignMessage(walletPassword, true, $"\"{message}\""); + var outputWithSingleQuotes = CreateWalletAngSignMessage(walletPassword, true, $"'{message}'"); + + Assert.IsFalse(string.IsNullOrWhiteSpace(outputWithoutQuotes), "Output without quotes should not be empty"); + Assert.IsFalse(string.IsNullOrWhiteSpace(outputWithDoubleQuotes), "Output with double quotes should not be empty"); + Assert.IsFalse(string.IsNullOrWhiteSpace(outputWithSingleQuotes), "Output with single quotes should not be empty"); + + var payloadWithoutQuotes = ExtractHexValue(outputWithoutQuotes, "Signed Payload:"); + var payloadWithDoubleQuotes = ExtractHexValue(outputWithDoubleQuotes, "Signed Payload:"); + var payloadWithSingleQuotes = ExtractHexValue(outputWithSingleQuotes, "Signed Payload:"); + + Assert.IsNotNull(payloadWithoutQuotes, "Signed payload should be present when signing without quotes"); + Assert.IsNotNull(payloadWithDoubleQuotes, "Signed payload should be present when signing with double quotes"); + Assert.IsNotNull(payloadWithSingleQuotes, "Signed payload should be present when signing with single quotes"); + + var msgPayloadWithoutQuotes = ExtractMessageFromSignedPayload(payloadWithoutQuotes); + var msgPayloadWithDoubleQuotes = ExtractMessageFromSignedPayload(payloadWithDoubleQuotes); + var msgPayloadWithSingleQuotes = ExtractMessageFromSignedPayload(payloadWithSingleQuotes); + + Assert.AreEqual(msgPayloadWithoutQuotes, msgPayloadWithDoubleQuotes, "Signing a message with surrounding double quotes should produce the same normalized message as a signing without quotes"); + Assert.AreEqual(msgPayloadWithoutQuotes, msgPayloadWithSingleQuotes, "Signing a message with surrounding single quotes should produce the same normalized message as a signing without quotes"); + } + private string CreateWalletAngSignMessage(string userPassword, bool withAccount = true, string messageToSign = "this is a test to sign") + { + var walletPassword = "test_pwd"; + var message = messageToSign; var wallet = TestUtils.GenerateTestWallet(walletPassword); if (withAccount) @@ -186,4 +223,46 @@ private static void InvokeNonPublic(object target, string methodName, params obj Assert.IsNotNull(method, $"Method '{methodName}' not found on type '{target.GetType().FullName}'."); method.Invoke(target, args); } + private static string ExtractMessageFromSignedPayload(string payloadHex) + { + var payloadBytes = HexToBytes(payloadHex); + Assert.IsNotNull(payloadBytes); + Assert.IsGreaterThan(4 + 1 + 32 + 2, payloadBytes.Length, "Payload is too short"); + + Assert.AreEqual(0x01, payloadBytes[0], "Invalid payload prefix byte 0"); + Assert.AreEqual(0x00, payloadBytes[1], "Invalid payload prefix byte 1"); + Assert.AreEqual(0x01, payloadBytes[2], "Invalid payload prefix byte 2"); + Assert.AreEqual(0xF0, payloadBytes[3], "Invalid payload prefix byte 3"); + + byte paramLength = payloadBytes[4]; + int paramStart = 5; + + Assert.IsGreaterThanOrEqualTo(paramStart + paramLength + 2, payloadBytes.Length, "Payload does not contain full param bytes"); + + var paramBytes = new byte[paramLength]; + Buffer.BlockCopy(payloadBytes, paramStart, paramBytes, 0, paramLength); + + int suffixIndex = paramStart + paramLength; + Assert.AreEqual(0x00, payloadBytes[suffixIndex], "Invalid payload suffix byte 0"); + Assert.AreEqual(0x00, payloadBytes[suffixIndex + 1], "Invalid payload suffix byte 1"); + + var saltAndMessage = Encoding.UTF8.GetString(paramBytes); + + Assert.IsGreaterThanOrEqualTo(32, saltAndMessage.Length, "Salt+message data is too short"); + var message = saltAndMessage[32..]; + + return message; + } + private static byte[] HexToBytes(string hex) + { + if (hex.Length % 2 != 0) + throw new ArgumentException("Hex string must have an even length", nameof(hex)); + + var bytes = new byte[hex.Length / 2]; + for (int i = 0; i < bytes.Length; i++) + { + bytes[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16); + } + return bytes; + } }