From fa743815f8322f2bf40f3fa0920f834a80facd92 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Fri, 12 May 2017 10:15:34 -0400 Subject: [PATCH] log in and store protected settings --- src/Console/Program.cs | 65 ++++++-- src/Core/AuthService.cs | 30 ---- src/Core/Core.csproj | 26 ++- src/Core/Models/ApiError.cs | 13 ++ src/Core/Models/ApiResult.cs | 75 +++++++++ src/Core/Models/EncryptedData.cs | 35 +++++ src/Core/Models/ErrorResponse.cs | 18 +++ src/Core/Models/LoginResult.cs | 16 ++ src/Core/Models/TokenRequest.cs | 33 ++++ src/Core/Models/TokenResponse.cs | 23 +++ src/Core/Services/ApiService.cs | 227 +++++++++++++++++++++++++++ src/Core/Services/AuthService.cs | 100 ++++++++++++ src/Core/Services/CryptoService.cs | 101 ++++++++++++ src/Core/Services/SettingsService.cs | 148 +++++++++++++++++ src/Core/Services/TokenService.cs | 171 ++++++++++++++++++++ src/Core/TokenService.cs | 55 ------- src/Core/packages.config | 5 + 17 files changed, 1038 insertions(+), 103 deletions(-) delete mode 100644 src/Core/AuthService.cs create mode 100644 src/Core/Models/ApiError.cs create mode 100644 src/Core/Models/ApiResult.cs create mode 100644 src/Core/Models/EncryptedData.cs create mode 100644 src/Core/Models/ErrorResponse.cs create mode 100644 src/Core/Models/LoginResult.cs create mode 100644 src/Core/Models/TokenRequest.cs create mode 100644 src/Core/Models/TokenResponse.cs create mode 100644 src/Core/Services/ApiService.cs create mode 100644 src/Core/Services/AuthService.cs create mode 100644 src/Core/Services/CryptoService.cs create mode 100644 src/Core/Services/SettingsService.cs create mode 100644 src/Core/Services/TokenService.cs delete mode 100644 src/Core/TokenService.cs create mode 100644 src/Core/packages.config diff --git a/src/Console/Program.cs b/src/Console/Program.cs index 27975cf3..c23611b0 100644 --- a/src/Console/Program.cs +++ b/src/Console/Program.cs @@ -15,6 +15,11 @@ namespace Bit.Console private static string[] _args = null; static void Main(string[] args) + { + MainAsync(args).Wait(); + } + + private static async Task MainAsync(string[] args) { _args = args; _usingArgs = args.Length > 0; @@ -22,6 +27,8 @@ namespace Bit.Console while(true) { + Con.ResetColor(); + if(_usingArgs) { selection = args[0]; @@ -45,10 +52,12 @@ namespace Bit.Console { case "1": case "login": - LogIn(); + case "signin": + await LogInAsync(); break; case "2": case "dir": + case "directory": break; case "3": @@ -57,6 +66,7 @@ namespace Bit.Console break; case "4": case "svc": + case "service": break; case "5": @@ -84,37 +94,60 @@ namespace Bit.Console _args = null; } - public static void LogIn() + private static async Task LogInAsync() { string email = null; - SecureString masterPassword = null; + string masterPassword = null; if(_usingArgs) { - email = _args[1]; - masterPassword = new SecureString(); - foreach(var c in _args[2]) - { - masterPassword.AppendChar(c); - } + Con.ForegroundColor = ConsoleColor.Red; + Con.WriteLine("You cannot log in via arguments. Use the console instead."); + Con.ResetColor(); + return; } else { Con.Write("Email: "); - email = Con.ReadLine(); + email = Con.ReadLine().Trim(); Con.Write("Master password: "); masterPassword = ReadSecureLine(); } - // TODO: Do login + var result = await Core.Services.AuthService.Instance.LogInAsync(email, masterPassword); + + if(result.TwoFactorRequired) + { + Con.WriteLine(); + Con.WriteLine(); + Con.WriteLine("Two-step login is enabled on this account. Please enter your verification code."); + Con.Write("Verification code: "); + var token = Con.ReadLine().Trim(); + result = await Core.Services.AuthService.Instance.LogInTwoFactorAsync(token, email, result.MasterPasswordHash); + } + + Con.WriteLine(); + Con.WriteLine(); + if(result.Success) + { + Con.ForegroundColor = ConsoleColor.Green; + Con.WriteLine("You have successfully logged in as {0}!", Core.Services.TokenService.Instance.AccessTokenEmail); + Con.ResetColor(); + } + else + { + Con.ForegroundColor = ConsoleColor.Red; + Con.WriteLine(result.ErrorMessage); + Con.ResetColor(); + } } - public static SecureString ReadSecureLine() + private static string ReadSecureLine() { - var input = new SecureString(); + var input = string.Empty; while(true) { - ConsoleKeyInfo i = Con.ReadKey(true); + var i = Con.ReadKey(true); if(i.Key == ConsoleKey.Enter) { break; @@ -123,13 +156,13 @@ namespace Bit.Console { if(input.Length > 0) { - input.RemoveAt(input.Length - 1); + input = input.Remove(input.Length - 1); Con.Write("\b \b"); } } else { - input.AppendChar(i.KeyChar); + input = string.Concat(input, i.KeyChar); Con.Write("*"); } } diff --git a/src/Core/AuthService.cs b/src/Core/AuthService.cs deleted file mode 100644 index 5caffda5..00000000 --- a/src/Core/AuthService.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Bit.Core -{ - public class AuthService - { - private static AuthService _instance; - - private AuthService() { } - - public static AuthService Instance - { - get - { - if(_instance == null) - { - _instance = new AuthService(); - } - - return _instance; - } - } - - public bool Authenticated { get; set; } - } -} diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 97d78155..1cb98906 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -30,6 +30,12 @@ 4 + + ..\..\packages\BouncyCastle.1.8.1\lib\BouncyCastle.Crypto.dll + + + ..\..\packages\Newtonsoft.Json.10.0.2\lib\net45\Newtonsoft.Json.dll + @@ -43,9 +49,25 @@ - - + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Core/Models/ApiError.cs b/src/Core/Models/ApiError.cs new file mode 100644 index 00000000..29f08855 --- /dev/null +++ b/src/Core/Models/ApiError.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Bit.Core.Models +{ + public class ApiError + { + public string Message { get; set; } + } +} diff --git a/src/Core/Models/ApiResult.cs b/src/Core/Models/ApiResult.cs new file mode 100644 index 00000000..723f54b2 --- /dev/null +++ b/src/Core/Models/ApiResult.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using System.Net; + +namespace Bit.Core.Models +{ + public class ApiResult + { + private List m_errors = new List(); + + public bool Succeeded { get; private set; } + public T Result { get; set; } + public IEnumerable Errors => m_errors; + public HttpStatusCode StatusCode { get; private set; } + + public static ApiResult Success(T result, HttpStatusCode statusCode) + { + return new ApiResult + { + Succeeded = true, + Result = result, + StatusCode = statusCode + }; + } + + public static ApiResult Failed(HttpStatusCode statusCode, params ApiError[] errors) + { + var result = new ApiResult + { + Succeeded = false, + StatusCode = statusCode + }; + + if(errors != null) + { + result.m_errors.AddRange(errors); + } + + return result; + } + } + + public class ApiResult + { + private List m_errors = new List(); + + public bool Succeeded { get; private set; } + public IEnumerable Errors => m_errors; + public HttpStatusCode StatusCode { get; private set; } + + public static ApiResult Success(HttpStatusCode statusCode) + { + return new ApiResult + { + Succeeded = true, + StatusCode = statusCode + }; + } + + public static ApiResult Failed(HttpStatusCode statusCode, params ApiError[] errors) + { + var result = new ApiResult + { + Succeeded = false, + StatusCode = statusCode + }; + + if(errors != null) + { + result.m_errors.AddRange(errors); + } + + return result; + } + } +} diff --git a/src/Core/Models/EncryptedData.cs b/src/Core/Models/EncryptedData.cs new file mode 100644 index 00000000..3935b562 --- /dev/null +++ b/src/Core/Models/EncryptedData.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; + +namespace Bit.Core.Models +{ + public class EncryptedData + { + public EncryptedData() { } + + public EncryptedData(byte[] plainValue) + { + IV = RandomBytes(); + Value = ProtectedData.Protect(plainValue, IV, DataProtectionScope.CurrentUser); + } + + public byte[] Value { get; set; } + public byte[] IV { get; set; } + + public byte[] Decrypt() + { + return ProtectedData.Unprotect(Value, IV, DataProtectionScope.CurrentUser); + } + + private byte[] RandomBytes() + { + var entropy = new byte[16]; + new RNGCryptoServiceProvider().GetBytes(entropy); + return entropy; + } + } +} diff --git a/src/Core/Models/ErrorResponse.cs b/src/Core/Models/ErrorResponse.cs new file mode 100644 index 00000000..45c59070 --- /dev/null +++ b/src/Core/Models/ErrorResponse.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Bit.Core.Models +{ + public class ErrorResponse + { + public string Message { get; set; } + public Dictionary> ValidationErrors { get; set; } + // For use in development environments. + public string ExceptionMessage { get; set; } + public string ExceptionStackTrace { get; set; } + public string InnerExceptionMessage { get; set; } + } +} diff --git a/src/Core/Models/LoginResult.cs b/src/Core/Models/LoginResult.cs new file mode 100644 index 00000000..61609855 --- /dev/null +++ b/src/Core/Models/LoginResult.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Bit.Core.Models +{ + public class LoginResult + { + public bool Success { get; set; } + public string ErrorMessage { get; set; } + public bool TwoFactorRequired { get; set; } + public string MasterPasswordHash { get; set; } + } +} diff --git a/src/Core/Models/TokenRequest.cs b/src/Core/Models/TokenRequest.cs new file mode 100644 index 00000000..46bff3a7 --- /dev/null +++ b/src/Core/Models/TokenRequest.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; + +namespace Bit.Core.Models +{ + public class TokenRequest + { + public string Email { get; set; } + public string MasterPasswordHash { get; set; } + public string Token { get; set; } + public int? Provider { get; set; } + + public IDictionary ToIdentityTokenRequest() + { + var dict = new Dictionary + { + { "grant_type", "password" }, + { "username", Email }, + { "password", MasterPasswordHash }, + { "scope", "api offline_access" }, + { "client_id", "mobile" } + }; + + if(Token != null && Provider.HasValue) + { + dict.Add("TwoFactorToken", Token); + dict.Add("TwoFactorProvider", Provider.Value.ToString()); + } + + return dict; + } + } +} diff --git a/src/Core/Models/TokenResponse.cs b/src/Core/Models/TokenResponse.cs new file mode 100644 index 00000000..31f07ad2 --- /dev/null +++ b/src/Core/Models/TokenResponse.cs @@ -0,0 +1,23 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Bit.Core.Models +{ + public class TokenResponse + { + [JsonProperty("access_token")] + public string AccessToken { get; set; } + [JsonProperty("expires_in")] + public long ExpiresIn { get; set; } + [JsonProperty("refresh_token")] + public string RefreshToken { get; set; } + [JsonProperty("token_type")] + public string TokenType { get; set; } + public List TwoFactorProviders { get; set; } + public string PrivateKey { get; set; } + } +} diff --git a/src/Core/Services/ApiService.cs b/src/Core/Services/ApiService.cs new file mode 100644 index 00000000..35c2045d --- /dev/null +++ b/src/Core/Services/ApiService.cs @@ -0,0 +1,227 @@ +using Bit.Core.Models; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; + +namespace Bit.Core.Services +{ + public class ApiService + { + private static ApiService _instance; + + private ApiService() + { + ApiClient = new HttpClient(); + ApiClient.BaseAddress = new Uri("https://api.bitwarden.com"); + + IdentityClient = new HttpClient(); + IdentityClient.BaseAddress = new Uri("https://identity.bitwarden.com"); + } + + public static ApiService Instance + { + get + { + if(_instance == null) + { + _instance = new ApiService(); + } + + return _instance; + } + } + + protected HttpClient ApiClient { get; private set; } + protected HttpClient IdentityClient { get; private set; } + + public virtual async Task> PostTokenAsync(TokenRequest requestObj) + { + var requestMessage = new HttpRequestMessage + { + Method = HttpMethod.Post, + RequestUri = new Uri(IdentityClient.BaseAddress, "connect/token"), + Content = new FormUrlEncodedContent(requestObj.ToIdentityTokenRequest()) + }; + + try + { + var response = await IdentityClient.SendAsync(requestMessage).ConfigureAwait(false); + var responseContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + if(!response.IsSuccessStatusCode) + { + var errorResponse = JObject.Parse(responseContent); + if(errorResponse["TwoFactorProviders"] != null) + { + return ApiResult.Success(new TokenResponse + { + TwoFactorProviders = errorResponse["TwoFactorProviders"].ToObject>() + }, response.StatusCode); + } + + return await HandleErrorAsync(response).ConfigureAwait(false); + } + + var responseObj = JsonConvert.DeserializeObject(responseContent); + return ApiResult.Success(responseObj, response.StatusCode); + } + catch + { + return HandledWebException(); + } + } + + protected ApiResult HandledWebException() + { + return ApiResult.Failed(HttpStatusCode.BadGateway, + new ApiError { Message = "There is a problem connecting to the server." }); + } + + protected ApiResult HandledWebException() + { + return ApiResult.Failed(HttpStatusCode.BadGateway, + new ApiError { Message = "There is a problem connecting to the server." }); + } + + protected async Task HandleTokenStateAsync() + { + return await HandleTokenStateAsync( + () => ApiResult.Success(HttpStatusCode.OK), + () => HandledWebException(), + (r) => HandleErrorAsync(r)); + } + + protected async Task> HandleTokenStateAsync() + { + return await HandleTokenStateAsync( + () => ApiResult.Success(default(T), HttpStatusCode.OK), + () => HandledWebException(), + (r) => HandleErrorAsync(r)); + } + + private async Task HandleTokenStateAsync(Func success, Func webException, + Func> error) + { + if(TokenService.Instance.AccessTokenNeedsRefresh && !string.IsNullOrWhiteSpace(TokenService.Instance.RefreshToken)) + { + var requestMessage = new HttpRequestMessage + { + Method = HttpMethod.Post, + RequestUri = new Uri(IdentityClient.BaseAddress, "connect/token"), + Content = new FormUrlEncodedContent(new Dictionary + { + { "grant_type", "refresh_token" }, + { "client_id", "mobile" }, + { "refresh_token", TokenService.Instance.RefreshToken } + }) + }; + + try + { + var response = await IdentityClient.SendAsync(requestMessage).ConfigureAwait(false); + if(!response.IsSuccessStatusCode) + { + if(response.StatusCode == HttpStatusCode.BadRequest) + { + response.StatusCode = HttpStatusCode.Unauthorized; + } + + return await error.Invoke(response).ConfigureAwait(false); + } + + var responseContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + var tokenResponse = JsonConvert.DeserializeObject(responseContent); + TokenService.Instance.AccessToken = tokenResponse.AccessToken; + TokenService.Instance.RefreshToken = tokenResponse.RefreshToken; + } + catch + { + return webException.Invoke(); + } + } + + return success.Invoke(); + } + + protected async Task> HandleErrorAsync(HttpResponseMessage response) + { + try + { + var errors = await ParseErrorsAsync(response).ConfigureAwait(false); + return ApiResult.Failed(response.StatusCode, errors.ToArray()); + } + catch + { } + + return ApiResult.Failed(response.StatusCode, + new ApiError { Message = "An unknown error has occurred." }); + } + + protected async Task HandleErrorAsync(HttpResponseMessage response) + { + try + { + var errors = await ParseErrorsAsync(response).ConfigureAwait(false); + return ApiResult.Failed(response.StatusCode, errors.ToArray()); + } + catch + { } + + return ApiResult.Failed(response.StatusCode, + new ApiError { Message = "An unknown error has occurred." }); + } + + private async Task> ParseErrorsAsync(HttpResponseMessage response) + { + var errors = new List(); + var statusCode = (int)response.StatusCode; + if(statusCode >= 400 && statusCode <= 500) + { + ErrorResponse errorResponseModel = null; + + var responseContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + if(!string.IsNullOrWhiteSpace(responseContent)) + { + var errorResponse = JObject.Parse(responseContent); + if(errorResponse["ErrorModel"] != null && errorResponse["ErrorModel"]["Message"] != null) + { + errorResponseModel = errorResponse["ErrorModel"].ToObject(); + } + else if(errorResponse["Message"] != null) + { + errorResponseModel = errorResponse.ToObject(); + } + } + + if(errorResponseModel != null) + { + if((errorResponseModel.ValidationErrors?.Count ?? 0) > 0) + { + foreach(var valError in errorResponseModel.ValidationErrors) + { + foreach(var errorMessage in valError.Value) + { + errors.Add(new ApiError { Message = errorMessage }); + } + } + } + else + { + errors.Add(new ApiError { Message = errorResponseModel.Message }); + } + } + } + + if(errors.Count == 0) + { + errors.Add(new ApiError { Message = "An unknown error has occurred." }); + } + + return errors; + } + } +} diff --git a/src/Core/Services/AuthService.cs b/src/Core/Services/AuthService.cs new file mode 100644 index 00000000..cb0d6ec3 --- /dev/null +++ b/src/Core/Services/AuthService.cs @@ -0,0 +1,100 @@ +using Bit.Core.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security; +using System.Text; +using System.Threading.Tasks; + +namespace Bit.Core.Services +{ + public class AuthService + { + private static AuthService _instance; + + private AuthService() { } + + public static AuthService Instance + { + get + { + if(_instance == null) + { + _instance = new AuthService(); + } + + return _instance; + } + } + + public bool Authenticated => !string.IsNullOrWhiteSpace(TokenService.Instance.AccessToken); + + public async Task LogInAsync(string email, string masterPassword) + { + var normalizedEmail = email.Trim().ToLower(); + var key = CryptoService.Instance.MakeKeyFromPassword(masterPassword, normalizedEmail); + + var request = new TokenRequest + { + Email = normalizedEmail, + MasterPasswordHash = CryptoService.Instance.HashPasswordBase64(key, masterPassword) + }; + + var response = await ApiService.Instance.PostTokenAsync(request); + + masterPassword = null; + key = null; + + var result = new LoginResult(); + if(!response.Succeeded) + { + result.Success = false; + result.ErrorMessage = response.Errors.FirstOrDefault()?.Message; + return result; + } + + result.Success = true; + if(response.Result.TwoFactorProviders != null && response.Result.TwoFactorProviders.Count > 0) + { + result.TwoFactorRequired = true; + result.MasterPasswordHash = request.MasterPasswordHash; + return result; + } + + await ProcessLogInSuccessAsync(response.Result); + return result; + } + + public async Task LogInTwoFactorAsync(string token, string email, string masterPasswordHash) + { + var request = new TokenRequest + { + Email = email.Trim().ToLower(), + MasterPasswordHash = masterPasswordHash, + Token = token.Trim().Replace(" ", ""), + Provider = 0 // Authenticator app (only 1 provider for now, so hard coded) + }; + + var response = await ApiService.Instance.PostTokenAsync(request); + + var result = new LoginResult(); + if(!response.Succeeded) + { + result.Success = false; + result.ErrorMessage = response.Errors.FirstOrDefault()?.Message; + return result; + } + + result.Success = true; + await ProcessLogInSuccessAsync(response.Result); + return result; + } + + private Task ProcessLogInSuccessAsync(TokenResponse response) + { + TokenService.Instance.AccessToken = response.AccessToken; + TokenService.Instance.RefreshToken = response.RefreshToken; + return Task.FromResult(0); + } + } +} diff --git a/src/Core/Services/CryptoService.cs b/src/Core/Services/CryptoService.cs new file mode 100644 index 00000000..5bbbb3e2 --- /dev/null +++ b/src/Core/Services/CryptoService.cs @@ -0,0 +1,101 @@ +using Org.BouncyCastle.Crypto.Digests; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Crypto.Parameters; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; + +namespace Bit.Core.Services +{ + public class CryptoService + { + private static CryptoService _instance; + + private CryptoService() { } + + public static CryptoService Instance + { + get + { + if(_instance == null) + { + _instance = new CryptoService(); + } + + return _instance; + } + } + + public byte[] MakeKeyFromPassword(string password, string salt) + { + if(password == null) + { + throw new ArgumentNullException(nameof(password)); + } + + if(salt == null) + { + throw new ArgumentNullException(nameof(salt)); + } + + var passwordBytes = Encoding.UTF8.GetBytes(password); + var saltBytes = Encoding.UTF8.GetBytes(salt); + + var keyBytes = DeriveKey(passwordBytes, saltBytes, 5000); + + password = null; + passwordBytes = null; + + return keyBytes; + } + + public string MakeKeyFromPasswordBase64(string password, string salt) + { + var key = MakeKeyFromPassword(password, salt); + password = null; + return Convert.ToBase64String(key); + } + + public byte[] HashPassword(byte[] key, string password) + { + if(key == null) + { + throw new ArgumentNullException(nameof(key)); + } + + if(password == null) + { + throw new ArgumentNullException(nameof(password)); + } + + var passwordBytes = Encoding.UTF8.GetBytes(password); + + var hashBytes = DeriveKey(key, passwordBytes, 1); + + password = null; + key = null; + + return hashBytes; + } + + public string HashPasswordBase64(byte[] key, string password) + { + var hash = HashPassword(key, password); + password = null; + key = null; + return Convert.ToBase64String(hash); + } + + private byte[] DeriveKey(byte[] password, byte[] salt, int rounds) + { + var generator = new Pkcs5S2ParametersGenerator(new Sha256Digest()); + generator.Init(password, salt, rounds); + var key = ((KeyParameter)generator.GenerateDerivedMacParameters(256)).GetKey(); + password = null; + return key; + } + } +} diff --git a/src/Core/Services/SettingsService.cs b/src/Core/Services/SettingsService.cs new file mode 100644 index 00000000..141e3c15 --- /dev/null +++ b/src/Core/Services/SettingsService.cs @@ -0,0 +1,148 @@ +using Bit.Core.Models; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security; +using System.Text; +using System.Threading.Tasks; + +namespace Bit.Core.Services +{ + public class SettingsService + { + private static SettingsService _instance; + private static object _locker = new object(); + private static string _baseStoragePath = string.Concat( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "\\bitwarden\\DirectoryConnector"); + + private IDictionary _settings; + + private SettingsService() { } + + public static SettingsService Instance + { + get + { + if(_instance == null) + { + _instance = new SettingsService(); + } + + return _instance; + } + } + + public IDictionary Settings + { + get + { + var filePath = $"{_baseStoragePath}\\settings.json"; + if(_settings == null && File.Exists(filePath)) + { + var serializer = new JsonSerializer(); + using(var s = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.Read)) + using(var sr = new StreamReader(s, Encoding.UTF8)) + using(var jsonTextReader = new JsonTextReader(sr)) + { + _settings = serializer.Deserialize>(jsonTextReader); + } + } + + return _settings == null ? new Dictionary() : _settings; + } + set + { + lock(_locker) + { + if(!Directory.Exists(_baseStoragePath)) + { + Directory.CreateDirectory(_baseStoragePath); + } + + _settings = value; + var filePath = $"{_baseStoragePath}\\settings.json"; + using(var s = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.Read)) + using(var sw = new StreamWriter(s, Encoding.UTF8)) + { + var json = JsonConvert.SerializeObject(_settings); + sw.Write(json); + } + } + } + } + + public void Set(string key, object value) + { + if(Contains(key)) + { + Settings[key] = value; + } + else + { + Settings.Add(key, value); + } + + Settings = Settings; + } + + public void Remove(string key) + { + Settings.Remove(key); + Settings = Settings; + } + + public bool Contains(string key) + { + return Settings.ContainsKey(key); + } + + public T Get(string key) + { + if(Settings.ContainsKey(key)) + { + return (T)Settings[key]; + } + + return default(T); + } + + public EncryptedData AccessToken + { + get + { + return Get("AccessToken"); + } + set + { + if(value == null) + { + Remove("AccessTolen"); + return; + } + + Set("AccessToken", value); + } + } + + public EncryptedData RefreshToken + { + get + { + return Get("RefreshToken"); + } + set + { + if(value == null) + { + Remove("RefreshToken"); + return; + } + + Set("RefreshToken", value); + } + } + } +} diff --git a/src/Core/Services/TokenService.cs b/src/Core/Services/TokenService.cs new file mode 100644 index 00000000..0412150c --- /dev/null +++ b/src/Core/Services/TokenService.cs @@ -0,0 +1,171 @@ +using Bit.Core.Models; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; + +namespace Bit.Core.Services +{ + public class TokenService + { + private static TokenService _instance; + private static readonly DateTime _epoc = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + private string _accessToken; + private dynamic _decodedAccessToken; + + private TokenService() { } + + public static TokenService Instance + { + get + { + if(_instance == null) + { + _instance = new TokenService(); + } + + return _instance; + } + } + + public string AccessToken + { + get + { + if(_accessToken != null) + { + return _accessToken; + } + + var encBytes = SettingsService.Instance.AccessToken; + if(encBytes != null) + { + _accessToken = Encoding.ASCII.GetString(encBytes.Decrypt()); + } + + return _accessToken; + } + set + { + _accessToken = value; + if(_accessToken == null) + { + SettingsService.Instance.AccessToken = null; + } + else + { + var bytes = Encoding.ASCII.GetBytes(_accessToken); + SettingsService.Instance.AccessToken = new EncryptedData(bytes); + bytes = null; + } + } + } + + public DateTime AccessTokenExpiration + { + get + { + var decoded = DecodeAccessToken(); + if(decoded?["exp"] == null) + { + throw new InvalidOperationException("No exp in token."); + } + + return _epoc.AddSeconds(Convert.ToDouble(decoded["exp"].Value())); + } + } + + public bool AccessTokenExpired => DateTime.UtcNow < AccessTokenExpiration; + public TimeSpan AccessTokenTimeRemaining => AccessTokenExpiration - DateTime.UtcNow; + public bool AccessTokenNeedsRefresh => AccessTokenTimeRemaining.TotalMinutes < 5; + public string AccessTokenUserId => DecodeAccessToken()?["sub"].Value(); + public string AccessTokenEmail => DecodeAccessToken()?["email"].Value(); + public string AccessTokenName => DecodeAccessToken()?["name"].Value(); + + public string RefreshToken + { + get + { + var encData = SettingsService.Instance.RefreshToken; + if(encData != null) + { + return Encoding.ASCII.GetString(encData.Decrypt()); + } + + return null; + } + set + { + if(value == null) + { + SettingsService.Instance.RefreshToken = null; + } + else + { + var bytes = Encoding.ASCII.GetBytes(value); + SettingsService.Instance.RefreshToken = new EncryptedData(bytes); + bytes = null; + } + } + } + + public JObject DecodeAccessToken() + { + if(_decodedAccessToken != null) + { + return _decodedAccessToken; + } + + if(AccessToken == null) + { + throw new InvalidOperationException($"{nameof(AccessToken)} not found."); + } + + var parts = AccessToken.Split('.'); + if(parts.Length != 3) + { + throw new InvalidOperationException($"{nameof(AccessToken)} must have 3 parts"); + } + + var decodedBytes = Base64UrlDecode(parts[1]); + if(decodedBytes == null || decodedBytes.Length < 1) + { + throw new InvalidOperationException($"{nameof(AccessToken)} must have 3 parts"); + } + + _decodedAccessToken = JObject.Parse(Encoding.UTF8.GetString(decodedBytes, 0, decodedBytes.Length)); + return _decodedAccessToken; + } + + private static byte[] Base64UrlDecode(string input) + { + var output = input; + // 62nd char of encoding + output = output.Replace('-', '+'); + // 63rd char of encoding + output = output.Replace('_', '/'); + // Pad with trailing '='s + switch(output.Length % 4) + { + case 0: + // No pad chars in this case + break; + case 2: + // Two pad chars + output += "=="; break; + case 3: + // One pad char + output += "="; break; + default: + throw new InvalidOperationException("Illegal base64url string!"); + } + + // Standard base64 decoder + return Convert.FromBase64String(output); + } + } +} diff --git a/src/Core/TokenService.cs b/src/Core/TokenService.cs deleted file mode 100644 index b7c512d3..00000000 --- a/src/Core/TokenService.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Security.Cryptography; -using System.Text; -using System.Threading.Tasks; - -namespace Bit.Core -{ - public class TokenService - { - private static TokenService _instance; - - private TokenService() { } - - public static TokenService Instance - { - get - { - if(_instance == null) - { - _instance = new TokenService(); - } - - return _instance; - } - } - - public string AccessToken - { - get - { - // TODO: get bytes from disc - var encBytes = new byte[0]; - var bytes = ProtectedData.Unprotect(encBytes, RandomEntropy(), DataProtectionScope.CurrentUser); - return Convert.ToBase64String(bytes); - } - set - { - var bytes = Convert.FromBase64String(value); - var encBytes = ProtectedData.Protect(bytes, RandomEntropy(), DataProtectionScope.CurrentUser); - // TODO: store bytes to disc - } - } - - public string RefreshToken { get; set; } - - private byte[] RandomEntropy() - { - var entropy = new byte[16]; - new RNGCryptoServiceProvider().GetBytes(entropy); - return entropy; - } - } -} diff --git a/src/Core/packages.config b/src/Core/packages.config new file mode 100644 index 00000000..e51e4099 --- /dev/null +++ b/src/Core/packages.config @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file