diff --git a/src/App/Abstractions/Services/ITokenService.cs b/src/App/Abstractions/Services/ITokenService.cs
index 30fcacc7b..931652682 100644
--- a/src/App/Abstractions/Services/ITokenService.cs
+++ b/src/App/Abstractions/Services/ITokenService.cs
@@ -18,5 +18,6 @@ namespace Bit.App.Abstractions
string TokenUserId { get; }
string TokenEmail { get; }
string TokenName { get; }
+ bool TokenPremium { get; }
}
}
diff --git a/src/App/App.csproj b/src/App/App.csproj
index 145ddb693..31b1ae484 100644
--- a/src/App/App.csproj
+++ b/src/App/App.csproj
@@ -294,6 +294,7 @@
+
diff --git a/src/App/Controls/LabeledValueCell.cs b/src/App/Controls/LabeledValueCell.cs
index c84e967e3..6a74e11ed 100644
--- a/src/App/Controls/LabeledValueCell.cs
+++ b/src/App/Controls/LabeledValueCell.cs
@@ -8,7 +8,8 @@ namespace Bit.App.Controls
string labelText = null,
string valueText = null,
string button1Text = null,
- string button2Text = null)
+ string button2Text = null,
+ string subText = null)
{
var containerStackLayout = new StackLayout
{
@@ -56,6 +57,18 @@ namespace Bit.App.Controls
VerticalOptions = LayoutOptions.CenterAndExpand
};
+ if(subText != null)
+ {
+ Sub = new Label
+ {
+ FontSize = Device.GetNamedSize(NamedSize.Small, typeof(Label)),
+ HorizontalOptions = LayoutOptions.End,
+ VerticalOptions = LayoutOptions.Center
+ };
+
+ buttonStackLayout.Children.Add(Sub);
+ }
+
if(button1Text != null)
{
Button1 = new ExtendedButton
@@ -100,12 +113,18 @@ namespace Bit.App.Controls
containerStackLayout.AdjustPaddingForDevice();
}
+ if(Sub != null && Button1 != null)
+ {
+ Sub.Margin = new Thickness(0, 0, 10, 0);
+ }
+
containerStackLayout.Children.Add(buttonStackLayout);
View = containerStackLayout;
}
public Label Label { get; private set; }
public Label Value { get; private set; }
+ public Label Sub { get; private set; }
public ExtendedButton Button1 { get; private set; }
public ExtendedButton Button2 { get; private set; }
}
diff --git a/src/App/Models/Page/VaultViewLoginPageModel.cs b/src/App/Models/Page/VaultViewLoginPageModel.cs
index de1b4cba8..6ebd79e0c 100644
--- a/src/App/Models/Page/VaultViewLoginPageModel.cs
+++ b/src/App/Models/Page/VaultViewLoginPageModel.cs
@@ -13,6 +13,8 @@ namespace Bit.App.Models.Page
private string _password;
private string _uri;
private string _notes;
+ private string _totpCode;
+ private int _totpSec = 30;
private bool _revealPassword;
private List _attachments;
@@ -194,6 +196,31 @@ namespace Bit.App.Models.Page
public string ShowHideText => RevealPassword ? AppResources.Hide : AppResources.Show;
public ImageSource ShowHideImage => RevealPassword ? ImageSource.FromFile("eye_slash") : ImageSource.FromFile("eye");
+ public string TotpCode
+ {
+ get { return _totpCode; }
+ set
+ {
+ _totpCode = value;
+ PropertyChanged(this, new PropertyChangedEventArgs(nameof(TotpCode)));
+ PropertyChanged(this, new PropertyChangedEventArgs(nameof(TotpCodeFormatted)));
+ }
+ }
+ public int TotpSecond
+ {
+ get { return _totpSec; }
+ set
+ {
+ _totpSec = value;
+ PropertyChanged(this, new PropertyChangedEventArgs(nameof(TotpSecond)));
+ PropertyChanged(this, new PropertyChangedEventArgs(nameof(TotpColor)));
+ }
+ }
+ public bool TotpLow => TotpSecond <= 7;
+ public Color TotpColor => !string.IsNullOrWhiteSpace(TotpCode) && TotpLow ? Color.Red : Color.Black;
+ public string TotpCodeFormatted => !string.IsNullOrWhiteSpace(TotpCode) ?
+ string.Format("{0} {1}", TotpCode.Substring(0, 3), TotpCode.Substring(3)) : null;
+
public List Attachments
{
get { return _attachments; }
diff --git a/src/App/Pages/Vault/VaultAddLoginPage.cs b/src/App/Pages/Vault/VaultAddLoginPage.cs
index 2836662f2..296e1c95c 100644
--- a/src/App/Pages/Vault/VaultAddLoginPage.cs
+++ b/src/App/Pages/Vault/VaultAddLoginPage.cs
@@ -168,12 +168,12 @@ namespace Bit.App.Pages
var login = new Login
{
- Uri = UriCell.Entry.Text?.Encrypt(),
- Name = NameCell.Entry.Text?.Encrypt(),
- Username = UsernameCell.Entry.Text?.Encrypt(),
- Password = PasswordCell.Entry.Text?.Encrypt(),
- Notes = NotesCell.Editor.Text?.Encrypt(),
- Totp = TotpCell.Entry.Text?.Encrypt(),
+ Name = NameCell.Entry.Text.Encrypt(),
+ Uri = string.IsNullOrWhiteSpace(UriCell.Entry.Text) ? null : UriCell.Entry.Text.Encrypt(),
+ Username = string.IsNullOrWhiteSpace(UsernameCell.Entry.Text) ? null : UsernameCell.Entry.Text.Encrypt(),
+ Password = string.IsNullOrWhiteSpace(PasswordCell.Entry.Text) ? null : PasswordCell.Entry.Text.Encrypt(),
+ Notes = string.IsNullOrWhiteSpace(NotesCell.Editor.Text) ? null : NotesCell.Editor.Text.Encrypt(),
+ Totp = string.IsNullOrWhiteSpace(TotpCell.Entry.Text) ? null : TotpCell.Entry.Text.Encrypt(),
Favorite = favoriteCell.On
};
diff --git a/src/App/Pages/Vault/VaultEditLoginPage.cs b/src/App/Pages/Vault/VaultEditLoginPage.cs
index c16979356..a945fed5b 100644
--- a/src/App/Pages/Vault/VaultEditLoginPage.cs
+++ b/src/App/Pages/Vault/VaultEditLoginPage.cs
@@ -177,12 +177,17 @@ namespace Bit.App.Pages
return;
}
- login.Uri = UriCell.Entry.Text?.Encrypt(login.OrganizationId);
- login.Name = NameCell.Entry.Text?.Encrypt(login.OrganizationId);
- login.Username = UsernameCell.Entry.Text?.Encrypt(login.OrganizationId);
- login.Password = PasswordCell.Entry.Text?.Encrypt(login.OrganizationId);
- login.Notes = NotesCell.Editor.Text?.Encrypt(login.OrganizationId);
- login.Totp = TotpCell.Entry.Text?.Encrypt(login.OrganizationId);
+ login.Name = NameCell.Entry.Text.Encrypt(login.OrganizationId);
+ login.Uri = string.IsNullOrWhiteSpace(UriCell.Entry.Text) ? null :
+ UriCell.Entry.Text.Encrypt(login.OrganizationId);
+ login.Username = string.IsNullOrWhiteSpace(UsernameCell.Entry.Text) ? null :
+ UsernameCell.Entry.Text.Encrypt(login.OrganizationId);
+ login.Password = string.IsNullOrWhiteSpace(PasswordCell.Entry.Text) ? null :
+ PasswordCell.Entry.Text.Encrypt(login.OrganizationId);
+ login.Notes = string.IsNullOrWhiteSpace(NotesCell.Editor.Text) ? null :
+ NotesCell.Editor.Text.Encrypt(login.OrganizationId);
+ login.Totp = string.IsNullOrWhiteSpace(TotpCell.Entry.Text) ? null :
+ TotpCell.Entry.Text.Encrypt(login.OrganizationId);
login.Favorite = favoriteCell.On;
if(FolderCell.Picker.SelectedIndex > 0)
diff --git a/src/App/Pages/Vault/VaultViewLoginPage.cs b/src/App/Pages/Vault/VaultViewLoginPage.cs
index 48363e258..d2843cb91 100644
--- a/src/App/Pages/Vault/VaultViewLoginPage.cs
+++ b/src/App/Pages/Vault/VaultViewLoginPage.cs
@@ -19,6 +19,7 @@ namespace Bit.App.Pages
private readonly ILoginService _loginService;
private readonly IUserDialogs _userDialogs;
private readonly IDeviceActionService _deviceActionService;
+ private readonly ITokenService _tokenService;
public VaultViewLoginPage(string loginId)
{
@@ -26,6 +27,7 @@ namespace Bit.App.Pages
_loginService = Resolver.Resolve();
_userDialogs = Resolver.Resolve();
_deviceActionService = Resolver.Resolve();
+ _tokenService = Resolver.Resolve();
Init();
}
@@ -39,6 +41,7 @@ namespace Bit.App.Pages
public LabeledValueCell PasswordCell { get; set; }
public LabeledValueCell UriCell { get; set; }
public LabeledValueCell NotesCell { get; set; }
+ public LabeledValueCell TotpCodeCell { get; set; }
private EditLoginToolBarItem EditItem { get; set; }
public List AttachmentCells { get; set; }
@@ -91,6 +94,15 @@ namespace Bit.App.Pages
}
});
+ // Totp
+ TotpCodeCell = new LabeledValueCell(AppResources.VerificationCodeTotp, button1Text: AppResources.Copy, subText: "--");
+ TotpCodeCell.Value.SetBinding(Label.TextProperty, nameof(VaultViewLoginPageModel.TotpCodeFormatted));
+ TotpCodeCell.Value.SetBinding(Label.TextColorProperty, nameof(VaultViewLoginPageModel.TotpColor));
+ TotpCodeCell.Button1.Command = new Command(() => Copy(Model.TotpCode, AppResources.VerificationCodeTotp));
+ TotpCodeCell.Sub.SetBinding(Label.TextProperty, nameof(VaultViewLoginPageModel.TotpSecond));
+ TotpCodeCell.Sub.SetBinding(Label.TextColorProperty, nameof(VaultViewLoginPageModel.TotpColor));
+ TotpCodeCell.Value.FontFamily = Helpers.OnPlatform(iOS: "Courier", Android: "monospace", WinPhone: "Courier");
+
// Notes
NotesCell = new LabeledValueCell();
NotesCell.Value.SetBinding(Label.TextProperty, nameof(VaultViewLoginPageModel.Notes));
@@ -129,6 +141,7 @@ namespace Bit.App.Pages
PasswordCell.Button1.WidthRequest = 40;
PasswordCell.Button2.WidthRequest = 59;
UsernameCell.Button1.WidthRequest = 59;
+ TotpCodeCell.Button1.WidthRequest = 59;
UriCell.Button1.WidthRequest = 75;
}
@@ -209,6 +222,38 @@ namespace Bit.App.Pages
Table.Root.Add(AttachmentsSection);
}
+ // Totp
+ var removeTotp = login.Totp == null || (!_tokenService.TokenPremium && !login.OrganizationUseTotp);
+ if(!removeTotp)
+ {
+ var totpKey = login.Totp.Decrypt(login.OrganizationId);
+ removeTotp = string.IsNullOrWhiteSpace(totpKey);
+ if(!removeTotp)
+ {
+ Model.TotpCode = Crypto.Totp(totpKey);
+ removeTotp = string.IsNullOrWhiteSpace(Model.TotpCode);
+ if(!removeTotp)
+ {
+ TotpTick(totpKey);
+ Device.StartTimer(new TimeSpan(0, 0, 1), () =>
+ {
+ TotpTick(totpKey);
+ return true;
+ });
+
+ if(!LoginInformationSection.Contains(TotpCodeCell))
+ {
+ LoginInformationSection.Add(TotpCodeCell);
+ }
+ }
+ }
+ }
+
+ if(removeTotp && LoginInformationSection.Contains(TotpCodeCell))
+ {
+ LoginInformationSection.Remove(TotpCodeCell);
+ }
+
base.OnAppearing();
}
@@ -273,6 +318,18 @@ namespace Bit.App.Pages
_userDialogs.Toast(string.Format(AppResources.ValueHasBeenCopied, alertLabel));
}
+ private void TotpTick(string totpKey)
+ {
+ var now = Helpers.EpocUtcNow() / 1000;
+ var mod = now % 30;
+ Model.TotpSecond = (int)(30 - mod);
+
+ if(mod == 0)
+ {
+ Model.TotpCode = Crypto.Totp(totpKey);
+ }
+ }
+
private class EditLoginToolBarItem : ExtendedToolbarItem
{
private readonly VaultViewLoginPage _page;
diff --git a/src/App/Repositories/AccountsApiRepository.cs b/src/App/Repositories/AccountsApiRepository.cs
index 414d1e17b..9b55f162a 100644
--- a/src/App/Repositories/AccountsApiRepository.cs
+++ b/src/App/Repositories/AccountsApiRepository.cs
@@ -5,13 +5,12 @@ using Bit.App.Abstractions;
using Bit.App.Models.Api;
using Plugin.Connectivity.Abstractions;
using Newtonsoft.Json;
+using Bit.App.Utilities;
namespace Bit.App.Repositories
{
public class AccountsApiRepository : BaseApiRepository, IAccountsApiRepository
{
- private static readonly DateTime _epoc = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
-
public AccountsApiRepository(
IConnectivity connectivity,
IHttpService httpService,
@@ -125,7 +124,7 @@ namespace Bit.App.Repositories
{
return await HandleErrorAsync(response).ConfigureAwait(false);
}
- return ApiResult.Success(_epoc.AddMilliseconds(ms), response.StatusCode);
+ return ApiResult.Success(Helpers.Epoc.AddMilliseconds(ms), response.StatusCode);
}
catch
{
diff --git a/src/App/Resources/AppResources.Designer.cs b/src/App/Resources/AppResources.Designer.cs
index 801124198..585281f2b 100644
--- a/src/App/Resources/AppResources.Designer.cs
+++ b/src/App/Resources/AppResources.Designer.cs
@@ -2122,6 +2122,15 @@ namespace Bit.App.Resources {
}
}
+ ///
+ /// Looks up a localized string similar to Verification Code (TOTP).
+ ///
+ public static string VerificationCodeTotp {
+ get {
+ return ResourceManager.GetString("VerificationCodeTotp", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Could not send verification email. Try again..
///
diff --git a/src/App/Resources/AppResources.resx b/src/App/Resources/AppResources.resx
index 27350e1a8..b55e52299 100644
--- a/src/App/Resources/AppResources.resx
+++ b/src/App/Resources/AppResources.resx
@@ -925,4 +925,8 @@
Authenticator Key (TOTP)
+
+ Verification Code (TOTP)
+ Totp code label
+
\ No newline at end of file
diff --git a/src/App/Services/TokenService.cs b/src/App/Services/TokenService.cs
index ed966f6b1..b9d2a2977 100644
--- a/src/App/Services/TokenService.cs
+++ b/src/App/Services/TokenService.cs
@@ -2,6 +2,7 @@
using Bit.App.Abstractions;
using System.Text;
using Newtonsoft.Json.Linq;
+using Bit.App.Utilities;
namespace Bit.App.Services
{
@@ -19,8 +20,6 @@ namespace Bit.App.Services
private string _refreshToken;
private string _authBearer;
- private static readonly DateTime _epoc = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
-
public TokenService(ISecureStorageService secureStorage)
{
_secureStorage = secureStorage;
@@ -73,7 +72,7 @@ namespace Bit.App.Services
throw new InvalidOperationException("No exp in token.");
}
- return _epoc.AddSeconds(Convert.ToDouble(decoded["exp"].Value()));
+ return Helpers.Epoc.AddSeconds(Convert.ToDouble(decoded["exp"].Value()));
}
}
@@ -97,6 +96,7 @@ namespace Bit.App.Services
public string TokenUserId => DecodeToken()?["sub"].Value();
public string TokenEmail => DecodeToken()?["email"].Value();
public string TokenName => DecodeToken()?["name"].Value();
+ public bool TokenPremium => (DecodeToken()?["premium"].Value()).GetValueOrDefault(false);
public string RefreshToken
{
diff --git a/src/App/Utilities/Base32.cs b/src/App/Utilities/Base32.cs
new file mode 100644
index 000000000..9d036360d
--- /dev/null
+++ b/src/App/Utilities/Base32.cs
@@ -0,0 +1,72 @@
+using System;
+
+namespace Bit.App.Utilities
+{
+ // ref: https://github.com/aspnet/Identity/blob/dev/src/Microsoft.Extensions.Identity.Core/Base32.cs
+ // with some modifications for cleaning input
+ public static class Base32
+ {
+ private static readonly string _base32Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
+
+ public static byte[] FromBase32(string input)
+ {
+ if(input == null)
+ {
+ throw new ArgumentNullException(nameof(input));
+ }
+
+ input = input.ToUpperInvariant();
+ var cleanedInput = string.Empty;
+ foreach(var c in input)
+ {
+ if(_base32Chars.IndexOf(c) < 0)
+ {
+ continue;
+ }
+
+ cleanedInput += c;
+ }
+
+ input = cleanedInput;
+ if(input.Length == 0)
+ {
+ return new byte[0];
+ }
+
+ var output = new byte[input.Length * 5 / 8];
+ var bitIndex = 0;
+ var inputIndex = 0;
+ var outputBits = 0;
+ var outputIndex = 0;
+
+ while(outputIndex < output.Length)
+ {
+ var byteIndex = _base32Chars.IndexOf(input[inputIndex]);
+ if(byteIndex < 0)
+ {
+ throw new FormatException();
+ }
+
+ var bits = Math.Min(5 - bitIndex, 8 - outputBits);
+ output[outputIndex] <<= bits;
+ output[outputIndex] |= (byte)(byteIndex >> (5 - (bitIndex + bits)));
+
+ bitIndex += bits;
+ if(bitIndex >= 5)
+ {
+ inputIndex++;
+ bitIndex = 0;
+ }
+
+ outputBits += bits;
+ if(outputBits >= 8)
+ {
+ outputIndex++;
+ outputBits = 0;
+ }
+ }
+
+ return output;
+ }
+ }
+}
diff --git a/src/App/Utilities/Crypto.cs b/src/App/Utilities/Crypto.cs
index ba6800352..20abfda3e 100644
--- a/src/App/Utilities/Crypto.cs
+++ b/src/App/Utilities/Crypto.cs
@@ -152,5 +152,36 @@ namespace Bit.App.Utilities
return true;
}
+
+ // ref: https://github.com/mirthas/totp-net/blob/master/TOTP/Totp.cs
+ public static string Totp(string b32Key)
+ {
+ var key = Base32.FromBase32(b32Key);
+ if(key == null || key.Length == 0)
+ {
+ return null;
+ }
+
+ var now = Helpers.EpocUtcNow() / 1000;
+ var sec = now / 30;
+
+ var secBytes = BitConverter.GetBytes(sec);
+ if(BitConverter.IsLittleEndian)
+ {
+ Array.Reverse(secBytes, 0, secBytes.Length);
+ }
+
+ var algorithm = WinRTCrypto.MacAlgorithmProvider.OpenAlgorithm(MacAlgorithm.HmacSha1);
+ var hasher = algorithm.CreateHash(key);
+ hasher.Append(secBytes);
+ var hash = hasher.GetValueAndReset();
+
+ var offset = (hash[hash.Length - 1] & 0xf);
+ var i = ((hash[offset] & 0x7f) << 24) | ((hash[offset + 1] & 0xff) << 16) |
+ ((hash[offset + 2] & 0xff) << 8) | (hash[offset + 3] & 0xff);
+ var code = i % (int)Math.Pow(10, 6);
+
+ return code.ToString().PadLeft(6, '0');
+ }
}
}
diff --git a/src/App/Utilities/Helpers.cs b/src/App/Utilities/Helpers.cs
index dcd0894f4..dda103ff1 100644
--- a/src/App/Utilities/Helpers.cs
+++ b/src/App/Utilities/Helpers.cs
@@ -5,6 +5,13 @@ namespace Bit.App.Utilities
{
public static class Helpers
{
+ public static readonly DateTime Epoc = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+
+ public static long EpocUtcNow()
+ {
+ return (long)(DateTime.UtcNow - Epoc).TotalMilliseconds;
+ }
+
public static T OnPlatform(T iOS = default(T), T Android = default(T),
T WinPhone = default(T), T Windows = default(T))
{