Skip to content
Open
Show file tree
Hide file tree
Changes from 33 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
198 changes: 196 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,11 @@

using Akka.Actor;
using Neo.ConsoleService;
using Neo.Cryptography;
using Neo.Extensions;
using Neo.Extensions.Factories;
using Neo.Json;
using Neo.Network.P2P;
using Neo.Network.P2P.Payloads;
using Neo.Persistence;
using Neo.Sign;
Expand All @@ -23,7 +26,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 +475,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 +508,183 @@ 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;

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 == null)
{
ConsoleHelper.Error("Null message");
return;
}

var saltBytes = new byte[16];
saltBytes = RandomNumberFactory.NextBytes(saltBytes.Length, cryptography: true);
var saltHex = Convert.ToHexStringLower(saltBytes);

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: ", $"{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 "verify message" command
/// </summary>
/// <param name="message">Original message that was signed</param>
/// <param name="signature">Signature in hex format</param>
/// <param name="publicKey">Public key in hex format</param>
/// <param name="salt">Salt in hex format</param>
[ConsoleCommand("verify message", Category = "Wallet Commands")]
private void OnVerifyMessageCommand(string message, string signature, string publicKey, string salt)
{
try
{
message = NormalizeMessage(message);

// Parse public key
if (!ECPoint.TryParse(publicKey, ECCurve.Secp256r1, out var pubKey))
{
ConsoleHelper.Error("Invalid public key format");
return;
}

// Parse signature
byte[] signatureBytes;
try
{
signatureBytes = signature.HexToBytes();
}
catch
{
ConsoleHelper.Error("Invalid signature format (must be hex string)");
return;
}

// Validate salt format (should be hex string, typically 32 characters for 16 bytes)
if (string.IsNullOrEmpty(salt))
{
ConsoleHelper.Error("Salt cannot be empty");
return;
}

// Reconstruct payload: 010001f0 + VarBytes(Salt + Message) + 0000
// Note: salt is used as hex string (lowercase), same as in signing
var saltHex = salt.ToLowerInvariant();
var paramBytes = Encoding.UTF8.GetBytes(saltHex + message);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The message can contain the salt. I think we can remove the salt, and recommend to add it in the NEP

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@roman-khimov what do you think?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is 010001f0?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@roman-khimov what do you think?

Same as previous, this code just does what wallets were doing for years. They salted the message, good for them, OK for me.

What is 010001f0?

Magic. Chosen years ago to ensure that signed message is never a transaction. Still works for N3 since we don't have transaction version 1 (yet).

Refs:

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Compared to salt, I prefer adding a timestamp. Additionally, the timestamp, message content, etc., can be encapsulated into structured data.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should not bother it so much~~~ as long as it works.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@roman-khimov the thing is that we added SignData call, this add a signature protection that makes incompatible with the previous versions

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But who needs an incompatible version of this thing? As noticed previously, even NeoFS is compatible with this scheme:
https://github.com/nspcc-dev/neofs-sdk-go/blob/23f5f0a8f3de952108d36cdbe589d0a2910ce8be/crypto/ecdsa/wallet_connect.go#L108

because when we played with gateways/wallets it was quickly discovered that they do not allow signing arbitrary data, they can sign messages in this format. So we adapted to it with this additional signature scheme. I don't see why node wallet can't do the same. There is nothing inherently wrong here, mostly it depends on what is signed and how the message signed is used, but that's not a wallet responsibility.

byte[] payload;
using (var ms = new MemoryStream())
using (var w = new BinaryWriter(ms, Encoding.UTF8, true))
{
w.Write((byte)0x01);
w.Write((byte)0x00);
w.Write((byte)0x01);
w.Write((byte)0xF0);
w.WriteVarBytes(paramBytes);
w.Write((ushort)0);
w.Flush();
payload = ms.ToArray();
}

// Calculate signData: SHA256(network || Hash256(payload))
var hash = new UInt256(Crypto.Hash256(payload));
var signData = hash.GetSignData(NeoSystem.Settings.Network);
Copy link
Member

@shargon shargon Feb 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This already makes incompatible with the wallets version. I don't see that these wallets or providers have previously tried to make their "standard" official (by nep o github issue), so I don't understand why we have to respect a project's own version

bool isValid = Crypto.VerifySignature(signData, signatureBytes, pubKey);
var contract = Contract.CreateSignatureContract(pubKey);
var address = contract.ScriptHash.ToAddress(NeoSystem.Settings.AddressVersion);

Console.WriteLine();
ConsoleHelper.Info("Verification Result:");
Console.WriteLine();
ConsoleHelper.Info(" Address: ", address);
ConsoleHelper.Info(" PublicKey: ", pubKey.EncodePoint(true).ToHexString());
ConsoleHelper.Info(" Signature: ", signature);
ConsoleHelper.Info(" Salt: ", saltHex);
ConsoleHelper.Info(" Status: ", isValid ? "Valid" : "Invalid");
Console.WriteLine();

if (!isValid)
{
ConsoleHelper.Info("Debug Information:");
Console.WriteLine();
ConsoleHelper.Info(" Message used: ", $"\"{message}\"");
ConsoleHelper.Info(" Message bytes: ", Encoding.UTF8.GetBytes(message).ToHexString());
ConsoleHelper.Info(" Salt+Message: ", $"{saltHex}{message}");
ConsoleHelper.Info(" Reconstructed Payload: ", payload.ToHexString());
ConsoleHelper.Info(" Payload Hash256: ", hash.ToString());
Console.WriteLine();
ConsoleHelper.Warning("Note: The message must match exactly what was signed.");
ConsoleHelper.Warning("If you used 'sign message \"Hello world!\"', the actual message signed is 'Hello world!' (without quotes).");
ConsoleHelper.Warning("If the signed message contains quote characters, you need to include them in the verify command.");
Console.WriteLine();
}
}
catch (Exception e)
{
ConsoleHelper.Error($"Verification failed: {GetExceptionMessage(e)}");
}
}

/// <summary>
/// Process "send" command
/// </summary>
Expand Down Expand Up @@ -735,7 +917,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;
Expand All @@ -762,4 +955,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-2026 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);
}
}
Loading