From 5f451c881881a9297a184a4f8ad9798eeb59615b Mon Sep 17 00:00:00 2001
From: Pavel Gross
Date: Fri, 11 Apr 2025 15:52:31 +0300
Subject: [PATCH] [#60] Wallet tools in SDK
Signed-off-by: Pavel Gross
---
.../FrostFS.SDK.Client.csproj | 1 +
src/FrostFS.SDK.Client/Tools/WalletTools.cs | 31 ++++++
src/FrostFS.SDK.Client/Wallets/Account.cs | 24 +++++
src/FrostFS.SDK.Client/Wallets/Contract.cs | 15 +++
src/FrostFS.SDK.Client/Wallets/Extra.cs | 6 ++
src/FrostFS.SDK.Client/Wallets/Parameter.cs | 12 +++
src/FrostFS.SDK.Client/Wallets/ScryptValue.cs | 15 +++
src/FrostFS.SDK.Client/Wallets/Wallet.cs | 18 ++++
src/FrostFS.SDK.Cryptography/Base58.cs | 2 +-
.../FrostFS.SDK.Cryptography.csproj | 1 +
src/FrostFS.SDK.Cryptography/Key.cs | 19 ++++
.../WalletExtractor.cs | 98 +++++++++++++++++++
src/FrostFS.SDK.Tests/TestData/wallet.json | 30 ++++++
src/FrostFS.SDK.Tests/Unit/WalletTests.cs | 25 +++++
14 files changed, 296 insertions(+), 1 deletion(-)
create mode 100644 src/FrostFS.SDK.Client/Tools/WalletTools.cs
create mode 100644 src/FrostFS.SDK.Client/Wallets/Account.cs
create mode 100644 src/FrostFS.SDK.Client/Wallets/Contract.cs
create mode 100644 src/FrostFS.SDK.Client/Wallets/Extra.cs
create mode 100644 src/FrostFS.SDK.Client/Wallets/Parameter.cs
create mode 100644 src/FrostFS.SDK.Client/Wallets/ScryptValue.cs
create mode 100644 src/FrostFS.SDK.Client/Wallets/Wallet.cs
create mode 100644 src/FrostFS.SDK.Cryptography/WalletExtractor.cs
create mode 100644 src/FrostFS.SDK.Tests/TestData/wallet.json
create mode 100644 src/FrostFS.SDK.Tests/Unit/WalletTests.cs
diff --git a/src/FrostFS.SDK.Client/FrostFS.SDK.Client.csproj b/src/FrostFS.SDK.Client/FrostFS.SDK.Client.csproj
index 1d16492..ed050a2 100644
--- a/src/FrostFS.SDK.Client/FrostFS.SDK.Client.csproj
+++ b/src/FrostFS.SDK.Client/FrostFS.SDK.Client.csproj
@@ -45,6 +45,7 @@
+
diff --git a/src/FrostFS.SDK.Client/Tools/WalletTools.cs b/src/FrostFS.SDK.Client/Tools/WalletTools.cs
new file mode 100644
index 0000000..45532a7
--- /dev/null
+++ b/src/FrostFS.SDK.Client/Tools/WalletTools.cs
@@ -0,0 +1,31 @@
+using System.Text.Json;
+using System;
+using FrostFS.SDK.Client.Wallets;
+using FrostFS.SDK.Cryptography;
+
+namespace FrostFS.SDK.Client;
+
+public static class WalletTools
+{
+ public static string GetWifFromWallet(string walletJsonText, byte[] password, int accountIndex = 0)
+ {
+ var wallet = JsonSerializer.Deserialize(walletJsonText) ?? throw new ArgumentException("Wrong wallet format");
+
+ if (wallet.Accounts == null || wallet.Accounts.Length < accountIndex + 1)
+ {
+ throw new ArgumentException("Wrong wallet content");
+ }
+
+ var encryptedKey = wallet.Accounts[accountIndex].Key;
+
+ if (string.IsNullOrEmpty(encryptedKey))
+ {
+ throw new ArgumentException("Cannot get encrypted WIF");
+ }
+
+ var privateKey = CryptoWallet.GetKeyFromEncodedWif(password, encryptedKey!);
+ var wif = privateKey.GetWIFFromPrivateKey();
+
+ return wif;
+ }
+}
diff --git a/src/FrostFS.SDK.Client/Wallets/Account.cs b/src/FrostFS.SDK.Client/Wallets/Account.cs
new file mode 100644
index 0000000..1d5b3eb
--- /dev/null
+++ b/src/FrostFS.SDK.Client/Wallets/Account.cs
@@ -0,0 +1,24 @@
+using System.Text.Json.Serialization;
+
+namespace FrostFS.SDK.Client.Wallets;
+
+public class Account
+{
+ [JsonPropertyName("address")]
+ public string? Address { get; set; }
+
+ [JsonPropertyName("key")]
+ public string? Key { get; set; }
+
+ [JsonPropertyName("label")]
+ public string? Label { get; set; }
+
+ [JsonPropertyName("contract")]
+ public Contract? Contract { get; set; }
+
+ [JsonPropertyName("lock")]
+ public bool Lock { get; set; }
+
+ [JsonPropertyName("isDefault")]
+ public bool IsDefault { get; set; }
+}
diff --git a/src/FrostFS.SDK.Client/Wallets/Contract.cs b/src/FrostFS.SDK.Client/Wallets/Contract.cs
new file mode 100644
index 0000000..b15d21d
--- /dev/null
+++ b/src/FrostFS.SDK.Client/Wallets/Contract.cs
@@ -0,0 +1,15 @@
+using System.Text.Json.Serialization;
+
+namespace FrostFS.SDK.Client.Wallets;
+
+public class Contract
+{
+ [JsonPropertyName("script")]
+ public string? Script { get; set; }
+
+ [JsonPropertyName("parameters")]
+ public Parameter[]? Parameters { get; set; }
+
+ [JsonPropertyName("deployed")]
+ public bool Deployed { get; set; }
+}
diff --git a/src/FrostFS.SDK.Client/Wallets/Extra.cs b/src/FrostFS.SDK.Client/Wallets/Extra.cs
new file mode 100644
index 0000000..bff8b8d
--- /dev/null
+++ b/src/FrostFS.SDK.Client/Wallets/Extra.cs
@@ -0,0 +1,6 @@
+namespace FrostFS.SDK.Client.Wallets;
+
+public class Extra
+{
+ public string? Tokens { get; set; }
+}
diff --git a/src/FrostFS.SDK.Client/Wallets/Parameter.cs b/src/FrostFS.SDK.Client/Wallets/Parameter.cs
new file mode 100644
index 0000000..c831e36
--- /dev/null
+++ b/src/FrostFS.SDK.Client/Wallets/Parameter.cs
@@ -0,0 +1,12 @@
+using System.Text.Json.Serialization;
+
+namespace FrostFS.SDK.Client.Wallets;
+
+public class Parameter
+{
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ [JsonPropertyName("type")]
+ public string? Type { get; set; }
+}
diff --git a/src/FrostFS.SDK.Client/Wallets/ScryptValue.cs b/src/FrostFS.SDK.Client/Wallets/ScryptValue.cs
new file mode 100644
index 0000000..2548b32
--- /dev/null
+++ b/src/FrostFS.SDK.Client/Wallets/ScryptValue.cs
@@ -0,0 +1,15 @@
+using System.Text.Json.Serialization;
+
+namespace FrostFS.SDK.Client.Wallets;
+
+public class ScryptValue
+{
+ [JsonPropertyName("n")]
+ public int N { get; set; }
+
+ [JsonPropertyName("r")]
+ public int R { get; set; }
+
+ [JsonPropertyName("p")]
+ public int P { get; set; }
+}
diff --git a/src/FrostFS.SDK.Client/Wallets/Wallet.cs b/src/FrostFS.SDK.Client/Wallets/Wallet.cs
new file mode 100644
index 0000000..e1439e0
--- /dev/null
+++ b/src/FrostFS.SDK.Client/Wallets/Wallet.cs
@@ -0,0 +1,18 @@
+using System.Text.Json.Serialization;
+
+namespace FrostFS.SDK.Client.Wallets;
+
+public class Wallet
+{
+ [JsonPropertyName("version")]
+ public string? Version { get; set; }
+
+ [JsonPropertyName("accounts")]
+ public Account[]? Accounts { get; set; }
+
+ [JsonPropertyName("scrypt")]
+ public ScryptValue? Scrypt { get; set; }
+
+ [JsonPropertyName("extra")]
+ public Extra? Extra { get; set; }
+}
diff --git a/src/FrostFS.SDK.Cryptography/Base58.cs b/src/FrostFS.SDK.Cryptography/Base58.cs
index 0026bc3..4dc6187 100644
--- a/src/FrostFS.SDK.Cryptography/Base58.cs
+++ b/src/FrostFS.SDK.Cryptography/Base58.cs
@@ -32,7 +32,7 @@ public static class Base58
public static string Base58CheckEncode(this Span data)
{
- byte[] checksum = DataHasher.Sha256(DataHasher.Sha256(data).AsSpan()); ;
+ byte[] checksum = DataHasher.Sha256(DataHasher.Sha256(data).AsSpan());
Span buffer = stackalloc byte[data.Length + 4];
data.CopyTo(buffer);
diff --git a/src/FrostFS.SDK.Cryptography/FrostFS.SDK.Cryptography.csproj b/src/FrostFS.SDK.Cryptography/FrostFS.SDK.Cryptography.csproj
index 04077e8..43f7187 100644
--- a/src/FrostFS.SDK.Cryptography/FrostFS.SDK.Cryptography.csproj
+++ b/src/FrostFS.SDK.Cryptography/FrostFS.SDK.Cryptography.csproj
@@ -35,6 +35,7 @@
runtime; build; native; contentfiles; analyzers; buildtransitive
+
diff --git a/src/FrostFS.SDK.Cryptography/Key.cs b/src/FrostFS.SDK.Cryptography/Key.cs
index 5f382fa..9eca104 100644
--- a/src/FrostFS.SDK.Cryptography/Key.cs
+++ b/src/FrostFS.SDK.Cryptography/Key.cs
@@ -82,6 +82,25 @@ public static class KeyExtension
return DataHasher.Sha256(script.AsSpan()).RIPEMD160();
}
+ public static string GetWIFFromPrivateKey(this byte[] privateKey)
+ {
+ if (privateKey == null || privateKey.Length != 32)
+ {
+ throw new ArgumentNullException(nameof(privateKey));
+ }
+
+ Span wifSpan = stackalloc byte[34];
+
+ wifSpan[0] = 0x80;
+ wifSpan[33] = 0x01;
+
+ privateKey.AsSpan().CopyTo(wifSpan.Slice(1));
+
+ var wif = Base58.Base58CheckEncode(wifSpan);
+
+ return wif;
+ }
+
private static string ToAddress(this byte[] scriptHash, byte version)
{
Span data = stackalloc byte[21];
diff --git a/src/FrostFS.SDK.Cryptography/WalletExtractor.cs b/src/FrostFS.SDK.Cryptography/WalletExtractor.cs
new file mode 100644
index 0000000..3e940f5
--- /dev/null
+++ b/src/FrostFS.SDK.Cryptography/WalletExtractor.cs
@@ -0,0 +1,98 @@
+using System;
+using System.Text;
+using System.Linq;
+using System.Security.Cryptography;
+using Org.BouncyCastle.Crypto.Generators;
+
+namespace FrostFS.SDK.Cryptography;
+
+public static class CryptoWallet
+{
+ private static int N = 16384;
+ private static int R = 8;
+ private static int P = 8;
+
+ private enum CipherAction
+ {
+ Encrypt,
+ Decrypt
+ }
+
+ public static byte[] GetKeyFromEncodedWif(byte[] password, string encryptedWIF)
+ {
+ var nep2Data = Base58.Base58CheckDecode(encryptedWIF);
+
+ if (nep2Data.Length == 39 && nep2Data[0] == 1 && nep2Data[1] == 66 && nep2Data[2] == 224)
+ {
+ var addressHash = nep2Data.AsSpan(3, 4).ToArray();
+ var derivedKey = SCrypt.Generate(password, addressHash, N, R, P, 64);
+ var derivedKeyHalf1 = derivedKey.Take(32).ToArray();
+ var derivedKeyHalf2 = derivedKey.Skip(32).ToArray();
+ var encrypted = nep2Data.AsSpan(7, 32).ToArray();
+ var decrypted = Aes(encrypted, derivedKeyHalf2, CipherAction.Decrypt);
+ var plainPrivateKey = XorRange(decrypted, derivedKeyHalf1, 0, decrypted.Length);
+
+ return plainPrivateKey;
+ }
+ else
+ {
+ throw new ArgumentException("Not valid NEP2 prefix.");
+ }
+ }
+
+ public static string EncryptWif(string plainText, ECDsa key)
+ {
+ var addressHash = GetAddressHash(key);
+ var derivedKey = SCrypt.Generate(Encoding.UTF8.GetBytes(plainText), addressHash, N, R, P, 64);
+ var derivedHalf1 = derivedKey.Take(32).ToArray();
+ var derivedHalf2 = derivedKey.Skip(32).ToArray();
+ var encryptedHalf1 = Aes(XorRange(key.PrivateKey(), derivedHalf1, 0, 16), derivedHalf2, CipherAction.Encrypt);
+ var encryptedHalf2 = Aes(XorRange(key.PrivateKey(), derivedHalf1, 16, 32), derivedHalf2, CipherAction.Encrypt);
+ var prefixes = new byte[] { 1, 66, 224 };
+ var concatenation = ArrayHelper.Concat([prefixes, addressHash, encryptedHalf1, encryptedHalf2]);
+
+ return Base58.Base58CheckEncode(concatenation);
+ }
+
+ public static byte[] GetAddressHash(ECDsa key)
+ {
+ string address = key.PublicKey().PublicKeyToAddress();
+
+ using SHA256 sha256 = SHA256.Create();
+ byte[] addressHashed = sha256.ComputeHash(Encoding.UTF8.GetBytes(address));
+ return [.. addressHashed.Take(4)];
+ }
+
+ private static byte[] XorRange(byte[] arr1, byte[] arr2, int from, int length)
+ {
+ byte[] result = new byte[length];
+
+ var j = 0;
+ for (var i = from; i < length; ++i)
+ {
+ result[j++] = (byte)(arr1[i] ^ arr2[i]);
+ }
+
+ return result;
+ }
+
+ private static byte[] Aes(byte[] data, byte[] key, CipherAction action)
+ {
+ using var aes = System.Security.Cryptography.Aes.Create();
+ aes.Key = key;
+ aes.Mode = CipherMode.ECB;
+ aes.Padding = PaddingMode.None;
+
+ if (action == CipherAction.Encrypt)
+ {
+ return aes.CreateEncryptor().TransformFinalBlock(data, 0, data.Length);
+ }
+
+ if (action == CipherAction.Decrypt)
+ {
+ return aes.CreateDecryptor().TransformFinalBlock(data, 0, data.Length);
+ }
+
+ throw new ArgumentException("Wrong cippher action", nameof(action));
+ }
+}
diff --git a/src/FrostFS.SDK.Tests/TestData/wallet.json b/src/FrostFS.SDK.Tests/TestData/wallet.json
new file mode 100644
index 0000000..7a4bdd4
--- /dev/null
+++ b/src/FrostFS.SDK.Tests/TestData/wallet.json
@@ -0,0 +1,30 @@
+{
+ "version": "1.0",
+ "accounts": [
+ {
+ "address": "NWeByJPgNC97F83hTUnSbnZSBKaFvk5HNw",
+ "key": "6PYVCcS2yp89JpcfR61FGhdhhzyYjSErNedmpZErnybNTxUZMRdhzJLrek",
+ "label": "",
+ "contract": {
+ "script": "DCEDJOdiiPy5ABANAYAqFO+XfMpFrQc1YSMERt8Us0TIWLZBVuezJw==",
+ "parameters": [
+ {
+ "name": "parameter0",
+ "type": "Signature"
+ }
+ ],
+ "deployed": false
+ },
+ "lock": false,
+ "isDefault": false
+ }
+ ],
+ "scrypt": {
+ "n": 16384,
+ "r": 8,
+ "p": 8
+ },
+ "extra": {
+ "Tokens": null
+ }
+}
\ No newline at end of file
diff --git a/src/FrostFS.SDK.Tests/Unit/WalletTests.cs b/src/FrostFS.SDK.Tests/Unit/WalletTests.cs
new file mode 100644
index 0000000..2acd77e
--- /dev/null
+++ b/src/FrostFS.SDK.Tests/Unit/WalletTests.cs
@@ -0,0 +1,25 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text;
+using FrostFS.SDK.Client;
+
+namespace FrostFS.SDK.Tests.Unit;
+
+[SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task", Justification = "Default Value is correct for tests")]
+public class WalletTest : SessionTestsBase
+{
+ [Fact]
+ public void TestWallet()
+ {
+ var password = Encoding.UTF8.GetBytes("");
+
+ var d = Directory.GetCurrentDirectory();
+ var path = ".\\..\\..\\..\\TestData\\wallet.json";
+ Assert.True(File.Exists(path));
+
+ var content = File.ReadAllText(path);
+
+ var wif = WalletTools.GetWifFromWallet(content, password);
+
+ Assert.Equal("KzPXA6669m2pf18XmUdoR8MnP1pi1PMmefiFujStVFnv7WR5SRmK", wif);
+ }
+}
\ No newline at end of file
--
2.45.3