From 1afe66dfcec00b2bc5cb734f6d3ce9fd05254039 Mon Sep 17 00:00:00 2001 From: 0xFirekeeper <0xFirekeeper@gmail.com> Date: Sat, 30 Mar 2024 04:04:16 +0300 Subject: [PATCH] embedded wallet --- Directory.Packages.props | 3 + Thirdweb.Console/Program.cs | 31 +- .../Thirdweb.Contracts/ThirdwebContract.cs | 2 +- Thirdweb/Thirdweb.Wallets/IWallet.cs | 17 +- .../Thirdweb.Wallets.Embedded/Embedded.cs | 209 +++++++ .../EmbeddedWallet.Authentication/AWS.cs | 110 ++++ .../Server.Types.cs | 293 +++++++++ .../EmbeddedWallet.Authentication/Server.cs | 571 ++++++++++++++++++ .../EmbeddedWallet.Cryptography.cs | 136 +++++ .../EmbeddedWallet.Encryption/IvGenerator.cs | 73 +++ .../EmbeddedWallet.Encryption/Secrets.cs | 476 +++++++++++++++ .../VerificationException.cs | 20 + .../EmbeddedWallet.Models/User.cs | 16 + .../EmbeddedWallet.Models/UserStatus.cs | 10 + .../LocalStorage.Types.cs | 72 +++ .../EmbeddedWallet.Storage/LocalStorage.cs | 108 ++++ .../EmbeddedWallet.AuthEndpoint.cs | 13 + .../EmbeddedWallet/EmbeddedWallet.EmailOTP.cs | 62 ++ .../EmbeddedWallet/EmbeddedWallet.JWT.cs | 13 + .../EmbeddedWallet/EmbeddedWallet.Misc.cs | 223 +++++++ .../EmbeddedWallet/EmbeddedWallet.OAuth.cs | 27 + .../EmbeddedWallet/EmbeddedWallet.PhoneOTP.cs | 41 ++ .../EmbeddedWallet/EmbeddedWallet.cs | 25 + .../EmbeddedWallet.cs | 7 - .../PrivateKey.cs} | 23 +- Thirdweb/Thirdweb.Wallets/ThirdwebAccount.cs | 39 +- .../ThirdwebAccountOptions.cs | 4 +- Thirdweb/Thirdweb.csproj | 3 + 28 files changed, 2592 insertions(+), 35 deletions(-) create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/Embedded.cs create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Authentication/AWS.cs create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Authentication/Server.Types.cs create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Authentication/Server.cs create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Encryption/EmbeddedWallet.Cryptography.cs create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Encryption/IvGenerator.cs create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Encryption/Secrets.cs create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Exceptions/VerificationException.cs create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Models/User.cs create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Models/UserStatus.cs create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Storage/LocalStorage.Types.cs create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Storage/LocalStorage.cs create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.AuthEndpoint.cs create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.EmailOTP.cs create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.JWT.cs create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.Misc.cs create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.OAuth.cs create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.PhoneOTP.cs create mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.cs delete mode 100644 Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.EmbeddedWallet/EmbeddedWallet.cs rename Thirdweb/Thirdweb.Wallets/{Thirdweb.Wallets.PrivateKeyWallet/PrivateKeyWallet.cs => Thirdweb.Wallets.PrivateKey/PrivateKey.cs} (90%) diff --git a/Directory.Packages.props b/Directory.Packages.props index 5759a8f..049fdeb 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,5 +16,8 @@ + + + \ No newline at end of file diff --git a/Thirdweb.Console/Program.cs b/Thirdweb.Console/Program.cs index d937303..ca8b262 100644 --- a/Thirdweb.Console/Program.cs +++ b/Thirdweb.Console/Program.cs @@ -1,6 +1,5 @@ using System.Numerics; using dotenv.net; -using Nethereum.Hex.HexTypes; using Newtonsoft.Json; using Thirdweb; @@ -27,8 +26,36 @@ var readResult = await ThirdwebContract.ReadContract(contract, "name"); Console.WriteLine($"Contract read result: {readResult}"); -var accountOptions = new ThirdwebAccountOptions(client: client, type: WalletType.PrivateKey, privateKey: privateKey); +var accountOptions = new ThirdwebAccountOptions(client: client, type: WalletType.Embedded, email: "firekeeper@thirdweb.com"); var account = new ThirdwebAccount(accountOptions); +await account.Initialize(); +var isConnected = account.IsConnected(); +await account.Disconnect(); // Force email + +if (!account.IsConnected()) +{ + if (account.Wallet is not Embedded embedded) + { + throw new Exception("Account is not connected and is not an embedded wallet."); + } + await embedded.SendOTP(); + var otp = Console.ReadLine(); + (var address, var canRetry) = await embedded.SubmitOTP(otp); + if (canRetry) + { + Console.WriteLine("OTP failed. Please try again."); + var otp2 = Console.ReadLine(); + (address, _) = await embedded.SubmitOTP(otp2); + } + if (string.IsNullOrEmpty(address)) + { + throw new Exception("Unknown Error, Null Address"); + } + else + { + Console.WriteLine($"Successfully Logged In! Address: {address}"); + } +} Console.WriteLine($"Wallet address: {account.GetAddress()}"); var message = "Hello, Thirdweb!"; diff --git a/Thirdweb/Thirdweb.Contracts/ThirdwebContract.cs b/Thirdweb/Thirdweb.Contracts/ThirdwebContract.cs index aaa55a0..de4963b 100644 --- a/Thirdweb/Thirdweb.Contracts/ThirdwebContract.cs +++ b/Thirdweb/Thirdweb.Contracts/ThirdwebContract.cs @@ -64,7 +64,7 @@ public static async Task WriteContract(ThirdwebAccount account, Thirdweb transaction.Nonce = new HexBigInteger(await rpc.SendRequestAsync("eth_getTransactionCount", account.GetAddress(), "latest")); string hash; - if (account.Options.Type == WalletType.PrivateKey) + if (account.Options.Type == WalletType.PrivateKey || account.Options.Type == WalletType.Embedded) { var signedTx = account.SignTransaction(transaction, contract.Chain); Console.WriteLine($"Signed transaction: {signedTx}"); diff --git a/Thirdweb/Thirdweb.Wallets/IWallet.cs b/Thirdweb/Thirdweb.Wallets/IWallet.cs index 963bf15..4fb8086 100644 --- a/Thirdweb/Thirdweb.Wallets/IWallet.cs +++ b/Thirdweb/Thirdweb.Wallets/IWallet.cs @@ -4,13 +4,16 @@ namespace Thirdweb { - internal interface IWallet + public interface IWallet { - string GetAddress(); - string EthSign(string message); - string PersonalSign(string message); - string SignTypedDataV4(string json); - string SignTypedDataV4(T data, TypedData typedData); - string SignTransaction(TransactionInput transaction, BigInteger chainId); + internal Task Initialize(); + internal string GetAddress(); + internal string EthSign(string message); + internal string PersonalSign(string message); + internal string SignTypedDataV4(string json); + internal string SignTypedDataV4(T data, TypedData typedData); + internal string SignTransaction(TransactionInput transaction, BigInteger chainId); + internal bool IsConnected(); + internal Task Disconnect(); } } diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/Embedded.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/Embedded.cs new file mode 100644 index 0000000..7192bdc --- /dev/null +++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/Embedded.cs @@ -0,0 +1,209 @@ +using System.Numerics; +using System.Text; +using Nethereum.ABI.EIP712; +using Nethereum.Hex.HexConvertors.Extensions; +using Nethereum.Hex.HexTypes; +using Nethereum.Model; +using Nethereum.RPC.Eth.DTOs; +using Nethereum.RPC.Eth.Mappers; +using Nethereum.Signer; +using Nethereum.Signer.EIP712; +using Thirdweb; +using Thirdweb.EWS; + +public class Embedded : IWallet +{ + EmbeddedWallet _embeddedWallet; + User _user; + EthECKey _ecKey; + string _email; + + internal Embedded(ThirdwebClient client, string email) + { + if (string.IsNullOrEmpty(email)) + { + throw new ArgumentException("Email must be provided to use Embedded Wallets."); + } + + _embeddedWallet = new EmbeddedWallet(client); + _email = email; + } + + public async Task Initialize() + { + try + { + _user = await _embeddedWallet.GetUserAsync(_email, "EmailOTP"); + _ecKey = new EthECKey(_user.Account.PrivateKey); + } + catch + { + Console.WriteLine("User not found. Please call Embedded.LoginWithOTP() to create a new user."); + _user = null; + _ecKey = null; + } + } + + #region Email OTP Flow + + public async Task SendOTP() + { + if (string.IsNullOrEmpty(_email)) + { + throw new Exception("Email is required for OTP login"); + } + + try + { + (bool isNewUser, bool isNewDevice, bool needsRecoveryCode) = await _embeddedWallet.SendOtpEmailAsync(_email); + Console.WriteLine("OTP sent to email. Please call SubmitOTP to login."); + } + catch (Exception e) + { + throw new Exception("Failed to send OTP email", e); + } + } + + public async Task<(string, bool)> SubmitOTP(string otp) + { + var res = await _embeddedWallet.VerifyOtpAsync(_email, otp, null); + if (res.User == null) + { + bool canRetry = res.CanRetry; + if (canRetry) + { + Console.WriteLine("Invalid OTP. Please try again."); + } + else + { + Console.WriteLine("Invalid OTP. Please request a new OTP."); + } + return (null, canRetry); + } + else + { + _user = res.User; + _ecKey = new EthECKey(_user.Account.PrivateKey); + return (GetAddress(), false); + } + } + + #endregion + + public string GetAddress() + { + return _ecKey.GetPublicAddress(); + } + + public string EthSign(string message) + { + if (message == null) + { + throw new ArgumentNullException(nameof(message), "Message to sign cannot be null."); + } + + var signer = new MessageSigner(); + var signature = signer.Sign(Encoding.UTF8.GetBytes(message), _ecKey); + return signature; + } + + public string PersonalSign(string message) + { + if (message == null) + { + throw new ArgumentNullException(nameof(message), "Message to sign cannot be null."); + } + + var signer = new EthereumMessageSigner(); + var signature = signer.EncodeUTF8AndSign(message, _ecKey); + return signature; + } + + public string SignTypedDataV4(string json) + { + if (json == null) + { + throw new ArgumentNullException(nameof(json), "Json to sign cannot be null."); + } + + var signer = new Eip712TypedDataSigner(); + var signature = signer.SignTypedDataV4(json, _ecKey); + return signature; + } + + public string SignTypedDataV4(T data, TypedData typedData) + { + if (data == null) + { + throw new ArgumentNullException(nameof(data), "Data to sign cannot be null."); + } + + var signer = new Eip712TypedDataSigner(); + var signature = signer.SignTypedDataV4(data, typedData, _ecKey); + return signature; + } + + public string SignTransaction(TransactionInput transaction, BigInteger chainId) + { + if (transaction == null) + { + throw new ArgumentNullException(nameof(transaction)); + } + + if (string.IsNullOrWhiteSpace(transaction.From)) + { + transaction.From = GetAddress(); + } + else if (transaction.From != GetAddress()) + { + throw new Exception("Transaction 'From' address does not match the wallet address"); + } + + var nonce = transaction.Nonce ?? throw new ArgumentNullException(nameof(transaction), "Transaction nonce has not been set"); + + var gasLimit = transaction.Gas; + var value = transaction.Value ?? new HexBigInteger(0); + + string signedTransaction; + if (transaction.Type != null && transaction.Type.Value == TransactionType.EIP1559.AsByte()) + { + var maxPriorityFeePerGas = transaction.MaxPriorityFeePerGas.Value; + var maxFeePerGas = transaction.MaxFeePerGas.Value; + var transaction1559 = new Transaction1559( + chainId, + nonce, + maxPriorityFeePerGas, + maxFeePerGas, + gasLimit, + transaction.To, + value, + transaction.Data, + transaction.AccessList.ToSignerAccessListItemArray() + ); + + var signer = new Transaction1559Signer(); + signer.SignTransaction(_ecKey, transaction1559); + signedTransaction = transaction1559.GetRLPEncoded().ToHex(); + } + else + { + var gasPrice = transaction.GasPrice; + var legacySigner = new LegacyTransactionSigner(); + signedTransaction = legacySigner.SignTransaction(_ecKey.GetPrivateKey(), chainId, transaction.To, value.Value, nonce, gasPrice.Value, gasLimit.Value, transaction.Data); + } + + return "0x" + signedTransaction; + } + + public bool IsConnected() + { + return _ecKey != null; + } + + public async Task Disconnect() + { + await _embeddedWallet.SignOutAsync(); + _user = null; + _ecKey = null; + } +} diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Authentication/AWS.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Authentication/AWS.cs new file mode 100644 index 0000000..b1624f1 --- /dev/null +++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Authentication/AWS.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Amazon; +using Amazon.CognitoIdentity; +using Amazon.CognitoIdentityProvider; +using Amazon.CognitoIdentityProvider.Model; +using Amazon.Extensions.CognitoAuthentication; +using Amazon.Lambda; +using Amazon.Lambda.Model; +using Amazon.Runtime; + +namespace Thirdweb.EWS +{ + internal class AWS + { + private static readonly RegionEndpoint awsRegion = RegionEndpoint.USWest2; + private const string cognitoAppClientId = "2e02ha2ce6du13ldk8pai4h3d0"; + private static readonly string cognitoIdentityPoolId = $"{awsRegion.SystemName}:2ad7ab1e-f48b-48a6-adfa-ac1090689c26"; + private static readonly string cognitoUserPoolId = $"{awsRegion.SystemName}_UFwLcZIpq"; + private static readonly string recoverySharePasswordLambdaFunctionName = + $"arn:aws:lambda:{awsRegion.SystemName}:324457261097:function:recovery-share-password-GenerateRecoverySharePassw-bbE5ZbVAToil"; + + internal static async Task SignUpCognitoUserAsync(string emailAddress, string userName) + { + emailAddress ??= "cognito@thirdweb.com"; + AmazonCognitoIdentityProviderClient provider = new(new AnonymousAWSCredentials(), awsRegion); + CognitoUserPool userPool = new(cognitoUserPoolId, cognitoAppClientId, provider); + Dictionary userAttributes = new() { { "email", emailAddress }, }; + await userPool.SignUpAsync(userName, Secrets.Random(12), userAttributes, new Dictionary()); + } + + internal static async Task StartCognitoUserAuth(string userName) + { + // https://stackoverflow.com/questions/66258459/how-to-get-aws-cognito-access-token-with-username-and-password-in-net-core-3-1 + AmazonCognitoIdentityProviderClient provider = new(new AnonymousAWSCredentials(), awsRegion); + CognitoUserPool userPool = new(cognitoUserPoolId, cognitoAppClientId, provider); + CognitoUser user = new(userName, cognitoAppClientId, userPool, provider); + InitiateCustomAuthRequest customRequest = + new() + { + AuthParameters = new Dictionary() { { "USERNAME", userName }, }, + ClientMetadata = new Dictionary(), + }; + try + { + AuthFlowResponse authResponse = await user.StartWithCustomAuthAsync(customRequest); + return authResponse.SessionID; + } + catch (UserNotFoundException) + { + return null; + } + } + + internal static async Task FinishCognitoUserAuth(string userName, string otp, string sessionId) + { + AmazonCognitoIdentityProviderClient provider = new(new AnonymousAWSCredentials(), awsRegion); + CognitoUserPool userPool = new(cognitoUserPoolId, cognitoAppClientId, provider); + CognitoUser user = new(userName, cognitoAppClientId, userPool, provider); + RespondToCustomChallengeRequest challengeRequest = + new() + { + ChallengeParameters = new Dictionary() { { "USERNAME", userName }, { "ANSWER", otp }, }, + ClientMetadata = new Dictionary(), + SessionID = sessionId, + }; + try + { + AuthFlowResponse authResponse = await user.RespondToCustomAuthAsync(challengeRequest); + AuthenticationResultType result = authResponse.AuthenticationResult ?? throw new VerificationException("The OTP is incorrect", true); + return new TokenCollection(result.AccessToken, result.IdToken, result.RefreshToken); + } + catch (NotAuthorizedException) + { + throw new VerificationException("The session expired", false); + } + catch (UserNotFoundException) + { + throw new InvalidOperationException("The user was not found"); + } + } + + internal static async Task InvokeRecoverySharePasswordLambdaAsync(string idToken, string invokePayload) + { + InvokeRequest request = new() { FunctionName = recoverySharePasswordLambdaFunctionName, Payload = invokePayload, }; + CognitoAWSCredentials credentials = new(cognitoIdentityPoolId, awsRegion); + string providerName = $"cognito-idp.{awsRegion.SystemName}.amazonaws.com/{cognitoUserPoolId}"; + credentials.AddLogin(providerName, idToken); + AmazonLambdaClient client = new(credentials, awsRegion); + InvokeResponse lambdaResponse = await client.InvokeAsync(request); + return lambdaResponse.Payload; + } + } + + internal class TokenCollection + { + internal TokenCollection(string accessToken, string idToken, string refreshToken) + { + AccessToken = accessToken; + IdToken = idToken; + RefreshToken = refreshToken; + } + + public string AccessToken { get; } + public string IdToken { get; } + public string RefreshToken { get; } + } +} diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Authentication/Server.Types.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Authentication/Server.Types.cs new file mode 100644 index 0000000..3f4f39b --- /dev/null +++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Authentication/Server.Types.cs @@ -0,0 +1,293 @@ +using System.Linq; +using System.Runtime.Serialization; + +namespace Thirdweb.EWS +{ + internal partial class Server + { + internal class VerifyResult + { + internal VerifyResult(bool isNewUser, string authToken, string walletUserId, string recoveryCode, string email) + { + IsNewUser = isNewUser; + AuthToken = authToken; + WalletUserId = walletUserId; + RecoveryCode = recoveryCode; + Email = email; + } + + internal bool IsNewUser { get; } + internal string AuthToken { get; } + internal string WalletUserId { get; } + internal string RecoveryCode { get; } + internal string Email { get; } + } + +#pragma warning disable CS0169, CS8618, IDE0051 // Deserialization will construct the following classes. + [DataContract] + private class AuthVerifiedTokenReturnType + { + [DataMember(Name = "verifiedToken")] + internal VerifiedTokenType VerifiedToken { get; set; } + + [DataMember(Name = "verifiedTokenJwtString")] + internal string VerifiedTokenJwtString { get; set; } + + [DataContract] + internal class VerifiedTokenType + { + [DataMember(Name = "authDetails")] + internal UserAuthDetails AuthDetails { get; set; } + + [DataMember] + private string authProvider; + + [DataMember] + private string developerClientId; + + [DataMember(Name = "isNewUser")] + internal bool IsNewUser { get; set; } + + [DataMember] + private string rawToken; + + [DataMember] + private string userId; + } + } + + [DataContract] + private class GetUserStatusApiReturnType + { + [DataMember] +#pragma warning disable CS0649 // Deserialization will populate this field. + private string status; +#pragma warning restore CS0649 // Field 'Server.GetUserStatusApiReturnType.status' is never assigned to, and will always have its default value null + internal UserStatus Status => (UserStatus)status.Length; + + [DataMember] + private StoredTokenType storedToken; + + [DataMember(Name = "user")] + internal UserType User { get; set; } + + [DataContract] + internal class UserType + { + [DataMember(Name = "authDetails")] + internal UserAuthDetails AuthDetails { get; set; } + + [DataMember] + private string walletAddress; + } + } + + [DataContract] + private class HttpErrorWithMessage + { + [DataMember(Name = "error")] + internal string Error { get; set; } = ""; + + [DataMember(Name = "message")] + internal string Message { get; set; } = ""; + } + + [DataContract] + private class SharesGetResponse + { + [DataMember(Name = "authShare")] + internal string AuthShare { get; set; } + + [DataMember(Name = "maybeEncryptedRecoveryShares")] + internal string[] MaybeEncryptedRecoveryShares { get; set; } + } + + [DataContract] + private class IsEmailUserOtpValidResponse + { + [DataMember(Name = "isValid")] + internal bool IsValid { get; set; } + } + + [DataContract] + private class IsEmailKmsOtpValidResponse + { + [DataMember(Name = "isOtpValid")] + internal bool IsOtpValid { get; set; } + } + + [DataContract] + private class HeadlessOauthLoginLinkResponse + { + [DataMember(Name = "googleLoginLink")] + internal string GoogleLoginLink { get; set; } + + [DataMember(Name = "platformLoginLink")] + internal string PlatformLoginLink { get; set; } + + [DataMember(Name = "oauthLoginLink")] + internal string OauthLoginLink { get; set; } + } + + [DataContract] + internal class StoredTokenType + { + [DataMember] + private string jwtToken; + + [DataMember] + private string authProvider; + + [DataMember(Name = "authDetails")] + internal UserAuthDetails AuthDetails { get; set; } + + [DataMember] + private string developerClientId; + + [DataMember] + private string cookieString; + + [DataMember] + private bool isNewUser; + } + + [DataContract] + internal class UserAuthDetails + { + [DataMember(Name = "email")] + internal string Email { get; set; } + + [DataMember(Name = "userWalletId")] + internal string WalletUserId { get; set; } + + [DataMember(Name = "recoveryShareManagement")] + internal string RecoveryShareManagement { get; set; } + + [DataMember(Name = "recoveryCode")] + internal string RecoveryCode { get; set; } + + [DataMember(Name = "backupRecoveryCodes")] + internal string[] BackupRecoveryCodes { get; set; } + } + + [DataContract] + internal class UserWallet + { + [DataMember(Name = "status")] + internal string Status { get; set; } + + [DataMember(Name = "isNewUser")] + internal bool IsNewUser { get; set; } + + [DataMember(Name = "walletUserId")] + internal string WalletUserId { get; set; } + + [DataMember(Name = "recoveryShareManagement")] + internal string RecoveryShareManagement { get; set; } + + [DataMember(Name = "storedToken")] + internal StoredTokenType StoredToken { get; set; } + } + + [DataContract] + private class IdTokenResponse + { + [DataMember(Name = "accessToken")] + internal string AccessToken { get; set; } + + [DataMember(Name = "idToken")] + internal string IdToken { get; set; } + } + + [DataContract] + private class RecoverySharePasswordResponse + { + [DataMember(Name = "body")] + internal string Body { get; set; } + + [DataMember(Name = "recoveryShareEncKey")] + internal string RecoverySharePassword { get; set; } + } + + [DataContract] + internal class RecoveryShareManagementResponse + { + internal string Value => data.oauth.FirstOrDefault()?.recovery_share_management; +#pragma warning disable CS0649 // Deserialization will populate these fields. + [DataMember] + private RecoveryShareManagementResponse data; + + [DataMember] + private RecoveryShareManagementResponse[] oauth; + + [DataMember] + private string recovery_share_management; +#pragma warning restore CS0649 // Field 'Server.RecoveryShareManagementResponse.*' is never assigned to, and will always have its default value null + } + + [DataContract] + internal class AuthResultType_OAuth + { + [DataMember(Name = "storedToken")] + internal StoredTokenType_OAuth StoredToken { get; set; } + + [DataMember(Name = "walletDetails")] + internal WalletDetailsType_OAuth WalletDetails { get; set; } + } + + [DataContract] + internal class StoredTokenType_OAuth + { + [DataMember(Name = "jwtToken")] + internal string JwtToken { get; set; } + + [DataMember(Name = "authProvider")] + internal string AuthProvider { get; set; } + + [DataMember(Name = "authDetails")] + internal AuthDetailsType_OAuth AuthDetails { get; set; } + + [DataMember(Name = "developerClientId")] + internal string DeveloperClientId { get; set; } + + [DataMember(Name = "cookieString")] + internal string CookieString { get; set; } + + [DataMember(Name = "shouldStoreCookieString")] + internal bool ShouldStoreCookieString { get; set; } + + [DataMember(Name = "isNewUser")] + internal bool IsNewUser { get; set; } + + [DataContract] + internal class AuthDetailsType_OAuth + { + [DataMember(Name = "email")] + internal string Email { get; set; } + + [DataMember(Name = "userWalletId")] + internal string UserWalletId { get; set; } + + [DataMember(Name = "recoveryCode")] + internal string RecoveryCode { get; set; } + } + } + + [DataContract] + internal class WalletDetailsType_OAuth + { + [DataMember(Name = "deviceShareStored")] + internal string DeviceShareStored { get; set; } + + [DataMember(Name = "isIframeStorageEnabled")] + internal bool IsIframeStorageEnabled { get; set; } + + [DataMember(Name = "walletAddress")] + internal string WalletAddress { get; set; } + } + +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. +#pragma warning restore CS0169 // The field 'Server.*' is never used +#pragma warning restore IDE0051 // The field 'Server.*' is unused + } +} diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Authentication/Server.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Authentication/Server.cs new file mode 100644 index 0000000..bf8ce01 --- /dev/null +++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Authentication/Server.cs @@ -0,0 +1,571 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using Newtonsoft.Json; + +namespace Thirdweb.EWS +{ + internal abstract class ServerBase + { + internal abstract Task VerifyThirdwebClientIdAsync(string domain); + internal abstract Task FetchDeveloperWalletSettings(); + internal abstract Task FetchUserDetailsAsync(string emailAddress, string authToken); + internal abstract Task StoreAddressAndSharesAsync(string walletAddress, string authShare, string encryptedRecoveryShare, string authToken, string[] backupRecoveryShares); + + internal abstract Task<(string authShare, string recoveryShare)> FetchAuthAndRecoverySharesAsync(string authToken); + internal abstract Task FetchAuthShareAsync(string authToken); + internal abstract Task FetchHeadlessOauthLoginLinkAsync(string authProvider); + + internal abstract Task CheckIsEmailKmsOtpValidAsync(string userName, string otp); + internal abstract Task CheckIsEmailUserOtpValidAsync(string emailAddress, string otp); + + internal abstract Task SendUserOtpEmailAsync(string emailAddress); + internal abstract Task SendRecoveryCodeEmailAsync(string authToken, string recoveryCode, string email); + internal abstract Task VerifyUserOtpAsync(string emailAddress, string otp); + + internal abstract Task SendKmsOtpEmailAsync(string emailAddress); + internal abstract Task VerifyKmsOtpAsync(string emailAddress, string otp, string sessionId); + + internal abstract Task SendKmsPhoneOtpAsync(string phoneNumber); + internal abstract Task VerifyKmsPhoneOtpAsync(string phoneNumber, string otp, string sessionId); + + internal abstract Task VerifyJwtAsync(string jwtToken); + + internal abstract Task VerifyOAuthAsync(string authVerifiedToken); + + internal abstract Task VerifyAuthEndpointAsync(string payload); + } + + internal partial class Server : ServerBase + { + private const string ROOT_URL = "https://embedded-wallet.thirdweb.com"; + private const string ROOT_URL_LEGACY = "https://ews.thirdweb.com"; + private const string API_ROOT_PATH = "/api/2023-10-20"; + private const string API_ROOT_PATH_LEGACY = "/api/2022-08-12"; + private const string BUNDLE_ID_HEADER = "x-bundle-id"; + private const string THIRDWEB_CLIENT_ID_HEADER = "x-thirdweb-client-id"; + private const string THIRDWEB_SECRET_KEY_HEADER = "x-thirdweb-client-id"; + private const string SESSION_NONCE_HEADER = "x-session-nonce"; + private const string EMBEDDED_WALLET_VERSION_HEADER = "x-embedded-wallet-version"; + + private static readonly MediaTypeHeaderValue jsonContentType = MediaTypeHeaderValue.Parse("application/json"); + private static readonly HttpClient httpClient = new(); + + private readonly string clientId; + + internal Server(string clientId, string bundleId, string platform, string version, string secretKey) + { + this.clientId = clientId; + + httpClient.DefaultRequestHeaders.Clear(); + + if (!string.IsNullOrEmpty(clientId)) + { + httpClient.DefaultRequestHeaders.Add(THIRDWEB_CLIENT_ID_HEADER, clientId); + } + + if (!string.IsNullOrEmpty(bundleId)) + { + httpClient.DefaultRequestHeaders.Add(BUNDLE_ID_HEADER, bundleId); + } + + if (!string.IsNullOrEmpty(secretKey)) + { + httpClient.DefaultRequestHeaders.Add(THIRDWEB_SECRET_KEY_HEADER, secretKey); + } + + httpClient.DefaultRequestHeaders.Add(SESSION_NONCE_HEADER, Guid.NewGuid().ToString()); + httpClient.DefaultRequestHeaders.Add(EMBEDDED_WALLET_VERSION_HEADER, $"{platform}:{version}"); + } + + // embedded-wallet/verify-thirdweb-client-id + internal override async Task VerifyThirdwebClientIdAsync(string parentDomain) + { + Dictionary queryParams = new() { { "clientId", clientId }, { "parentDomain", parentDomain } }; + Uri uri = MakeUri("/embedded-wallet/verify-thirdweb-client-id", queryParams); + StringContent content = MakeHttpContent(new { clientId, parentDomain }); + HttpResponseMessage response = await httpClient.PostAsync(uri, content); + await CheckStatusCodeAsync(response); + var error = await DeserializeAsync(response); + return error.Error; + } + + // embedded-wallet/developer-wallet-settings + internal override async Task FetchDeveloperWalletSettings() + { + try + { + Dictionary queryParams = new() { { "clientId", clientId }, }; + Uri uri = MakeUri("/embedded-wallet/developer-wallet-settings", queryParams); + HttpResponseMessage response = await httpClient.GetAsync(uri); + var responseContent = await DeserializeAsync(response); + return responseContent.Value ?? "AWS_MANAGED"; + } + catch (System.Exception e) + { + Console.WriteLine("Could not fetch recovery share management type, defaulting to managed: " + e.Message); + return "AWS_MANAGED"; + } + } + + // embedded-wallet/embedded-wallet-user-details + internal override async Task FetchUserDetailsAsync(string emailAddress, string authToken) + { + Dictionary queryParams = new(); + if (emailAddress == null && authToken == null) + { + throw new InvalidOperationException("Must provide either email address or auth token"); + } + + queryParams.Add("email", emailAddress ?? "uninitialized"); + queryParams.Add("clientId", clientId); + + Uri uri = MakeUri("/embedded-wallet/embedded-wallet-user-details", queryParams); + HttpResponseMessage response = await SendHttpWithAuthAsync(uri, authToken ?? ""); + await CheckStatusCodeAsync(response); + var rv = await DeserializeAsync(response); + return rv; + } + + // embedded-wallet/embedded-wallet-shares POST + internal override async Task StoreAddressAndSharesAsync(string walletAddress, string authShare, string encryptedRecoveryShare, string authToken, string[] backupRecoveryShares) + { + var encryptedRecoveryShares = + backupRecoveryShares == null + ? new[] { new { share = encryptedRecoveryShare, isClientEncrypted = "true" } } + : new[] { new { share = encryptedRecoveryShare, isClientEncrypted = "true" } }.Concat(backupRecoveryShares.Select((s) => new { share = s, isClientEncrypted = "true" })).ToArray(); + + HttpRequestMessage httpRequestMessage = + new(HttpMethod.Post, MakeUri("/embedded-wallet/embedded-wallet-shares")) + { + Content = MakeHttpContent( + new + { + authShare, + maybeEncryptedRecoveryShares = encryptedRecoveryShares, + walletAddress, + } + ), + }; + HttpResponseMessage response = await SendHttpWithAuthAsync(httpRequestMessage, authToken); + await CheckStatusCodeAsync(response); + } + + // embedded-wallet/embedded-wallet-shares GET + internal override async Task<(string authShare, string recoveryShare)> FetchAuthAndRecoverySharesAsync(string authToken) + { + SharesGetResponse sharesGetResponse = await FetchRemoteSharesAsync(authToken, true); + string authShare = sharesGetResponse.AuthShare ?? throw new InvalidOperationException("Server failed to return auth share"); + string encryptedRecoveryShare = sharesGetResponse.MaybeEncryptedRecoveryShares?.FirstOrDefault() ?? throw new InvalidOperationException("Server failed to return recovery share"); + return (authShare, encryptedRecoveryShare); + } + + // embedded-wallet/embedded-wallet-shares GET + internal override async Task FetchAuthShareAsync(string authToken) + { + SharesGetResponse sharesGetResponse = await FetchRemoteSharesAsync(authToken, false); + return sharesGetResponse.AuthShare ?? throw new InvalidOperationException("Server failed to return auth share"); + } + + // embedded-wallet/embedded-wallet-shares GET + private async Task FetchRemoteSharesAsync(string authToken, bool wantsRecoveryShare) + { + Dictionary queryParams = + new() + { + { "getEncryptedAuthShare", "true" }, + { "getEncryptedRecoveryShare", wantsRecoveryShare ? "true" : "false" }, + { "useSealedSecret", "false" } + }; + Uri uri = MakeUri("/embedded-wallet/embedded-wallet-shares", queryParams); + HttpResponseMessage response = await SendHttpWithAuthAsync(uri, authToken); + await CheckStatusCodeAsync(response); + var rv = await DeserializeAsync(response); + return rv; + } + + // embedded-wallet/cognito-id-token + private async Task FetchCognitoIdTokenAsync(string authToken) + { + Uri uri = MakeUri("/embedded-wallet/cognito-id-token"); + HttpResponseMessage response = await SendHttpWithAuthAsync(uri, authToken); + await CheckStatusCodeAsync(response); + return await DeserializeAsync(response); + } + + // embedded-wallet/headless-oauth-login-link + internal override async Task FetchHeadlessOauthLoginLinkAsync(string authProvider) + { + // based on above unity implementation, adapt to this class + Uri uri = MakeUri( + "/embedded-wallet/headless-oauth-login-link", + new Dictionary + { + { "platform", "unity" }, + { "authProvider", authProvider }, + { "baseUrl", "https://embedded-wallet.thirdweb.com" } + } + ); + + HttpResponseMessage response = await httpClient.GetAsync(uri); + await CheckStatusCodeAsync(response); + var rv = await DeserializeAsync(response); + return rv.PlatformLoginLink; + } + + // /embedded-wallet/is-cognito-otp-valid + internal override async Task CheckIsEmailKmsOtpValidAsync(string email, string otp) + { + Uri uri = MakeUriLegacy( + "/embedded-wallet/is-cognito-otp-valid", + new Dictionary + { + { "email", email }, + { "code", otp }, + { "clientId", clientId } + } + ); + HttpResponseMessage response = await httpClient.GetAsync(uri); + await CheckStatusCodeAsync(response); + var result = await DeserializeAsync(response); + return result.IsOtpValid; + } + + // embedded-wallet/is-thirdweb-email-otp-valid + internal override async Task CheckIsEmailUserOtpValidAsync(string email, string otp) + { + Uri uri = MakeUri("/embedded-wallet/is-thirdweb-email-otp-valid"); + StringContent content = MakeHttpContent( + new + { + email, + otp, + clientId, + } + ); + HttpResponseMessage response = await httpClient.PostAsync(uri, content); + await CheckStatusCodeAsync(response); + var result = await DeserializeAsync(response); + return result.IsValid; + } + + // embedded-wallet/send-user-managed-email-otp + internal override async Task SendUserOtpEmailAsync(string emailAddress) + { + Uri uri = MakeUri("/embedded-wallet/send-user-managed-email-otp"); + StringContent content = MakeHttpContent(new { clientId, email = emailAddress }); + HttpResponseMessage response = await httpClient.PostAsync(uri, content); + await CheckStatusCodeAsync(response); + } + + // embedded-wallet/send-wallet-recovery-code + internal override async Task SendRecoveryCodeEmailAsync(string authToken, string recoveryCode, string email) + { + HttpRequestMessage httpRequestMessage = + new(HttpMethod.Post, MakeUri("/embedded-wallet/send-wallet-recovery-code")) + { + Content = MakeHttpContent( + new + { + strategy = "email", + clientId, + email, + recoveryCode + } + ), + }; + try + { + HttpResponseMessage response = await SendHttpWithAuthAsync(httpRequestMessage, authToken); + await CheckStatusCodeAsync(response); + } + catch (Exception ex) + { + throw new InvalidOperationException("Error sending recovery code email", ex); + } + } + + // embedded-wallet/validate-thirdweb-email-otp + internal override async Task VerifyUserOtpAsync(string emailAddress, string otp) + { + Uri uri = MakeUri("/embedded-wallet/validate-thirdweb-email-otp"); + StringContent content = MakeHttpContent( + new + { + clientId, + email = emailAddress, + otp + } + ); + HttpResponseMessage response = await httpClient.PostAsync(uri, content); + await CheckStatusCodeAsync(response); + var authVerifiedToken = await DeserializeAsync(response); + return new VerifyResult( + authVerifiedToken.VerifiedToken.IsNewUser, + authVerifiedToken.VerifiedTokenJwtString, + authVerifiedToken.VerifiedToken.AuthDetails.WalletUserId, + authVerifiedToken.VerifiedToken.AuthDetails.RecoveryCode, + authVerifiedToken.VerifiedToken.AuthDetails.Email + ); + } + + // KMS Send + internal override async Task SendKmsOtpEmailAsync(string emailAddress) + { + string userName = MakeCognitoUserName(emailAddress, "email"); + string sessionId = await AWS.StartCognitoUserAuth(userName); + if (sessionId == null) + { + await AWS.SignUpCognitoUserAsync(emailAddress, userName); + for (int i = 0; i < 3; ++i) + { + await Task.Delay(3333 * i); + sessionId = await AWS.StartCognitoUserAuth(userName); + if (sessionId != null) + { + break; + } + } + if (sessionId == null) + { + throw new InvalidOperationException("Cannot find user within timeout period"); + } + } + return sessionId; + } + + // embedded-wallet/validate-cognito-email-otp + internal override async Task VerifyKmsOtpAsync(string emailAddress, string otp, string sessionId) + { + string userName = MakeCognitoUserName(emailAddress, "email"); + TokenCollection tokens = await AWS.FinishCognitoUserAuth(userName, otp, sessionId); + Uri uri = MakeUri("/embedded-wallet/validate-cognito-email-otp"); + ByteArrayContent content = MakeHttpContent( + new + { + developerClientId = clientId, + access_token = tokens.AccessToken, + id_token = tokens.IdToken, + refresh_token = tokens.RefreshToken, + otpMethod = "email", + } + ); + HttpResponseMessage response = await httpClient.PostAsync(uri, content); + await CheckStatusCodeAsync(response); + var authVerifiedToken = await DeserializeAsync(response); + bool isNewUser = authVerifiedToken.VerifiedToken.IsNewUser; + string authToken = authVerifiedToken.VerifiedTokenJwtString; + string walletUserId = authVerifiedToken.VerifiedToken.AuthDetails.WalletUserId; + var idTokenResponse = await FetchCognitoIdTokenAsync(authToken); + string idToken = idTokenResponse.IdToken; + string invokePayload = Serialize(new { accessToken = idTokenResponse.AccessToken, idToken = idTokenResponse.IdToken }); + MemoryStream responsePayload = await AWS.InvokeRecoverySharePasswordLambdaAsync(idToken, invokePayload); + JsonSerializer jsonSerializer = new(); + var payload = jsonSerializer.Deserialize(new JsonTextReader(new StreamReader(responsePayload))); + payload = jsonSerializer.Deserialize(new JsonTextReader(new StringReader(payload.Body))); + return new VerifyResult(isNewUser, authToken, walletUserId, payload.RecoverySharePassword, authVerifiedToken.VerifiedToken.AuthDetails.Email); + } + + internal override async Task SendKmsPhoneOtpAsync(string phoneNumber) + { + string userName = MakeCognitoUserName(phoneNumber, "sms"); + string sessionId = await AWS.StartCognitoUserAuth(userName); + if (sessionId == null) + { + await AWS.SignUpCognitoUserAsync(null, userName); + for (int i = 0; i < 3; ++i) + { + await Task.Delay(3333 * i); + sessionId = await AWS.StartCognitoUserAuth(userName); + if (sessionId != null) + { + break; + } + } + if (sessionId == null) + { + throw new InvalidOperationException("Cannot find user within timeout period"); + } + } + return sessionId; + } + + // embedded-wallet/validate-cognito-email-otp + internal override async Task VerifyKmsPhoneOtpAsync(string phoneNumber, string otp, string sessionId) + { + string userName = MakeCognitoUserName(phoneNumber, "sms"); + TokenCollection tokens = await AWS.FinishCognitoUserAuth(userName, otp, sessionId); + Uri uri = MakeUri("/embedded-wallet/validate-cognito-email-otp"); + ByteArrayContent content = MakeHttpContent( + new + { + developerClientId = clientId, + access_token = tokens.AccessToken, + id_token = tokens.IdToken, + refresh_token = tokens.RefreshToken, + otpMethod = "email", + } + ); + HttpResponseMessage response = await httpClient.PostAsync(uri, content); + await CheckStatusCodeAsync(response); + var authVerifiedToken = await DeserializeAsync(response); + bool isNewUser = authVerifiedToken.VerifiedToken.IsNewUser; + string authToken = authVerifiedToken.VerifiedTokenJwtString; + string walletUserId = authVerifiedToken.VerifiedToken.AuthDetails.WalletUserId; + var idTokenResponse = await FetchCognitoIdTokenAsync(authToken); + string idToken = idTokenResponse.IdToken; + string invokePayload = Serialize(new { accessToken = idTokenResponse.AccessToken, idToken = idTokenResponse.IdToken }); + MemoryStream responsePayload = await AWS.InvokeRecoverySharePasswordLambdaAsync(idToken, invokePayload); + JsonSerializer jsonSerializer = new(); + var payload = jsonSerializer.Deserialize(new JsonTextReader(new StreamReader(responsePayload))); + payload = jsonSerializer.Deserialize(new JsonTextReader(new StringReader(payload.Body))); + return new VerifyResult(isNewUser, authToken, walletUserId, payload.RecoverySharePassword, authVerifiedToken.VerifiedToken.AuthDetails.Email); + } + + // embedded-wallet/validate-custom-jwt + internal override async Task VerifyJwtAsync(string jwtToken) + { + var requestContent = new { jwt = jwtToken, developerClientId = clientId }; + StringContent content = MakeHttpContent(requestContent); + Uri uri = MakeUri("/embedded-wallet/validate-custom-jwt"); + HttpResponseMessage response = await httpClient.PostAsync(uri, content); + await CheckStatusCodeAsync(response); + var authVerifiedToken = await DeserializeAsync(response); + bool isNewUser = authVerifiedToken.VerifiedToken.IsNewUser; + string authToken = authVerifiedToken.VerifiedTokenJwtString; + string walletUserId = authVerifiedToken.VerifiedToken.AuthDetails.WalletUserId; + string email = authVerifiedToken.VerifiedToken.AuthDetails.Email; + string recoveryCode = authVerifiedToken.VerifiedToken.AuthDetails.RecoveryCode; + return new VerifyResult(isNewUser, authToken, walletUserId, recoveryCode, email); + } + + // embedded-wallet/validate-custom-auth-endpoint + internal override async Task VerifyAuthEndpointAsync(string payload) + { + var requestContent = new { payload, developerClientId = clientId }; + StringContent content = MakeHttpContent(requestContent); + Uri uri = MakeUri("/embedded-wallet/validate-custom-auth-endpoint"); + HttpResponseMessage response = await httpClient.PostAsync(uri, content); + await CheckStatusCodeAsync(response); + var authVerifiedToken = await DeserializeAsync(response); + bool isNewUser = authVerifiedToken.VerifiedToken.IsNewUser; + string authToken = authVerifiedToken.VerifiedTokenJwtString; + string walletUserId = authVerifiedToken.VerifiedToken.AuthDetails.WalletUserId; + string email = authVerifiedToken.VerifiedToken.AuthDetails.Email; + string recoveryCode = authVerifiedToken.VerifiedToken.AuthDetails.RecoveryCode; + return new VerifyResult(isNewUser, authToken, walletUserId, recoveryCode, email); + } + + internal override async Task VerifyOAuthAsync(string authResultStr) + { + var authResult = JsonConvert.DeserializeObject(authResultStr); + bool isNewUser = authResult.StoredToken.IsNewUser; + string authToken = authResult.StoredToken.CookieString; + string walletUserId = authResult.StoredToken.AuthDetails.UserWalletId; + bool isUserManaged = (await FetchUserDetailsAsync(authResult.StoredToken.AuthDetails.Email, authToken)).RecoveryShareManagement == "USER_MANAGED"; + string recoveryCode = null; + if (!isUserManaged) + { + var idTokenResponse = await FetchCognitoIdTokenAsync(authToken); + string idToken = idTokenResponse.IdToken; + string invokePayload = Serialize(new { accessToken = idTokenResponse.AccessToken, idToken = idTokenResponse.IdToken }); + MemoryStream responsePayload = await AWS.InvokeRecoverySharePasswordLambdaAsync(idToken, invokePayload); + JsonSerializer jsonSerializer = new(); + var payload = jsonSerializer.Deserialize(new JsonTextReader(new StreamReader(responsePayload))); + payload = jsonSerializer.Deserialize(new JsonTextReader(new StringReader(payload.Body))); + recoveryCode = payload.RecoverySharePassword; + } + return new VerifyResult(isNewUser, authToken, walletUserId, recoveryCode, authResult.StoredToken.AuthDetails.Email); + } + + #region Misc + + private Task SendHttpWithAuthAsync(HttpRequestMessage httpRequestMessage, string authToken) + { + httpRequestMessage.Headers.Add("Authorization", $"Bearer embedded-wallet-token:{authToken}"); +#if DEBUG + Console.WriteLine($"Request: {JsonConvert.SerializeObject(httpRequestMessage)}"); +#endif + return httpClient.SendAsync(httpRequestMessage); + } + + private Task SendHttpWithAuthAsync(Uri uri, string authToken) + { + HttpRequestMessage httpRequestMessage = new(HttpMethod.Get, uri); +#if DEBUG + Console.WriteLine($"Request: {JsonConvert.SerializeObject(httpRequestMessage)}"); +#endif + return SendHttpWithAuthAsync(httpRequestMessage, authToken); + } + + private static async Task CheckStatusCodeAsync(HttpResponseMessage response) + { +#if DEBUG + Console.WriteLine($"Response: {await response.Content.ReadAsStringAsync()}"); +#endif + if (!response.IsSuccessStatusCode) + { + var error = await DeserializeAsync(response); + throw new InvalidOperationException(string.IsNullOrEmpty(error.Error) ? error.Message : error.Error); + } + } + + private static async Task DeserializeAsync(HttpResponseMessage response) + { + JsonSerializer jsonSerializer = new(); + TextReader textReader = new StreamReader(await response.Content.ReadAsStreamAsync()); + var rv = jsonSerializer.Deserialize(new JsonTextReader(textReader)); + return rv; + } + + private static Uri MakeUri(string path, IDictionary parameters = null) + { + UriBuilder b = new(ROOT_URL) { Path = API_ROOT_PATH + path, }; + if (parameters != null && parameters.Any()) + { + string queryString = string.Join('&', parameters.Select((p) => $"{p.Key}={Uri.EscapeDataString(p.Value)}")); + b.Query = queryString; + } + return b.Uri; + } + + private static Uri MakeUriLegacy(string path, IDictionary parameters = null) + { + UriBuilder b = new(ROOT_URL_LEGACY) { Path = API_ROOT_PATH_LEGACY + path, }; + if (parameters != null && parameters.Any()) + { + string queryString = string.Join('&', parameters.Select((p) => $"{p.Key}={Uri.EscapeDataString(p.Value)}")); + b.Query = queryString; + } + return b.Uri; + } + + private static StringContent MakeHttpContent(object data) + { + StringContent stringContent = new(Serialize(data)); + stringContent.Headers.ContentType = jsonContentType; + return stringContent; + } + + private static string Serialize(object data) + { + JsonSerializer jsonSerializer = new() { NullValueHandling = NullValueHandling.Ignore, }; + StringWriter stringWriter = new(); + jsonSerializer.Serialize(stringWriter, data); + string rv = stringWriter.ToString(); + + return rv; + } + + private string MakeCognitoUserName(string userData, string type) + { + return $"{userData}:{type}:{clientId}"; + } + + #endregion + } +} diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Encryption/EmbeddedWallet.Cryptography.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Encryption/EmbeddedWallet.Cryptography.cs new file mode 100644 index 0000000..e5d0787 --- /dev/null +++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Encryption/EmbeddedWallet.Cryptography.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using Nethereum.Web3.Accounts; +using Org.BouncyCastle.Crypto.Engines; +using Org.BouncyCastle.Crypto.Modes; +using Org.BouncyCastle.Crypto.Parameters; + +namespace Thirdweb.EWS +{ + internal partial class EmbeddedWallet + { + private string DecryptShare(string encryptedShare, string password) + { + string[] parts = encryptedShare.Split(ENCRYPTION_SEPARATOR); + byte[] ciphertextWithTag = Convert.FromBase64String(parts[0]); + byte[] iv = Convert.FromBase64String(parts[1]); + byte[] salt = Convert.FromBase64String(parts[2]); + + int iterationCount; + if (parts.Length > 3 && int.TryParse(parts[3], out var parsedIterationCount)) + { + iterationCount = parsedIterationCount; + } + else + { + iterationCount = DEPRECATED_ITERATION_COUNT; + } + + byte[] key = GetEncryptionKey(password, salt, iterationCount); + + byte[] encodedShare; + try + { + // Bouncy Castle expects the authentication tag after the ciphertext. + GcmBlockCipher cipher = new(new AesEngine()); + cipher.Init(forEncryption: false, new AeadParameters(new KeyParameter(key), 8 * TAG_SIZE, iv)); + encodedShare = new byte[cipher.GetOutputSize(ciphertextWithTag.Length)]; + int offset = cipher.ProcessBytes(ciphertextWithTag, 0, ciphertextWithTag.Length, encodedShare, 0); + cipher.DoFinal(encodedShare, offset); + } + catch + { + try + { + int ciphertextSize = ciphertextWithTag.Length - TAG_SIZE; + var ciphertext = new byte[ciphertextSize]; + Array.Copy(ciphertextWithTag, ciphertext, ciphertext.Length); + var tag = new byte[TAG_SIZE]; + Array.Copy(ciphertextWithTag, ciphertextSize, tag, 0, tag.Length); + encodedShare = new byte[ciphertext.Length]; + using AesGcm crypto = new(key); + crypto.Decrypt(iv, ciphertext, tag, encodedShare); + } + catch (CryptographicException) + { + throw new VerificationException("Invalid recovery code", true); + } + } + string share = Encoding.ASCII.GetString(encodedShare); + return share; + } + + private async Task EncryptShareAsync(string share, string password) + { + const int saltSize = 16; + var salt = new byte[saltSize]; + RandomNumberGenerator.Fill(salt); + byte[] key = GetEncryptionKey(password, salt, CURRENT_ITERATION_COUNT); + byte[] encodedShare = Encoding.ASCII.GetBytes(share); + const int ivSize = 12; + var iv = new byte[ivSize]; + await ivGenerator.ComputeIvAsync(iv); + byte[] encryptedShare; + try + { + // Bouncy Castle includes the authentication tag after the ciphertext. + GcmBlockCipher cipher = new(new AesEngine()); + cipher.Init(forEncryption: true, new AeadParameters(new KeyParameter(key), 8 * TAG_SIZE, iv)); + encryptedShare = new byte[cipher.GetOutputSize(encodedShare.Length)]; + int offset = cipher.ProcessBytes(encodedShare, 0, encodedShare.Length, encryptedShare, 0); + cipher.DoFinal(encryptedShare, offset); + } + catch + { + var tag = new byte[TAG_SIZE]; + encryptedShare = new byte[encodedShare.Length]; + using AesGcm crypto = new(key); + crypto.Encrypt(iv, encodedShare, encryptedShare, tag); + encryptedShare = encryptedShare.Concat(tag).ToArray(); + } + string rv = + $"{Convert.ToBase64String(encryptedShare)}{ENCRYPTION_SEPARATOR}{Convert.ToBase64String(iv)}{ENCRYPTION_SEPARATOR}{Convert.ToBase64String(salt)}{ENCRYPTION_SEPARATOR}{CURRENT_ITERATION_COUNT}"; + return rv; + } + + private (string deviceShare, string recoveryShare, string authShare) CreateShares(string secret) + { + Secrets secrets = new(); + secret = $"{WALLET_PRIVATE_KEY_PREFIX}{secret}"; + string encodedSecret = Secrets.GetHexString(Encoding.ASCII.GetBytes(secret)); + List shares = secrets.Share(encodedSecret, 3, 2); + return (shares[0], shares[1], shares[2]); + } + + private static byte[] GetEncryptionKey(string password, byte[] salt, int iterationCount) + { + using Rfc2898DeriveBytes pbkdf2 = new(password, salt, iterationCount, HashAlgorithmName.SHA256); + byte[] keyMaterial = pbkdf2.GetBytes(KEY_SIZE); + return keyMaterial; + } + + private Account MakeAccountFromShares(params string[] shares) + { + Secrets secrets = new(); + string encodedSecret = secrets.Combine(shares); + string secret = Encoding.ASCII.GetString(Secrets.GetBytes(encodedSecret)); + if (!secret.StartsWith(WALLET_PRIVATE_KEY_PREFIX)) + { + throw new InvalidOperationException($"Corrupted share encountered {secret}"); + } + return new Account(secret.Split(WALLET_PRIVATE_KEY_PREFIX)[1]); + } + + private string MakeRecoveryCode() + { + const int codeSize = 16; + const string characters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + string recoveryCode = new(Enumerable.Range(0, codeSize).Select((_) => characters[RandomNumberGenerator.GetInt32(characters.Length)]).ToArray()); + return recoveryCode; + } + } +} diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Encryption/IvGenerator.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Encryption/IvGenerator.cs new file mode 100644 index 0000000..50754fc --- /dev/null +++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Encryption/IvGenerator.cs @@ -0,0 +1,73 @@ +using System.Security.Cryptography; +#if UNITY_5_3_OR_NEWER +using UnityEngine; +#endif + +namespace Thirdweb.EWS +{ + internal abstract class IvGeneratorBase + { + internal abstract Task ComputeIvAsync(byte[] iv); + } + + internal class IvGenerator : IvGeneratorBase + { + private long prbsValue; + private readonly string ivFilePath; + private const int nPrbsBits = 48; + private const long prbsPeriod = (1L << nPrbsBits) - 1; + private static readonly long taps = new int[] { nPrbsBits, 47, 21, 20 }.Aggregate(0L, (a, b) => a + (1L << (nPrbsBits - b))); // https://docs.xilinx.com/v/u/en-US/xapp052, page 5 + + internal IvGenerator() + { + string directory; +#if UNITY_5_3_OR_NEWER + directory = Application.persistentDataPath; +#else + directory = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); +#endif + directory = Path.Combine(directory, "EWS"); + Directory.CreateDirectory(directory); + ivFilePath = Path.Combine(directory, "iv.txt"); + try + { + prbsValue = long.Parse(File.ReadAllText(ivFilePath)); + } + catch (Exception) + { + prbsValue = (0x434a49445a27 ^ DateTime.Now.Ticks) & prbsPeriod; + } + } + + /// + /// Compute IV using half LFSR-generated and half random bytes. + /// + /// https://crypto.stackexchange.com/questions/84357/what-are-the-rules-for-using-aes-gcm-correctly + /// The IV byte array to fill. This must be twelve bytes in size. + internal override async Task ComputeIvAsync(byte[] iv) + { + RandomNumberGenerator.Fill(iv); + prbsValue = ComputeNextPrbsValue(prbsValue); + await File.WriteAllTextAsync(ivFilePath, prbsValue.ToString()); + byte[] prbsBytes = Enumerable.Range(0, nPrbsBits / 8).Select((i) => (byte)(prbsValue >> (8 * i))).ToArray(); + Array.Copy(prbsBytes, iv, prbsBytes.Length); + } + + /// + /// Compute the next value of a PRBS using a 48-bit Galois LFSR. + /// + /// https://en.wikipedia.org/wiki/Linear-feedback_shift_register + /// The current PRBS value. + /// The next value. + private static long ComputeNextPrbsValue(long prbsValue) + { + prbsValue <<= 1; + if ((prbsValue & (1L << nPrbsBits)) != 0) + { + prbsValue ^= taps; + prbsValue &= prbsPeriod; + } + return prbsValue; + } + } +} diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Encryption/Secrets.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Encryption/Secrets.cs new file mode 100644 index 0000000..8c6266d --- /dev/null +++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Encryption/Secrets.cs @@ -0,0 +1,476 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; + +namespace Thirdweb.EWS +{ + internal class Secrets + { + private Config config = new(Defaults.nBits); + private const int nHexDigitBits = 4; + private readonly Func GetRandomInt32 = (nBits) => RandomNumberGenerator.GetInt32(1, 1 << nBits); + private static readonly string padding = string.Join("", Enumerable.Repeat("0", Defaults.maxPaddingMultiple)); + private static readonly string[] nybbles = { "0000", "0001", "0010", "0011", "0100", "0101", "0110", "0111", "1000", "1001", "1010", "1011", "1100", "1101", "1110", "1111", }; + + /// + /// Reconsitute a secret from . + /// + /// + /// The return value will not be the original secret if the number of shares provided is less than the threshold + /// number of shares. + /// Duplicate shares do not count toward the threshold. + /// + /// The shares used to reconstitute the secret. + /// The reconstituted secret. + public string Combine(IReadOnlyList shares) + { + return Combine(shares, 0); + } + + /// + /// Convert a string of hexadecimal digits into a byte array. + /// + /// The string of hexadecimal digits to convert. + /// A byte array. + public static byte[] GetBytes(string s) + { + byte[] bytes = Enumerable.Range(0, s.Length / 2).Select((i) => byte.Parse(s.Substring(i * 2, 2), NumberStyles.HexNumber)).ToArray(); + return bytes; + } + + /// + /// Convert a byte array into a string of hexadecimal digits. + /// + /// The byte array to convert. + /// A string of hexadecimal digits. + public static string GetHexString(byte[] bytes) + { + return BitConverter.ToString(bytes).Replace("-", "").ToLowerInvariant(); + } + + /// + /// Generate a new share identified as . + /// + /// + /// The return value will be invalid if the number of shares provided is less than the threshold number of shares. + /// If is the identifier of a share in and the number of shares + /// provided is at least the threshold number of shares, the return value will be the same as the identified share. + /// Duplicate shares do not count toward the threshold. + /// + /// The identifier of the share to generate. + /// The shares from which to generate the new share. + /// A hexadecimal string of the new share. + /// + /// + public string NewShare(int shareId, IReadOnlyList shares) + { + if (shareId <= 0) + { + throw new ArgumentOutOfRangeException(nameof(shareId), $"{nameof(shareId)} must be greater than zero."); + } + else if (shares == null || !shares.Any() || string.IsNullOrEmpty(shares[0])) + { + throw new ArgumentException($"{nameof(shares)} cannot be empty.", nameof(shares)); + } + ShareComponents share = ExtractShareComponents(shares[0]); + return ConstructPublicShareString(share.nBits, Convert.ToString(shareId, Defaults.radix), Combine(shares, shareId)); + } + + /// + /// Generate a random value expressed as a string of hexadecimal digits that contains bytes using a + /// secure random number generator. + /// + /// The number of bytes of output. + /// A hexadecimal string of the value. + /// + public static string Random(int nBytes) + { + const int maxnBytes = (1 << 16) / 8; + if (nBytes < 1 || nBytes > maxnBytes) + { + throw new ArgumentOutOfRangeException(nameof(nBytes), $"{nameof(nBytes)} must be in the range [1, {maxnBytes}]."); + } + var bytes = new byte[nBytes]; + RandomNumberGenerator.Fill(bytes); + string rv = GetHexString(bytes); + return rv; + } + + /// + /// Divide a into + /// shares, requiring shares to + /// reconstruct the secret. Optionally, initialize with . Optionally, zero-pad the secret to a length + /// that is a multiple of (default 128) before sharing. + /// + /// A secret value expressed as a string of hexadecimal digits. + /// The number of shares to produce. + /// The number of shares required to reconstruct the secret. + /// The number of bits to use to create the shares. + /// The amount of zero-padding to apply to the secret before sharing. + /// A list of strings of hexadecimal digits. + /// + /// + public List Share(string secret, int nShares, int threshold, int nBits = 0, int paddingMultiple = 128) + { + // Initialize based on nBits if it's specified. + if (nBits != 0) + { + if (nBits < Defaults.minnBits || nBits > Defaults.maxnBits) + { + throw new ArgumentOutOfRangeException(nameof(nBits), $"{nameof(nBits)} must be in the range [{Defaults.minnBits}, {Defaults.maxnBits}]."); + } + config = new(nBits); + } + + // Validate the parameters. + if (string.IsNullOrEmpty(secret)) + { + throw new ArgumentException($"{nameof(secret)} cannot be empty.", nameof(secret)); + } + else if (!secret.All((ch) => char.IsDigit(ch) || (ch >= 'A' && ch <= 'F') || (ch >= 'a' && ch <= 'f'))) + { + throw new ArgumentException($"{nameof(secret)} must consist only of hexadecimal digits.", nameof(secret)); + } + else if (nShares < 2 || nShares > Math.Min(config.maxnShares, Defaults.maxnShares)) + { + if (nShares > Defaults.maxnShares) + { + throw new ArgumentOutOfRangeException(nameof(nShares), $"The maximum number of shares is {Defaults.maxnShares} since the maximum bit count is {Defaults.maxnBits}."); + } + else if (nShares > config.maxnShares) + { + throw new ArgumentOutOfRangeException( + nameof(nShares), + $"{nameof(nShares)} must be in the range [2, {config.maxnShares}]. To create {nShares} shares, specify at least {Math.Ceiling(Math.Log(nShares + 1, 2))} bits." + ); + } + throw new ArgumentOutOfRangeException(nameof(nShares), $"{nameof(nShares)} must be in the range [2, {config.maxnShares}]."); + } + else if (threshold < 2 || threshold > nShares) + { + throw new ArgumentOutOfRangeException(nameof(threshold), $"{nameof(threshold)} must be in the range [2, {nShares}]."); + } + else if (paddingMultiple < 0 || paddingMultiple > 1024) + { + throw new ArgumentOutOfRangeException(nameof(paddingMultiple), $"{nameof(paddingMultiple)} must be in the range [0, {Defaults.maxPaddingMultiple}]."); + } + + // Prepend a 1 as a marker to preserve the correct number of leading zeros in the secret. + secret = "1" + Hex2bin(secret); + + // Create the shares. For additional security, pad in multiples of 128 bits by default. This is a small trade-off in larger + // share size to help prevent leakage of information about small secrets and increase the difficulty of attacking them. + List l = SplitNumStringToIntArray(secret, paddingMultiple); + var x = new string[nShares]; + var y = new string[nShares]; + foreach (int value in l) + { + var subShares = GetShares(value, nShares, threshold); + for (int i = 0; i < nShares; ++i) + { + x[i] = Convert.ToString(subShares[i].x, Defaults.radix); + y[i] = PadLeft(Convert.ToString(subShares[i].y, 2), config.nBits) + (y[i] ?? ""); + } + } + for (int i = 0; i < nShares; ++i) + { + x[i] = ConstructPublicShareString(config.nBits, x[i], Bin2hex(y[i])); + } + return x.ToList(); + } + + private static string Bin2hex(string value) + { + value = PadLeft(value, nHexDigitBits); + StringBuilder sb = new(); + for (int i = 0; i < value.Length; i += nHexDigitBits) + { + int num = Convert.ToInt32(value.Substring(i, nHexDigitBits), 2); + sb.Append(Convert.ToString(num, 16)); + } + return sb.ToString(); + } + + private string Combine(IReadOnlyList shares, int shareId) + { + // Zip distinct shares. E.g. + // [ [ 193, 186, 29, 177, 196 ], + // [ 53, 105, 139, 127, 149 ], + // [ 146, 211, 249, 206, 81 ] ] + // becomes + // [ [ 193, 53, 146 ], + // [ 186, 105, 211 ], + // [ 29, 139, 249 ], + // [ 177, 127, 206 ], + // [ 196, 149, 81 ] ] + int nBits = 0; + List x = new(); + List> y = new(); + foreach (ShareComponents share in shares.Select((s) => ExtractShareComponents(s))) + { + // All shares must have the same bits settings. + if (nBits == 0) + { + nBits = share.nBits; + + // Reconfigure based on the bits settings of the shares. + if (config.nBits != nBits) + { + config = new(nBits); + } + } + else if (share.nBits != nBits) + { + throw new ArgumentException("Shares are mismatched due to different bits settings.", nameof(shares)); + } + + // Spread the share across the arrays if the share.id is not already in array `x`. + if (x.IndexOf(share.id) == -1) + { + x.Add(share.id); + List splitShare = SplitNumStringToIntArray(Hex2bin(share.data)); + for (int i = 0, n = splitShare.Count; i < n; ++i) + { + if (i >= y.Count) + { + y.Add(new List()); + } + y[i].Add(splitShare[i]); + } + } + } + + // Extract the secret from the zipped share data. + StringBuilder sb = new(); + foreach (List y_ in y) + { + sb.Insert(0, PadLeft(Convert.ToString(Lagrange(shareId, x, y_), 2), nBits)); + } + string result = sb.ToString(); + + // If `shareId` is not zero, NewShare invoked Combine. In this case, return the new share data directly. Otherwise, find + // the first '1' which was added in the Share method as a padding marker and return only the data after the padding and the + // marker. Convert the binary string, which is the derived secret, to hexadecimal. + return Bin2hex(shareId >= 1 ? result : result[(result.IndexOf("1") + 1)..]); + } + + private static string ConstructPublicShareString(int nBits, string shareId, string data) + { + int id = Convert.ToInt32(shareId, Defaults.radix); + string base36Bits = char.ConvertFromUtf32(nBits > 9 ? nBits - 10 + 'A' : nBits + '0'); + int idMax = (1 << nBits) - 1; + int paddingMultiple = Convert.ToString(idMax, Defaults.radix).Length; + string hexId = PadLeft(Convert.ToString(id, Defaults.radix), paddingMultiple); + if (id < 1 || id > idMax) + { + throw new ArgumentOutOfRangeException(nameof(shareId), $"{nameof(shareId)} must be in the range [1, {idMax}]."); + } + string share = base36Bits + hexId + data; + return share; + } + + private static ShareComponents ExtractShareComponents(string share) + { + // Extract the first character which represents the number of bits in base 36. + int nBits = GetLargeBaseValue(share[0]); + if (nBits < Defaults.minnBits || nBits > Defaults.maxnBits) + { + throw new ArgumentException($"Unexpected {nBits}-bit share outside of the range [{Defaults.minnBits}, {Defaults.maxnBits}].", nameof(share)); + } + + // Calculate the maximum number of shares allowed for the given number of bits. + int maxnShares = (1 << nBits) - 1; + + // Derive the identifier length from the bit count. + int idLength = Convert.ToString(maxnShares, Defaults.radix).Length; + + // Extract all the parts now that the segment sizes are known. + var rx = new Regex("^([3-9A-Ka-k]{1})([0-9A-Fa-f]{" + idLength + "})([0-9A-Fa-f]+)$"); + MatchCollection shareComponents = rx.Matches(share); + GroupCollection groups = shareComponents.FirstOrDefault()?.Groups; + if (groups == null || groups.Count != 4) + { + throw new ArgumentException("Malformed share", nameof(share)); + } + + // Convert the identifier from a string of hexadecimal digits into an integer. + int id = Convert.ToInt32(groups[2].Value, Defaults.radix); + + // Return the components of the share. + ShareComponents rv = new(nBits, id, groups[3].Value); + return rv; + } + + private static int GetLargeBaseValue(char ch) + { + int rv = + ch >= 'a' + ? ch - 'a' + 10 + : ch >= 'A' + ? ch - 'A' + 10 + : ch - '0'; + return rv; + } + + private (int x, int y)[] GetShares(int secret, int nShares, int threshold) + { + int[] coefficients = Enumerable.Range(0, threshold - 1).Select((i) => GetRandomInt32(config.nBits)).Concat(new[] { secret }).ToArray(); + var shares = Enumerable.Range(1, nShares).Select((i) => (i, Horner(i, coefficients))).ToArray(); + return shares; + } + + private static string Hex2bin(string value) + { + StringBuilder sb = new(); + foreach (char ch in value) + { + sb.Append(nybbles[GetLargeBaseValue(ch)]); + } + return sb.ToString(); + } + + // Evaluate the polynomial at `x` using Horner's Method. + // NOTE: fx = fx * x + coefficients[i] -> exp(log(fx) + log(x)) + coefficients[i], so if fx is zero, set fx to coefficients[i] + // since using the exponential or logarithmic form will result in an incorrect value. + private int Horner(int x, IEnumerable coefficients) + { + int logx = config.logarithms[x]; + int fx = 0; + foreach (int coefficient in coefficients) + { + fx = fx == 0 ? coefficient : config.exponents[(logx + config.logarithms[fx]) % config.maxnShares] ^ coefficient; + } + return fx; + } + + // Evaluate the Lagrange interpolation polynomial at x = `shareId` using x and y arrays that are of the same length, with + // corresponding elements constituting points on the polynomial. + private int Lagrange(int shareId, IReadOnlyList x, IReadOnlyList y) + { + int sum = 0; + foreach (int i in Enumerable.Range(0, x.Count)) + { + if (i < y.Count && y[i] != 0) + { + int product = config.logarithms[y[i]]; + foreach (int j in Enumerable.Range(0, x.Count).Where((j) => i != j)) + { + if (shareId == x[j]) + { + // This happens when computing a share that is in the list of shares used to compute it. + product = -1; + break; + } + + // Ensure it's not negative. + product = (product + config.logarithms[shareId ^ x[j]] - config.logarithms[x[i] ^ x[j]] + config.maxnShares) % config.maxnShares; + } + sum = product == -1 ? sum : sum ^ config.exponents[product]; + } + } + return sum; + } + + private static string PadLeft(string value, int paddingMultiple) + { + if (paddingMultiple == 1) + { + return value; + } + else if (paddingMultiple < 2 || paddingMultiple > Defaults.maxPaddingMultiple) + { + throw new ArgumentOutOfRangeException(nameof(paddingMultiple), $"{nameof(paddingMultiple)} must be in the range [0, {Defaults.maxPaddingMultiple}]."); + } + if (value.Any()) + { + int extra = value.Length % paddingMultiple; + if (extra > 0) + { + string s = padding + value; + value = s[^(paddingMultiple - extra + value.Length)..]; + } + } + return value; + } + + private List SplitNumStringToIntArray(string value, int paddingMultiple = 0) + { + if (paddingMultiple > 0) + { + value = PadLeft(value, paddingMultiple); + } + List parts = new(); + int i; + for (i = value.Length; i > config.nBits; i -= config.nBits) + { + parts.Add(Convert.ToInt32(value.Substring(i - config.nBits, config.nBits), 2)); + } + parts.Add(Convert.ToInt32(value[..i], 2)); + return parts; + } + + private class Config + { + internal readonly int[] exponents; + internal readonly int[] logarithms; + internal readonly int maxnShares; + internal readonly int nBits; + + internal Config(int nBits) + { + // Set the scalar values. + this.nBits = nBits; + int size = 1 << nBits; + maxnShares = size - 1; + + // Construct the exponent and logarithm tables for multiplication. + int primitive = Defaults.primitivePolynomialCoefficients[nBits]; + exponents = new int[size]; + logarithms = new int[size]; + for (int x = 1, i = 0; i < size; ++i) + { + exponents[i] = x; + logarithms[x] = i; + x <<= 1; + if (x >= size) + { + x ^= primitive; + x &= maxnShares; + } + } + } + } + + private class Defaults + { + internal const int minnBits = 3; + internal const int maxnBits = 20; // up to 1,048,575 shares + internal const int maxnShares = (1 << maxnBits) - 1; + internal const int maxPaddingMultiple = 1024; + internal const int nBits = 8; + internal const int radix = 16; // hexadecimal + + // These are primitive polynomial coefficients for Galois Fields GF(2^n) for 2 <= n <= 20. The index of each term in the + // array corresponds to the n for that polynomial. + internal static readonly int[] primitivePolynomialCoefficients = { -1, -1, 1, 3, 3, 5, 3, 3, 29, 17, 9, 5, 83, 27, 43, 3, 45, 9, 39, 39, 9, }; + } + + private class ShareComponents + { + internal int nBits; + internal int id; + internal string data; + + internal ShareComponents(int nBits, int id, string data) + { + this.nBits = nBits; + this.id = id; + this.data = data; + } + } + } +} diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Exceptions/VerificationException.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Exceptions/VerificationException.cs new file mode 100644 index 0000000..8f1c987 --- /dev/null +++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Exceptions/VerificationException.cs @@ -0,0 +1,20 @@ +using System; +using System.Runtime.Serialization; + +namespace Thirdweb.EWS +{ + [Serializable] + internal class VerificationException : Exception + { + internal bool CanRetry { get; } + + public VerificationException(string message, bool canRetry) + : base(message) + { + CanRetry = canRetry; + } + + protected VerificationException(SerializationInfo info, StreamingContext context) + : base(info, context) { } + } +} diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Models/User.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Models/User.cs new file mode 100644 index 0000000..b9acd7c --- /dev/null +++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Models/User.cs @@ -0,0 +1,16 @@ +using Nethereum.Web3.Accounts; + +namespace Thirdweb.EWS +{ + internal class User + { + internal User(Account account, string emailAddress) + { + Account = account; + EmailAddress = emailAddress; + } + + public Account Account { get; internal set; } + public string EmailAddress { get; internal set; } + } +} diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Models/UserStatus.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Models/UserStatus.cs new file mode 100644 index 0000000..a42e694 --- /dev/null +++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Models/UserStatus.cs @@ -0,0 +1,10 @@ +namespace Thirdweb.EWS +{ + internal enum UserStatus + { + SignedOut = 10, + SignedInWalletUninitialized = 31, + SignedInNewDevice = 21, + SignedInWalletInitialized = 29, + } +} diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Storage/LocalStorage.Types.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Storage/LocalStorage.Types.cs new file mode 100644 index 0000000..8aa7eed --- /dev/null +++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Storage/LocalStorage.Types.cs @@ -0,0 +1,72 @@ +using System.Runtime.Serialization; + +namespace Thirdweb.EWS +{ + internal partial class LocalStorage : LocalStorageBase + { + [DataContract] + internal class DataStorage + { + internal string AuthToken => authToken; + internal string DeviceShare => deviceShare; + internal string EmailAddress => emailAddress; + internal string WalletUserId => walletUserId; + internal string AuthProvider => authProvider; + + [DataMember] + private string authToken; + + [DataMember] + private string deviceShare; + + [DataMember] + private string emailAddress; + + [DataMember] + private string walletUserId; + + [DataMember] + private string authProvider; + + internal DataStorage(string authToken, string deviceShare, string emailAddress, string walletUserId, string authProvider) + { + this.authToken = authToken; + this.deviceShare = deviceShare; + this.emailAddress = emailAddress; + this.walletUserId = walletUserId; + this.authProvider = authProvider; + } + + internal void ClearAuthToken() => authToken = null; + } + + [DataContract] + internal class SessionStorage + { + internal string Id => id; + internal bool IsKmsWallet => isKmsWallet; + + [DataMember] + private string id; + + [DataMember] + private bool isKmsWallet; + + internal SessionStorage(string id, bool isKmsWallet) + { + this.id = id; + this.isKmsWallet = isKmsWallet; + } + } + + [DataContract] + private class Storage + { + [DataMember] + internal DataStorage Data { get; set; } + + [DataMember] + internal SessionStorage Session { get; set; } + } + } +} diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Storage/LocalStorage.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Storage/LocalStorage.cs new file mode 100644 index 0000000..03fee78 --- /dev/null +++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet.Storage/LocalStorage.cs @@ -0,0 +1,108 @@ +using System; +using System.IO; +using System.Runtime.Serialization.Json; +using System.Security; +using System.Threading.Tasks; +#if UNITY_5_3_OR_NEWER +using UnityEngine; +#endif + +namespace Thirdweb.EWS +{ + internal abstract class LocalStorageBase + { + internal abstract LocalStorage.DataStorage Data { get; } + internal abstract LocalStorage.SessionStorage Session { get; } + + internal abstract Task RemoveAuthTokenAsync(); + internal abstract Task RemoveSessionAsync(); + internal abstract Task SaveDataAsync(LocalStorage.DataStorage data); + internal abstract Task SaveSessionAsync(string sessionId, bool isKmsWallet); + } + + internal partial class LocalStorage : LocalStorageBase + { + internal override DataStorage Data => storage.Data; + internal override SessionStorage Session => storage.Session; + private readonly Storage storage; + private readonly string filePath; + + internal LocalStorage(string clientId) + { + string directory; +#if UNITY_5_3_OR_NEWER + directory = Application.persistentDataPath; +#else + directory = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + Console.WriteLine($"Embedded Wallet Storage: Using '{directory}'"); +#endif + directory = Path.Combine(directory, "EWS"); + Directory.CreateDirectory(directory); + filePath = Path.Combine(directory, $"{clientId}.txt"); + try + { + byte[] json = File.ReadAllBytes(filePath); + DataContractJsonSerializer serializer = new(typeof(Storage)); + MemoryStream fin = new(json); + storage = (Storage)serializer.ReadObject(fin); + } + catch (Exception) + { + storage = new Storage(); + } + } + + internal override Task RemoveAuthTokenAsync() + { + return UpdateDataAsync(() => + { + if (storage.Data?.AuthToken != null) + { + storage.Data.ClearAuthToken(); + return true; + } + return false; + }); + } + + private async Task UpdateDataAsync(Func fn) + { + if (fn()) + { + DataContractJsonSerializer serializer = new(typeof(Storage)); + MemoryStream fout = new(); + serializer.WriteObject(fout, storage); + await File.WriteAllBytesAsync(filePath, fout.ToArray()); + return true; + } + return false; + } + + internal override Task SaveDataAsync(DataStorage data) + { + return UpdateDataAsync(() => + { + storage.Data = data; + return true; + }); + } + + internal override Task SaveSessionAsync(string sessionId, bool isKmsWallet) + { + return UpdateDataAsync(() => + { + storage.Session = new SessionStorage(sessionId, isKmsWallet); + return true; + }); + } + + internal override Task RemoveSessionAsync() + { + return UpdateDataAsync(() => + { + storage.Session = null; + return true; + }); + } + } +} diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.AuthEndpoint.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.AuthEndpoint.cs new file mode 100644 index 0000000..1850cdd --- /dev/null +++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.AuthEndpoint.cs @@ -0,0 +1,13 @@ +using System.Threading.Tasks; + +namespace Thirdweb.EWS +{ + internal partial class EmbeddedWallet + { + public async Task SignInWithAuthEndpointAsync(string payload, string encryptionKey, string recoveryCode) + { + Server.VerifyResult result = await server.VerifyAuthEndpointAsync(payload); + return await PostAuthSetup(result, recoveryCode, encryptionKey, "AuthEndpoint"); + } + } +} diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.EmailOTP.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.EmailOTP.cs new file mode 100644 index 0000000..ccd2f64 --- /dev/null +++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.EmailOTP.cs @@ -0,0 +1,62 @@ +using System; +using System.Threading.Tasks; + +namespace Thirdweb.EWS +{ + internal partial class EmbeddedWallet + { + public async Task<(bool isNewUser, bool isNewDevice, bool needsPassword)> SendOtpEmailAsync(string emailAddress) + { + Server.UserWallet userWallet = await server.FetchUserDetailsAsync(emailAddress, null); + bool isKmsWallet = userWallet.RecoveryShareManagement != "USER_MANAGED"; + string sessionId = ""; + if (isKmsWallet) + { + sessionId = await server.SendKmsOtpEmailAsync(emailAddress); + } + else + { + await server.SendUserOtpEmailAsync(emailAddress); + } + await localStorage.SaveSessionAsync(sessionId, isKmsWallet); + bool isNewDevice = userWallet.IsNewUser || localStorage.Data?.WalletUserId != userWallet.WalletUserId; + return (userWallet.IsNewUser, isNewDevice, !isKmsWallet); + } + + public async Task VerifyOtpAsync(string emailAddress, string otp, string recoveryCode) + { + if (localStorage.Session == null) + { + throw new InvalidOperationException($"Must first invoke {nameof(SendOtpEmailAsync)}", new NullReferenceException()); + } + try + { + if (localStorage.Session.IsKmsWallet) + { + if (!await server.CheckIsEmailKmsOtpValidAsync(emailAddress, otp)) + { + throw new VerificationException("Invalid OTP", true); + } + Server.VerifyResult result = await server.VerifyKmsOtpAsync(emailAddress, otp, localStorage.Session.Id); + await localStorage.RemoveSessionAsync(); + return await PostAuthSetup(result, recoveryCode, null, "EmailOTP"); + } + else + { + if (!await server.CheckIsEmailUserOtpValidAsync(emailAddress, otp)) + { + throw new VerificationException("Invalid OTP", true); + } + Server.VerifyResult result = await server.VerifyUserOtpAsync(emailAddress, otp); + await localStorage.RemoveSessionAsync(); + return await PostAuthSetup(result, recoveryCode, null, "EmailOTP"); + } + } + catch (VerificationException ex) + { + Console.WriteLine("VerifyOtpAsync Error: " + ex.Message); + return new VerifyResult(ex.CanRetry); + } + } + } +} diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.JWT.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.JWT.cs new file mode 100644 index 0000000..fb4c31c --- /dev/null +++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.JWT.cs @@ -0,0 +1,13 @@ +using System.Threading.Tasks; + +namespace Thirdweb.EWS +{ + internal partial class EmbeddedWallet + { + public async Task SignInWithJwtAsync(string jwt, string encryptionKey, string recoveryCode) + { + Server.VerifyResult result = await server.VerifyJwtAsync(jwt); + return await PostAuthSetup(result, recoveryCode, encryptionKey, "JWT"); + } + } +} diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.Misc.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.Misc.cs new file mode 100644 index 0000000..acd6013 --- /dev/null +++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.Misc.cs @@ -0,0 +1,223 @@ +using System; +using System.Threading.Tasks; +using Nethereum.Web3.Accounts; + +namespace Thirdweb.EWS +{ + internal partial class EmbeddedWallet + { + public async Task VerifyThirdwebClientIdAsync(string domain) + { + var error = await server.VerifyThirdwebClientIdAsync(domain); + if (error != "") + { + throw new InvalidOperationException($"Invalid thirdweb client id for domain {domain} | {error}"); + } + } + + private async Task PostAuthSetup(Server.VerifyResult result, string userRecoveryCode, string twManagedRecoveryCodeOverride, string authProvider) + { + // Define necessary variables from the result. + Account account; + string walletUserId = result.WalletUserId; + string authToken = result.AuthToken; + string emailAddress = result.Email; + string deviceShare = localStorage.Data?.DeviceShare; + + // Fetch user details from the server. + Server.UserWallet userDetails = await server.FetchUserDetailsAsync(emailAddress, authToken); + bool isUserManaged = userDetails.RecoveryShareManagement == "USER_MANAGED"; + bool isNewUser = userDetails.IsNewUser; + User user; + + // Initialize variables related to recovery codes and email status. + string mainRecoveryCode = null; + string[] backupRecoveryCodes = null; + bool? wasEmailed = null; + + if (!isUserManaged) + { + mainRecoveryCode = twManagedRecoveryCodeOverride ?? result.RecoveryCode; + if (mainRecoveryCode == null) + throw new InvalidOperationException("Server failed to return recovery code."); + (account, deviceShare) = result.IsNewUser ? await CreateAccountAsync(result.AuthToken, mainRecoveryCode) : await RecoverAccountAsync(result.AuthToken, mainRecoveryCode); + user = await MakeUserAsync(emailAddress, account, authToken, walletUserId, deviceShare, authProvider); + return new VerifyResult(user, mainRecoveryCode, backupRecoveryCodes, wasEmailed); + } + + if (isNewUser) + { + // Create recovery code for user-managed accounts. + mainRecoveryCode = MakeRecoveryCode(); + + // Commented out section for future use: Generating multiple backup recovery codes. + /* + backupRecoveryCodes = new string[7]; + for (int i = 0; i < backupRecoveryCodes.Length; i++) + backupRecoveryCodes[i] = MakeRecoveryCode(); + */ + + // Create a new account and handle the recovery codes. + (account, deviceShare) = await CreateAccountAsync(authToken, mainRecoveryCode, backupRecoveryCodes); + + // Attempt to send the recovery code via email and record the outcome. + try + { + if (emailAddress == null) + throw new ArgumentNullException(nameof(emailAddress)); + await server.SendRecoveryCodeEmailAsync(authToken, mainRecoveryCode, emailAddress); + wasEmailed = true; + } + catch + { + wasEmailed = false; + } + } + else + { + // Handling for existing users. + if (userRecoveryCode == null) + { + if (deviceShare == null) + throw new ArgumentNullException(nameof(userRecoveryCode)); + + // Fetch the auth share and create an account from shares. + string authShare = await server.FetchAuthShareAsync(authToken); + account = MakeAccountFromShares(authShare, deviceShare); + } + else + { + // Recover the account using the provided recovery code. + (account, deviceShare) = await RecoverAccountAsync(authToken, userRecoveryCode); + } + } + + // Validate the device share returned from server operations. + if (deviceShare == null) + { + throw new InvalidOperationException("Server failed to return account"); + } + + // Construct the user object and prepare the result. + user = await MakeUserAsync(emailAddress, account, authToken, walletUserId, deviceShare, authProvider); + return new VerifyResult(user, mainRecoveryCode, backupRecoveryCodes, wasEmailed); + } + + public async Task SignOutAsync() + { + user = null; + await localStorage.RemoveAuthTokenAsync(); + } + + public async Task GetUserAsync(string email, string authProvider) + { + if (user != null) + { + return user; + } + else if (localStorage.Data?.AuthToken == null) + { + throw new InvalidOperationException("User is not signed in"); + } + + Server.UserWallet userWallet = await server.FetchUserDetailsAsync(null, localStorage.Data.AuthToken); + switch (userWallet.Status) + { + case "Logged Out": + await SignOutAsync(); + throw new InvalidOperationException("User is logged out"); + case "Logged In, Wallet Uninitialized": + await SignOutAsync(); + throw new InvalidOperationException("User is logged in but wallet is uninitialized"); + case "Logged In, Wallet Initialized": + if (string.IsNullOrEmpty(localStorage.Data?.DeviceShare)) + { + await SignOutAsync(); + throw new InvalidOperationException("User is logged in but wallet is uninitialized"); + } + + string authShare = await server.FetchAuthShareAsync(localStorage.Data.AuthToken); + string emailAddress = userWallet.StoredToken?.AuthDetails.Email; + + if (email != null && email != emailAddress) + { + await SignOutAsync(); + throw new InvalidOperationException("User email does not match"); + } + else if (email == null && localStorage.Data.AuthProvider != authProvider) + { + await SignOutAsync(); + throw new InvalidOperationException($"User auth provider does not match. Expected {localStorage.Data.AuthProvider}, got {authProvider}"); + } + else if (authShare == null) + { + throw new InvalidOperationException("Server failed to return auth share"); + } + user = new User(MakeAccountFromShares(new[] { authShare, localStorage.Data.DeviceShare }), emailAddress); + return user; + } + throw new InvalidOperationException($"Unexpected user status '{userWallet.Status}'"); + } + + private async Task MakeUserAsync(string emailAddress, Account account, string authToken, string walletUserId, string deviceShare, string authProvider) + { + var data = new LocalStorage.DataStorage(authToken, deviceShare, emailAddress ?? "", walletUserId, authProvider); + await localStorage.SaveDataAsync(data); + user = new User(account, emailAddress ?? ""); + return user; + } + + private async Task<(Account account, string deviceShare)> CreateAccountAsync(string authToken, string recoveryCode, string[] backupRecoveryCodes = null) + { + string secret = Secrets.Random(KEY_SIZE); + (string deviceShare, string recoveryShare, string authShare) = CreateShares(secret); + string encryptedRecoveryShare = await EncryptShareAsync(recoveryShare, recoveryCode); + Account account = new(secret); + + string[] backupRecoveryShares = null; + if (backupRecoveryCodes != null) + { + backupRecoveryShares = new string[backupRecoveryCodes.Length]; + for (int i = 0; i < backupRecoveryCodes.Length; i++) + { + backupRecoveryShares[i] = await EncryptShareAsync(recoveryShare, backupRecoveryCodes[i]); + } + } + await server.StoreAddressAndSharesAsync(account.Address, authShare, encryptedRecoveryShare, authToken, backupRecoveryShares); + return (account, deviceShare); + } + + private async Task<(Account account, string deviceShare)> RecoverAccountAsync(string authToken, string recoveryCode) + { + (string authShare, string encryptedRecoveryShare) = await server.FetchAuthAndRecoverySharesAsync(authToken); + // make below async + string recoveryShare = await Task.Run(() => DecryptShare(encryptedRecoveryShare, recoveryCode)); + Account account = MakeAccountFromShares(authShare, recoveryShare); + Secrets secrets = new(); + string deviceShare = secrets.NewShare(DEVICE_SHARE_ID, new[] { authShare, recoveryShare }); + return (account, deviceShare); + } + + public class VerifyResult + { + public User User { get; } + public bool CanRetry { get; } + public string MainRecoveryCode { get; } + public string[] BackupRecoveryCodes { get; } + public bool? WasEmailed { get; } + + public VerifyResult(User user, string mainRecoveryCode, string[] backupRecoveryCodes, bool? wasEmailed) + { + User = user; + MainRecoveryCode = mainRecoveryCode; + BackupRecoveryCodes = backupRecoveryCodes; + WasEmailed = wasEmailed; + } + + public VerifyResult(bool canRetry) + { + CanRetry = canRetry; + } + } + } +} diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.OAuth.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.OAuth.cs new file mode 100644 index 0000000..d3edd33 --- /dev/null +++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.OAuth.cs @@ -0,0 +1,27 @@ +using System.Threading.Tasks; +using Nethereum.Web3.Accounts; +using Newtonsoft.Json; + +namespace Thirdweb.EWS +{ + internal partial class EmbeddedWallet + { + public async Task SignInWithOauthAsync(string authProvider, string authResult, string recoveryCode) + { + Server.VerifyResult result = await server.VerifyOAuthAsync(authResult); + return await PostAuthSetup(result, recoveryCode, null, authProvider); + } + + public async Task FetchHeadlessOauthLoginLinkAsync(string authProvider) + { + return await server.FetchHeadlessOauthLoginLinkAsync(authProvider); + } + + public async Task IsRecoveryCodeNeededAsync(string authResultStr) + { + var authResult = JsonConvert.DeserializeObject(authResultStr); + Server.UserWallet userWallet = await server.FetchUserDetailsAsync(authResult.StoredToken.AuthDetails.Email, null); + return userWallet.RecoveryShareManagement == "USER_MANAGED" && !userWallet.IsNewUser && localStorage.Data?.DeviceShare == null; + } + } +} diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.PhoneOTP.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.PhoneOTP.cs new file mode 100644 index 0000000..f079d1c --- /dev/null +++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.PhoneOTP.cs @@ -0,0 +1,41 @@ +using System; +using System.Threading.Tasks; + +namespace Thirdweb.EWS +{ + internal partial class EmbeddedWallet + { + public async Task<(bool isNewUser, bool isNewDevice, bool needsPassword)> SendOtpPhoneAsync(string phoneNumber) + { + string sessionId = await server.SendKmsPhoneOtpAsync(phoneNumber); + bool isKmsWallet = true; + await localStorage.SaveSessionAsync(sessionId, isKmsWallet); + bool isNewUser = true; + bool isNewDevice = true; + return (isNewUser, isNewDevice, !isKmsWallet); + } + + public async Task VerifyPhoneOtpAsync(string phoneNumber, string otp, string recoveryCode) + { + if (localStorage.Session == null) + { + throw new InvalidOperationException($"Must first invoke {nameof(SendOtpPhoneAsync)}", new NullReferenceException()); + } + try + { + // if (!await server.CheckIsPhoneKmsOtpValidAsync(phoneNumber, otp)) + // { + // throw new VerificationException("Invalid OTP", true); + // } + Server.VerifyResult result = await server.VerifyKmsPhoneOtpAsync(phoneNumber, otp, localStorage.Session.Id); + await localStorage.RemoveSessionAsync(); + return await PostAuthSetup(result, recoveryCode, null, "PhoneOTP"); + } + catch (VerificationException ex) + { + Console.WriteLine("VerifyPhoneOtpAsync Error: " + ex.Message); + return new VerifyResult(ex.CanRetry); + } + } + } +} diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.cs new file mode 100644 index 0000000..918bc60 --- /dev/null +++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.Embedded/EmbeddedWallet/EmbeddedWallet.cs @@ -0,0 +1,25 @@ +namespace Thirdweb.EWS +{ + internal partial class EmbeddedWallet + { + private readonly LocalStorageBase localStorage; + private readonly ServerBase server; + private readonly IvGeneratorBase ivGenerator; + private User user; + + private const int DEVICE_SHARE_ID = 1; + private const int KEY_SIZE = 256 / 8; + private const int TAG_SIZE = 16; + private const int CURRENT_ITERATION_COUNT = 650_000; + private const int DEPRECATED_ITERATION_COUNT = 5_000_000; + private const string WALLET_PRIVATE_KEY_PREFIX = "thirdweb_"; + private const string ENCRYPTION_SEPARATOR = ":"; + + public EmbeddedWallet(ThirdwebClient client) + { + localStorage = new LocalStorage(client.ClientId); + server = new Server(client.ClientId, client.BundleId, "dotnet", Constants.Version, client.SecretKey); + ivGenerator = new IvGenerator(); + } + } +} diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.EmbeddedWallet/EmbeddedWallet.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.EmbeddedWallet/EmbeddedWallet.cs deleted file mode 100644 index 3e1db48..0000000 --- a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.EmbeddedWallet/EmbeddedWallet.cs +++ /dev/null @@ -1,7 +0,0 @@ -using Thirdweb; - -internal class EmbeddedWallet : PrivateKeyWallet -{ - internal EmbeddedWallet(string privateKeyHex) - : base(privateKeyHex) { } -} diff --git a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.PrivateKeyWallet/PrivateKeyWallet.cs b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.PrivateKey/PrivateKey.cs similarity index 90% rename from Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.PrivateKeyWallet/PrivateKeyWallet.cs rename to Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.PrivateKey/PrivateKey.cs index 3fa977b..1e24421 100644 --- a/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.PrivateKeyWallet/PrivateKeyWallet.cs +++ b/Thirdweb/Thirdweb.Wallets/Thirdweb.Wallets.PrivateKey/PrivateKey.cs @@ -10,11 +10,11 @@ using Nethereum.Signer.EIP712; using Thirdweb; -internal class PrivateKeyWallet : IWallet +public class PrivateKey : IWallet { - private readonly EthECKey _ecKey; + private EthECKey _ecKey; - internal PrivateKeyWallet(string privateKeyHex) + internal PrivateKey(string privateKeyHex) { if (string.IsNullOrEmpty(privateKeyHex)) { @@ -24,6 +24,12 @@ internal PrivateKeyWallet(string privateKeyHex) _ecKey = new EthECKey(privateKeyHex); } + public Task Initialize() + { + // No initialization required for private key wallets + return Task.CompletedTask; + } + public string GetAddress() { return _ecKey.GetPublicAddress(); @@ -128,4 +134,15 @@ public string SignTransaction(TransactionInput transaction, BigInteger chainId) return "0x" + signedTransaction; } + + public bool IsConnected() + { + return _ecKey != null; + } + + public Task Disconnect() + { + _ecKey = null; + return Task.CompletedTask; + } } diff --git a/Thirdweb/Thirdweb.Wallets/ThirdwebAccount.cs b/Thirdweb/Thirdweb.Wallets/ThirdwebAccount.cs index ce549a7..e4110e0 100644 --- a/Thirdweb/Thirdweb.Wallets/ThirdwebAccount.cs +++ b/Thirdweb/Thirdweb.Wallets/ThirdwebAccount.cs @@ -8,7 +8,7 @@ public class ThirdwebAccount { internal ThirdwebAccountOptions Options { get; private set; } - private IWallet _wallet; + public IWallet Wallet { get; private set; } public ThirdwebAccount(ThirdwebAccountOptions options) { @@ -19,46 +19,57 @@ public ThirdwebAccount(ThirdwebAccountOptions options) Options = options; - InitializeWallet(); - } - - private void InitializeWallet() - { - _wallet = Options.Type switch + Wallet = Options.Type switch { - WalletType.PrivateKey => new PrivateKeyWallet(Options.PrivateKey), + WalletType.PrivateKey => new PrivateKey(Options.PrivateKey), + WalletType.Embedded => new Embedded(Options.Client, Options.Email), _ => throw new ArgumentException("Invalid wallet type"), }; } + public async Task Initialize() + { + await Wallet.Initialize(); + } + public string GetAddress() { - return _wallet.GetAddress(); + return Wallet.GetAddress(); } public string EthSign(string message) { - return _wallet.EthSign(message); + return Wallet.EthSign(message); } public string PersonalSign(string message) { - return _wallet.PersonalSign(message); + return Wallet.PersonalSign(message); } public string SignTypedDataV4(string json) { - return _wallet.SignTypedDataV4(json); + return Wallet.SignTypedDataV4(json); } public string SignTypedDataV4(T data, TypedData typedData) { - return _wallet.SignTypedDataV4(data, typedData); + return Wallet.SignTypedDataV4(data, typedData); } public string SignTransaction(TransactionInput transaction, BigInteger chainId) { - return _wallet.SignTransaction(transaction, chainId); + return Wallet.SignTransaction(transaction, chainId); + } + + public bool IsConnected() + { + return Wallet.IsConnected(); + } + + public async Task Disconnect() + { + await Wallet.Disconnect(); } } } diff --git a/Thirdweb/Thirdweb.Wallets/ThirdwebAccountOptions.cs b/Thirdweb/Thirdweb.Wallets/ThirdwebAccountOptions.cs index 5ace94a..e09c9d7 100644 --- a/Thirdweb/Thirdweb.Wallets/ThirdwebAccountOptions.cs +++ b/Thirdweb/Thirdweb.Wallets/ThirdwebAccountOptions.cs @@ -6,12 +6,14 @@ public class ThirdwebAccountOptions { internal ThirdwebClient Client { get; private set; } internal WalletType Type { get; private set; } + internal string Email { get; private set; } internal string PrivateKey { get; private set; } - public ThirdwebAccountOptions(ThirdwebClient client, WalletType type, string privateKey) + public ThirdwebAccountOptions(ThirdwebClient client, WalletType type, string email = null, string privateKey = null) { Client = client; Type = type; + Email = email; PrivateKey = privateKey; } } diff --git a/Thirdweb/Thirdweb.csproj b/Thirdweb/Thirdweb.csproj index cdb9025..ab2f649 100644 --- a/Thirdweb/Thirdweb.csproj +++ b/Thirdweb/Thirdweb.csproj @@ -33,6 +33,9 @@ + + +