Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
cd219c6
port message sign to master-n3
ajara87 Nov 26, 2025
c57e6e7
reordering using due to format
ajara87 Nov 26, 2025
f050e7a
fix format using
ajara87 Nov 26, 2025
5bfdbff
add trycatch
ajara87 Nov 26, 2025
9520277
Use of GetSignData
ajara87 Nov 26, 2025
18bf56a
modify consoleservicebase
ajara87 Nov 27, 2025
62d9cc3
UT for OnSignMessageCommand
ajara87 Nov 27, 2025
995577f
format review
ajara87 Nov 27, 2025
87b6864
update using
ajara87 Nov 29, 2025
d178f20
Merge branch 'master-n3' into message-sign
ajara87 Nov 30, 2025
78b9598
Merge branch 'master-n3' into message-sign
ajara87 Dec 2, 2025
295ccd8
add UT
ajara87 Dec 2, 2025
58836bb
remove comments
ajara87 Dec 2, 2025
5fdeeec
Apply suggestion from @superboyiii
ajara87 Dec 10, 2025
cd98183
Add UT for quotes and normalize message
ajara87 Dec 10, 2025
f7e9137
Merge branch 'master-n3' into message-sign
ajara87 Dec 21, 2025
d6babdb
Merge branch 'master-n3' into message-sign
ajara87 Jan 2, 2026
3358226
Merge branch 'master-n3' into message-sign
ajara87 Jan 6, 2026
eec7794
Merge branch 'master-n3' into message-sign
ajara87 Jan 9, 2026
105c3cc
Update TestUtils.cs
ajara87 Jan 11, 2026
156dd25
Update UT_MainService_Wallet.cs
ajara87 Jan 11, 2026
4c7fb8d
add superboiii suggestion OnVerifyMessageCommand
ajara87 Jan 13, 2026
b004143
Update UT_MainService_Wallet.cs
ajara87 Jan 13, 2026
5ab8d72
chris suggestion
ajara87 Jan 13, 2026
51190c8
Merge branch 'master-n3' into message-sign
shargon Jan 15, 2026
53643e4
Merge branch 'master-n3' into message-sign
ajara87 Jan 16, 2026
929a81b
Merge branch 'master-n3' into message-sign
ajara87 Jan 19, 2026
6abda71
Merge branch 'master-n3' into message-sign
shargon Jan 21, 2026
205211d
add normalize message to verify message
ajara87 Jan 21, 2026
0b66999
Merge branch 'master-n3' into message-sign
superboyiii Jan 27, 2026
e35806c
Merge branch 'master-n3' into message-sign
ajara87 Jan 27, 2026
beefb8b
Merge branch 'master-n3' into message-sign
superboyiii Feb 5, 2026
91c7449
Merge branch 'master-n3' into message-sign
shargon Feb 5, 2026
0045632
Merge branch 'master-n3' into message-sign
ajara87 Feb 14, 2026
708d8aa
add parameter to choose
ajara87 Feb 14, 2026
d8ad5df
modify parameter name
ajara87 Feb 16, 2026
8f1c586
modify verify signature
ajara87 Feb 16, 2026
5f66021
add UTs
ajara87 Feb 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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