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