Skip to content
80 changes: 78 additions & 2 deletions src/Neo.CLI/CLI/MainService.Wallet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -470,8 +474,8 @@ private void OnListKeyCommand()
/// <summary>
/// Process "sign" command
/// </summary>
/// <param name="jsonObjectToSign">Json object to sign</param>
[ConsoleCommand("sign", Category = "Wallet Commands")]
/// <param name="jsonObjectToSign">The json string that records the transaction information</param>
[ConsoleCommand("sign transaction", Category = "Wallet Commands")]
private void OnSignCommand(JObject jsonObjectToSign)
{
if (NoWallet()) return;
Expand Down Expand Up @@ -503,6 +507,77 @@ private void OnSignCommand(JObject jsonObjectToSign)
}
}

/// <summary>
/// Process "sign message" command
/// </summary>
/// <param name="message">Message to sign</param>
[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();
}
}

/// <summary>
/// Process "send" command
/// </summary>
Expand Down Expand Up @@ -761,4 +836,5 @@ private void SignAndSendTx(DataCache snapshot, Transaction tx)
ConsoleHelper.Info("Incomplete signature:\n", $"{context}");
}
}
internal Func<string, bool, string> ReadUserInput { get; set; } = ConsoleHelper.ReadUserInput;
}
5 changes: 4 additions & 1 deletion src/Neo.ConsoleService/ConsoleServiceBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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))
Expand Down Expand Up @@ -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<CommandToken> args, bool consumeAll, out object? value)
Expand Down
32 changes: 32 additions & 0 deletions tests/Neo.CLI.Tests/TestUtils.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
189 changes: 189 additions & 0 deletions tests/Neo.CLI.Tests/UT_MainService_Wallet.cs
Original file line number Diff line number Diff line change
@@ -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<string, bool, string> 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);
}
}
Loading