[#60] Wallet tools in SDK #62

Merged
PavelGrossSpb merged 1 commit from PavelGrossSpb/frostfs-sdk-csharp:WalletTools into master 2025-04-11 13:12:07 +00:00
14 changed files with 296 additions and 1 deletions

View file

@ -45,6 +45,7 @@
<PackageReference Include="Microsoft.Extensions.Options" Version="7.0.1" /> <PackageReference Include="Microsoft.Extensions.Options" Version="7.0.1" />
<PackageReference Include="System.Diagnostics.DiagnosticSource" Version="7.0.0" /> <PackageReference Include="System.Diagnostics.DiagnosticSource" Version="7.0.0" />
<PackageReference Include="System.Runtime.Caching" Version="7.0.0" /> <PackageReference Include="System.Runtime.Caching" Version="7.0.0" />
<PackageReference Include="System.Text.Json" Version="7.0.4" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View file

@ -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<Wallet>(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;
}
}

View file

@ -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; }
}

View file

@ -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; }
}

View file

@ -0,0 +1,6 @@
namespace FrostFS.SDK.Client.Wallets;
public class Extra
{
public string? Tokens { get; set; }
}

View file

@ -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; }
}

View file

@ -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; }
}

View file

@ -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; }
}

View file

@ -32,7 +32,7 @@ public static class Base58
public static string Base58CheckEncode(this Span<byte> data) public static string Base58CheckEncode(this Span<byte> data)
{ {
byte[] checksum = DataHasher.Sha256(DataHasher.Sha256(data).AsSpan()); ; byte[] checksum = DataHasher.Sha256(DataHasher.Sha256(data).AsSpan());
Span<byte> buffer = stackalloc byte[data.Length + 4]; Span<byte> buffer = stackalloc byte[data.Length + 4];
data.CopyTo(buffer); data.CopyTo(buffer);

View file

@ -35,6 +35,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="System.Memory" Version="4.5.5" /> <PackageReference Include="System.Memory" Version="4.5.5" />
<PackageReference Include="System.Text.Json" Version="7.0.4" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -82,6 +82,25 @@ public static class KeyExtension
return DataHasher.Sha256(script.AsSpan()).RIPEMD160(); 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<byte> 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) private static string ToAddress(this byte[] scriptHash, byte version)
{ {
Span<byte> data = stackalloc byte[21]; Span<byte> data = stackalloc byte[21];

View file

@ -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));
}
}

View file

@ -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
}
}

View file

@ -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);
}
}