attachments page with upload/delete

This commit is contained in:
Kyle Spearrin 2017-07-22 15:38:08 -04:00
parent b32603b472
commit f9d336a3a6
24 changed files with 786 additions and 80 deletions

View file

@ -17,6 +17,7 @@ using Bit.App.Models.Page;
using Bit.App;
using Android.Nfc;
using Android.Views.InputMethods;
using System.IO;
namespace Bit.Android
{
@ -215,6 +216,25 @@ namespace Bit.Android
ZXing.Net.Mobile.Forms.Android.PermissionsHandler.OnRequestPermissionsResult(requestCode, permissions, grantResults);
}
protected override void OnActivityResult(int requestCode, Result resultCode, Intent data)
{
if(requestCode == Constants.SelectFileRequestCode && resultCode == Result.Ok)
{
global::Android.Net.Uri uri = null;
if(data != null)
{
uri = data.Data;
using(var stream = ContentResolver.OpenInputStream(uri))
using(var memoryStream = new MemoryStream())
{
stream.CopyTo(memoryStream);
MessagingCenter.Send(Xamarin.Forms.Application.Current, "SelectFileResult",
new Tuple<byte[], string>(memoryStream.ToArray(), Utilities.GetFileName(ApplicationContext, uri)));
}
}
}
}
public void RateApp()
{
try

View file

@ -6,6 +6,7 @@ using Android.Webkit;
using Plugin.CurrentActivity;
using System.IO;
using Android.Support.V4.Content;
using Bit.App;
namespace Bit.Android.Services
{
@ -123,9 +124,12 @@ namespace Bit.Android.Services
}
}
public byte[] SelectFile()
public void SelectFile()
{
return null;
var intent = new Intent(Intent.ActionOpenDocument);
intent.AddCategory(Intent.CategoryOpenable);
intent.SetType("*/*");
CrossCurrentActivity.Current.Activity.StartActivityForResult(intent, Constants.SelectFileRequestCode);
}
}
}

View file

@ -4,6 +4,7 @@ using Android.Content;
using Java.Security;
using System.IO;
using Android.Nfc;
using Android.Provider;
namespace Bit.Android
{
@ -101,5 +102,28 @@ namespace Bit.Android
return message;
}
public static string GetFileName(Context context, global::Android.Net.Uri uri)
{
string name = null;
string[] projection = { MediaStore.MediaColumns.DisplayName };
var metaCursor = context.ContentResolver.Query(uri, projection, null, null, null);
if(metaCursor != null)
{
try
{
if(metaCursor.MoveToFirst())
{
name = metaCursor.GetString(0);
}
}
finally
{
metaCursor.Close();
}
}
return name;
}
}
}

View file

@ -8,5 +8,7 @@ namespace Bit.App.Abstractions
{
Task<ApiResult<CipherResponse>> GetByIdAsync(string id);
Task<ApiResult<ListResponse<CipherResponse>>> GetAsync();
Task<ApiResult<CipherResponse>> PostAttachmentAsync(string cipherId, byte[] data, string fileName);
Task<ApiResult> DeleteAttachmentAsync(string cipherId, string attachmentId);
}
}

View file

@ -22,6 +22,7 @@ namespace Bit.App.Abstractions
byte[] DecryptToBytes(byte[] encyptedValue, SymmetricCryptoKey key = null);
byte[] RsaDecryptToBytes(CipherString encyptedValue, byte[] privateKey);
CipherString Encrypt(string plaintextValue, SymmetricCryptoKey key = null);
byte[] EncryptToBytes(byte[] plainBytes, SymmetricCryptoKey key = null);
SymmetricCryptoKey MakeKeyFromPassword(string password, string salt);
string MakeKeyFromPasswordBase64(string password, string salt);
byte[] HashPassword(SymmetricCryptoKey key, string password);

View file

@ -1,11 +1,13 @@
namespace Bit.App.Abstractions
using System;
namespace Bit.App.Abstractions
{
public interface IDeviceActionService
{
void CopyToClipboard(string text);
bool OpenFile(byte[] fileData, string id, string fileName);
bool CanOpenFile(string fileName);
byte[] SelectFile();
void SelectFile();
void ClearCache();
}
}

View file

@ -15,5 +15,7 @@ namespace Bit.App.Abstractions
Task<ApiResult<LoginResponse>> SaveAsync(Login login);
Task<ApiResult> DeleteAsync(string id);
Task<byte[]> DownloadAndDecryptAttachmentAsync(string url, string orgId = null);
Task<ApiResult<CipherResponse>> EncryptAndSaveAttachmentAsync(Login login, byte[] data, string fileName);
Task<ApiResult> DeleteAttachmentAsync(Login login, string attachmentId);
}
}

View file

@ -84,6 +84,7 @@
<Compile Include="Controls\FormPickerCell.cs" />
<Compile Include="Controls\FormEntryCell.cs" />
<Compile Include="Controls\PinControl.cs" />
<Compile Include="Controls\VaultAttachmentsViewCell.cs" />
<Compile Include="Controls\VaultListViewCell.cs" />
<Compile Include="Enums\TwoFactorProviderType.cs" />
<Compile Include="Enums\EncryptionType.cs" />
@ -121,6 +122,7 @@
<Compile Include="Models\CipherString.cs" />
<Compile Include="Models\Data\AttachmentData.cs" />
<Compile Include="Models\Attachment.cs" />
<Compile Include="Models\Page\VaultAttachmentsPageModel.cs" />
<Compile Include="Models\SymmetricCryptoKey.cs" />
<Compile Include="Models\Data\SettingsData.cs" />
<Compile Include="Models\Data\FolderData.cs" />
@ -162,6 +164,7 @@
<Compile Include="Pages\Settings\SettingsPage.cs" />
<Compile Include="Pages\Settings\SettingsListFoldersPage.cs" />
<Compile Include="Pages\Vault\VaultAutofillListLoginsPage.cs" />
<Compile Include="Pages\Vault\VaultAttachmentsPage.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="Abstractions\Repositories\ILoginRepository.cs" />
<Compile Include="Repositories\AttachmentRepository.cs" />

View file

@ -33,5 +33,7 @@
public const string Locked = "other:locked";
public const string LastLoginEmail = "other:lastLoginEmail";
public const string LastSync = "other:lastSync";
public const int SelectFileRequestCode = 42;
}
}

View file

@ -5,7 +5,7 @@ namespace Bit.App.Controls
{
public class LabeledRightDetailCell : ExtendedViewCell
{
public LabeledRightDetailCell()
public LabeledRightDetailCell(bool showIcon = true)
{
Label = new Label
{
@ -22,32 +22,38 @@ namespace Bit.App.Controls
VerticalOptions = LayoutOptions.Center
};
Icon = new CachedImage
{
WidthRequest = 16,
HeightRequest = 16,
HorizontalOptions = LayoutOptions.End,
VerticalOptions = LayoutOptions.Center,
Margin = new Thickness(5, 0, 0, 0)
};
var stackLayout = new StackLayout
StackLayout = new StackLayout
{
Orientation = StackOrientation.Horizontal,
Padding = new Thickness(15, 10),
Children = { Label, Detail, Icon }
Children = { Label, Detail }
};
if(showIcon)
{
Icon = new CachedImage
{
WidthRequest = 16,
HeightRequest = 16,
HorizontalOptions = LayoutOptions.End,
VerticalOptions = LayoutOptions.Center,
Margin = new Thickness(5, 0, 0, 0)
};
StackLayout.Children.Add(Icon);
}
if(Device.RuntimePlatform == Device.Android)
{
Label.TextColor = Color.Black;
}
View = stackLayout;
View = StackLayout;
}
public Label Label { get; private set; }
public Label Detail { get; private set; }
public CachedImage Icon { get; private set; }
public StackLayout StackLayout { get; private set; }
}
}

View file

@ -0,0 +1,21 @@
using Bit.App.Models.Page;
using Xamarin.Forms;
namespace Bit.App.Controls
{
public class VaultAttachmentsViewCell : LabeledRightDetailCell
{
public VaultAttachmentsViewCell()
: base(false)
{
Label.SetBinding(Label.TextProperty, nameof(VaultAttachmentsPageModel.Attachment.Name));
Detail.SetBinding(Label.TextProperty, nameof(VaultAttachmentsPageModel.Attachment.SizeName));
BackgroundColor = Color.White;
if(Device.RuntimePlatform == Device.iOS)
{
StackLayout.BackgroundColor = Color.White;
}
}
}
}

View file

@ -0,0 +1,25 @@
using System.Collections.Generic;
namespace Bit.App.Models.Page
{
public class VaultAttachmentsPageModel
{
public class Attachment : List<Attachment>
{
public string Id { get; set; }
public string Name { get; set; }
public string SizeName { get; set; }
public long Size { get; set; }
public string Url { get; set; }
public Attachment(Models.Attachment attachment)
{
Id = attachment.Id;
Name = attachment.FileName?.Decrypt();
SizeName = attachment.SizeName;
Size = attachment.Size;
Url = attachment.Url;
}
}
}
}

View file

@ -217,6 +217,23 @@ namespace Bit.App.Pages
private void Layout_LayoutChanged(object sender, EventArgs e)
{
AnalyticsLabel.WidthRequest = StackLayout.Bounds.Width - AnalyticsLabel.Bounds.Left * 2;
CopyTotpLabel.WidthRequest = StackLayout.Bounds.Width - CopyTotpLabel.Bounds.Left * 2;
if(AutofillAlwaysLabel != null)
{
AutofillAlwaysLabel.WidthRequest = StackLayout.Bounds.Width - AutofillAlwaysLabel.Bounds.Left * 2;
}
if(AutofillPasswordFieldLabel != null)
{
AutofillPasswordFieldLabel.WidthRequest = StackLayout.Bounds.Width - AutofillPasswordFieldLabel.Bounds.Left * 2;
}
if(AutofillPersistNotificationLabel != null)
{
AutofillPersistNotificationLabel.WidthRequest =
StackLayout.Bounds.Width - AutofillPersistNotificationLabel.Bounds.Left * 2;
}
}
private void AnalyticsCell_Changed(object sender, ToggledEventArgs e)

View file

@ -0,0 +1,288 @@
using System;
using System.Linq;
using Acr.UserDialogs;
using Bit.App.Abstractions;
using Bit.App.Controls;
using Bit.App.Models.Page;
using Bit.App.Resources;
using Xamarin.Forms;
using XLabs.Ioc;
using Bit.App.Utilities;
using Plugin.Connectivity.Abstractions;
using System.Collections.Generic;
using Bit.App.Models;
using System.Threading.Tasks;
namespace Bit.App.Pages
{
public class VaultAttachmentsPage : ExtendedContentPage
{
private readonly ILoginService _loginService;
private readonly IUserDialogs _userDialogs;
private readonly IConnectivity _connectivity;
private readonly IDeviceActionService _deviceActiveService;
private readonly IGoogleAnalyticsService _googleAnalyticsService;
private readonly string _loginId;
private Login _login;
private byte[] _fileBytes;
private DateTime? _lastAction;
public VaultAttachmentsPage(string loginId)
: base(true)
{
_loginId = loginId;
_loginService = Resolver.Resolve<ILoginService>();
_connectivity = Resolver.Resolve<IConnectivity>();
_userDialogs = Resolver.Resolve<IUserDialogs>();
_deviceActiveService = Resolver.Resolve<IDeviceActionService>();
_googleAnalyticsService = Resolver.Resolve<IGoogleAnalyticsService>();
Init();
}
public ExtendedObservableCollection<VaultAttachmentsPageModel.Attachment> PresentationAttchments { get; private set; }
= new ExtendedObservableCollection<VaultAttachmentsPageModel.Attachment>();
public ListView ListView { get; set; }
public StackLayout NoDataStackLayout { get; set; }
public StackLayout AddNewStackLayout { get; set; }
public Label FileLabel { get; set; }
public ExtendedTableView NewTable { get; set; }
public Label NoDataLabel { get; set; }
private void Init()
{
SubscribeFileResult(true);
var selectButton = new ExtendedButton
{
Text = AppResources.ChooseFile,
Command = new Command(() => _deviceActiveService.SelectFile()),
Style = (Style)Application.Current.Resources["btn-primaryAccent"],
FontSize = Device.GetNamedSize(NamedSize.Medium, typeof(Button))
};
FileLabel = new Label
{
Text = AppResources.NoFileChosen,
Style = (Style)Application.Current.Resources["text-muted"],
FontSize = Device.GetNamedSize(NamedSize.Small, typeof(Label)),
HorizontalTextAlignment = TextAlignment.Center
};
AddNewStackLayout = new StackLayout
{
Children = { selectButton, FileLabel },
Orientation = StackOrientation.Vertical,
Padding = new Thickness(20, Helpers.OnPlatform(iOS: 10, Android: 20), 20, 20),
VerticalOptions = LayoutOptions.Start
};
NewTable = new ExtendedTableView
{
Intent = TableIntent.Settings,
HasUnevenRows = true,
NoFooter = true,
EnableScrolling = false,
EnableSelection = false,
VerticalOptions = LayoutOptions.Start,
Margin = new Thickness(0, Helpers.OnPlatform(iOS: 10, Android: 30), 0, 0),
Root = new TableRoot
{
new TableSection(AppResources.AddNewAttachment)
{
new ExtendedViewCell
{
View = AddNewStackLayout,
BackgroundColor = Color.White
}
}
}
};
ListView = new ListView(ListViewCachingStrategy.RecycleElement)
{
ItemsSource = PresentationAttchments,
HasUnevenRows = true,
ItemTemplate = new DataTemplate(() => new VaultAttachmentsViewCell()),
Footer = NewTable,
VerticalOptions = LayoutOptions.FillAndExpand
};
NoDataLabel = new Label
{
Text = AppResources.NoAttachments,
HorizontalTextAlignment = TextAlignment.Center,
FontSize = Device.GetNamedSize(NamedSize.Small, typeof(Label)),
Style = (Style)Application.Current.Resources["text-muted"]
};
NoDataStackLayout = new StackLayout
{
VerticalOptions = LayoutOptions.Start,
Spacing = 0,
Margin = new Thickness(0, 40, 0, 0)
};
var saveToolBarItem = new ToolbarItem(AppResources.Save, null, async () =>
{
if(_lastAction.LastActionWasRecent() || _login == null)
{
return;
}
_lastAction = DateTime.UtcNow;
if(!_connectivity.IsConnected)
{
AlertNoConnection();
return;
}
if(_fileBytes == null)
{
await DisplayAlert(AppResources.AnErrorHasOccurred, string.Format(AppResources.ValidationFieldRequired,
AppResources.File), AppResources.Ok);
return;
}
_userDialogs.ShowLoading(AppResources.Saving, MaskType.Black);
var saveTask = await _loginService.EncryptAndSaveAttachmentAsync(_login, _fileBytes, FileLabel.Text);
_userDialogs.HideLoading();
if(saveTask.Succeeded)
{
_fileBytes = null;
FileLabel.Text = AppResources.NoFileChosen;
_userDialogs.Toast(AppResources.AttachementAdded);
_googleAnalyticsService.TrackAppEvent("AddedAttachment");
await LoadAttachmentsAsync();
}
else if(saveTask.Errors.Count() > 0)
{
await _userDialogs.AlertAsync(saveTask.Errors.First().Message, AppResources.AnErrorHasOccurred);
}
else
{
await _userDialogs.AlertAsync(AppResources.AnErrorHasOccurred);
}
}, ToolbarItemOrder.Default, 0);
Title = AppResources.Attachments;
Content = ListView;
ToolbarItems.Add(saveToolBarItem);
if(Device.RuntimePlatform == Device.iOS)
{
ListView.RowHeight = -1;
NewTable.RowHeight = -1;
NewTable.EstimatedRowHeight = 44;
NewTable.HeightRequest = 180;
ListView.BackgroundColor = Color.Transparent;
ToolbarItems.Add(new DismissModalToolBarItem(this, AppResources.Close));
}
}
protected async override void OnAppearing()
{
base.OnAppearing();
ListView.ItemSelected += AttachmentSelected;
await LoadAttachmentsAsync();
}
protected override void OnDisappearing()
{
base.OnDisappearing();
ListView.ItemSelected -= AttachmentSelected;
}
private async Task LoadAttachmentsAsync()
{
_login = await _loginService.GetByIdAsync(_loginId);
if(_login == null)
{
await Navigation.PopForDeviceAsync();
return;
}
var attachmentsToAdd = _login.Attachments
.Select(a => new VaultAttachmentsPageModel.Attachment(a))
.OrderBy(s => s.Name);
PresentationAttchments.ResetWithRange(attachmentsToAdd);
AdjustContent();
}
private void AdjustContent()
{
if(PresentationAttchments.Count == 0)
{
NoDataStackLayout.Children.Clear();
NoDataStackLayout.Children.Add(NoDataLabel);
NoDataStackLayout.Children.Add(NewTable);
Content = NoDataStackLayout;
}
else
{
Content = ListView;
}
}
private async void AttachmentSelected(object sender, SelectedItemChangedEventArgs e)
{
var attachment = e.SelectedItem as VaultAttachmentsPageModel.Attachment;
if(attachment == null)
{
return;
}
((ListView)sender).SelectedItem = null;
var buttons = new List<string> { };
var selection = await DisplayActionSheet(attachment.Name, AppResources.Cancel, AppResources.Delete,
buttons.ToArray());
if(selection == AppResources.Delete)
{
_userDialogs.ShowLoading(AppResources.Deleting, MaskType.Black);
var saveTask = await _loginService.DeleteAttachmentAsync(_login, attachment.Id);
_userDialogs.HideLoading();
if(saveTask.Succeeded)
{
_userDialogs.Toast(AppResources.AttachmentDeleted);
_googleAnalyticsService.TrackAppEvent("DeletedAttachment");
await LoadAttachmentsAsync();
}
else if(saveTask.Errors.Count() > 0)
{
await _userDialogs.AlertAsync(saveTask.Errors.First().Message, AppResources.AnErrorHasOccurred);
}
else
{
await _userDialogs.AlertAsync(AppResources.AnErrorHasOccurred);
}
}
}
private void AlertNoConnection()
{
DisplayAlert(AppResources.InternetConnectionRequiredTitle, AppResources.InternetConnectionRequiredMessage,
AppResources.Ok);
}
private void SubscribeFileResult(bool subscribe)
{
MessagingCenter.Unsubscribe<Application, Tuple<byte[], string>>(Application.Current, "SelectFileResult");
if(!subscribe)
{
return;
}
MessagingCenter.Subscribe<Application, Tuple<byte[], string>>(
Application.Current, "SelectFileResult", (sender, result) =>
{
FileLabel.Text = result.Item2;
_fileBytes = result.Item1;
SubscribeFileResult(true);
});
}
}
}

View file

@ -42,6 +42,7 @@ namespace Bit.App.Pages
public FormEditorCell NotesCell { get; private set; }
public FormPickerCell FolderCell { get; private set; }
public ExtendedTextCell GenerateCell { get; private set; }
public ExtendedTextCell AttachmentsCell { get; private set; }
public ExtendedTextCell DeleteCell { get; private set; }
private void Init()
@ -112,6 +113,12 @@ namespace Bit.App.Pages
On = login.Favorite
};
AttachmentsCell = new ExtendedTextCell
{
Text = AppResources.Attachments,
ShowDisclousure = true
};
DeleteCell = new ExtendedTextCell { Text = AppResources.Delete, TextColor = Color.Red };
var table = new ExtendedTableView
@ -133,7 +140,8 @@ namespace Bit.App.Pages
{
TotpCell,
FolderCell,
favoriteCell
favoriteCell,
AttachmentsCell
},
new TableSection(AppResources.Notes)
{
@ -257,6 +265,10 @@ namespace Bit.App.Pages
{
GenerateCell.Tapped += GenerateCell_Tapped;
}
if(AttachmentsCell != null)
{
AttachmentsCell.Tapped += AttachmentsCell_Tapped;
}
if(DeleteCell != null)
{
DeleteCell.Tapped += DeleteCell_Tapped;
@ -286,6 +298,10 @@ namespace Bit.App.Pages
{
GenerateCell.Tapped -= GenerateCell_Tapped;
}
if(AttachmentsCell != null)
{
AttachmentsCell.Tapped -= AttachmentsCell_Tapped;
}
if(DeleteCell != null)
{
DeleteCell.Tapped -= DeleteCell_Tapped;
@ -336,6 +352,12 @@ namespace Bit.App.Pages
await Navigation.PushForDeviceAsync(page);
}
private async void AttachmentsCell_Tapped(object sender, EventArgs e)
{
var page = new ExtendedNavigationPage(new VaultAttachmentsPage(_loginId));
await Navigation.PushModalAsync(page);
}
private async void DeleteCell_Tapped(object sender, EventArgs e)
{
if(!_connectivity.IsConnected)

View file

@ -10,6 +10,7 @@ using System.Threading.Tasks;
using Bit.App.Utilities;
using System.Collections.Generic;
using Bit.App.Models;
using System.Linq;
namespace Bit.App.Pages
{
@ -164,52 +165,77 @@ namespace Bit.App.Pages
Model.Update(login);
if(!Model.ShowUri)
if(LoginInformationSection.Contains(UriCell))
{
LoginInformationSection.Remove(UriCell);
}
else if(!LoginInformationSection.Contains(UriCell))
if(Model.ShowUri)
{
LoginInformationSection.Add(UriCell);
}
if(!Model.ShowUsername)
if(LoginInformationSection.Contains(UsernameCell))
{
LoginInformationSection.Remove(UsernameCell);
}
else if(!LoginInformationSection.Contains(UsernameCell))
if(Model.ShowUsername)
{
LoginInformationSection.Add(UsernameCell);
}
if(!Model.ShowPassword)
if(LoginInformationSection.Contains(PasswordCell))
{
LoginInformationSection.Remove(PasswordCell);
}
else if(!LoginInformationSection.Contains(PasswordCell))
if(Model.ShowPassword)
{
LoginInformationSection.Add(PasswordCell);
}
if(!Model.ShowNotes)
if(Table.Root.Contains(NotesSection))
{
Table.Root.Remove(NotesSection);
}
else if(!Table.Root.Contains(NotesSection))
if(Model.ShowNotes)
{
Table.Root.Add(NotesSection);
}
// Totp
if(LoginInformationSection.Contains(TotpCodeCell))
{
LoginInformationSection.Remove(TotpCodeCell);
}
if(login.Totp != null && (_tokenService.TokenPremium || login.OrganizationUseTotp))
{
var totpKey = login.Totp.Decrypt(login.OrganizationId);
if(!string.IsNullOrWhiteSpace(totpKey))
{
Model.TotpCode = Crypto.Totp(totpKey);
if(!string.IsNullOrWhiteSpace(Model.TotpCode))
{
TotpTick(totpKey);
Device.StartTimer(new TimeSpan(0, 0, 1), () =>
{
TotpTick(totpKey);
return true;
});
LoginInformationSection.Add(TotpCodeCell);
}
}
}
CleanupAttachmentCells();
if(!Model.ShowAttachments && Table.Root.Contains(AttachmentsSection))
if(Table.Root.Contains(AttachmentsSection))
{
Table.Root.Remove(AttachmentsSection);
}
else if(Model.ShowAttachments && !Table.Root.Contains(AttachmentsSection))
if(Model.ShowAttachments)
{
AttachmentsSection = new TableSection(AppResources.Attachments);
AttachmentCells = new List<AttachmentViewCell>();
foreach(var attachment in Model.Attachments)
foreach(var attachment in Model.Attachments.OrderBy(s => s.Name))
{
var attachmentCell = new AttachmentViewCell(attachment, async () =>
{
@ -222,38 +248,6 @@ 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();
}

View file

@ -5,6 +5,8 @@ using Bit.App.Abstractions;
using Bit.App.Models.Api;
using Newtonsoft.Json;
using Plugin.Connectivity.Abstractions;
using System.Globalization;
using System.IO;
namespace Bit.App.Repositories
{
@ -99,5 +101,89 @@ namespace Bit.App.Repositories
}
}
}
public virtual async Task<ApiResult<CipherResponse>> PostAttachmentAsync(string cipherId, byte[] data,
string fileName)
{
if(!Connectivity.IsConnected)
{
return HandledNotConnected<CipherResponse>();
}
var tokenStateResponse = await HandleTokenStateAsync<CipherResponse>();
if(!tokenStateResponse.Succeeded)
{
return tokenStateResponse;
}
using(var client = HttpService.ApiClient)
using(var content = new MultipartFormDataContent("--BWMobileFormBoundary" + DateTime.UtcNow.Ticks))
{
content.Add(new StreamContent(new MemoryStream(data)), "data", fileName);
var requestMessage = new TokenHttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri(client.BaseAddress, string.Concat(ApiRoute, "/", cipherId, "/attachment")),
Content = content
};
try
{
var response = await client.SendAsync(requestMessage).ConfigureAwait(false);
if(!response.IsSuccessStatusCode)
{
return await HandleErrorAsync<CipherResponse>(response).ConfigureAwait(false);
}
var responseContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
var responseObj = JsonConvert.DeserializeObject<CipherResponse>(responseContent);
return ApiResult<CipherResponse>.Success(responseObj, response.StatusCode);
}
catch
{
return HandledWebException<CipherResponse>();
}
}
}
public virtual async Task<ApiResult> DeleteAttachmentAsync(string cipherId, string attachmentId)
{
if(!Connectivity.IsConnected)
{
return HandledNotConnected();
}
var tokenStateResponse = await HandleTokenStateAsync();
if(!tokenStateResponse.Succeeded)
{
return tokenStateResponse;
}
using(var client = HttpService.ApiClient)
{
var requestMessage = new TokenHttpRequestMessage()
{
Method = HttpMethod.Delete,
RequestUri = new Uri(client.BaseAddress,
string.Concat(ApiRoute, "/", cipherId, "/attachment/", attachmentId)),
};
try
{
var response = await client.SendAsync(requestMessage).ConfigureAwait(false);
if(!response.IsSuccessStatusCode)
{
return await HandleErrorAsync(response).ConfigureAwait(false);
}
return ApiResult.Success(response.StatusCode);
}
catch
{
return HandledWebException();
}
}
}
}
}

View file

@ -151,6 +151,24 @@ namespace Bit.App.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Attachment added.
/// </summary>
public static string AttachementAdded {
get {
return ResourceManager.GetString("AttachementAdded", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Attachment deleted.
/// </summary>
public static string AttachmentDeleted {
get {
return ResourceManager.GetString("AttachmentDeleted", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to This attachment is {0} in size. Are you sure you want to download it onto your device?.
/// </summary>
@ -529,6 +547,15 @@ namespace Bit.App.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Choose File.
/// </summary>
public static string ChooseFile {
get {
return ResourceManager.GetString("ChooseFile", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Close.
/// </summary>
@ -997,6 +1024,15 @@ namespace Bit.App.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to File.
/// </summary>
public static string File {
get {
return ResourceManager.GetString("File", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to File a Bug Report.
/// </summary>
@ -1582,6 +1618,15 @@ namespace Bit.App.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to There are no attachments..
/// </summary>
public static string NoAttachments {
get {
return ResourceManager.GetString("NoAttachments", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to There are no favorites in your vault..
/// </summary>
@ -1591,6 +1636,15 @@ namespace Bit.App.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to No file chosen.
/// </summary>
public static string NoFileChosen {
get {
return ResourceManager.GetString("NoFileChosen", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to There are no logins in your vault..
/// </summary>

View file

@ -965,4 +965,22 @@
<data name="PremiumRequired" xml:space="preserve">
<value>A premium membership is required to use this feature.</value>
</data>
<data name="AttachementAdded" xml:space="preserve">
<value>Attachment added</value>
</data>
<data name="AttachmentDeleted" xml:space="preserve">
<value>Attachment deleted</value>
</data>
<data name="ChooseFile" xml:space="preserve">
<value>Choose File</value>
</data>
<data name="File" xml:space="preserve">
<value>File</value>
</data>
<data name="NoFileChosen" xml:space="preserve">
<value>No file chosen</value>
</data>
<data name="NoAttachments" xml:space="preserve">
<value>There are no attachments.</value>
</data>
</root>

View file

@ -260,6 +260,26 @@ namespace Bit.App.Services
return Crypto.AesCbcEncrypt(plainBytes, key);
}
public byte[] EncryptToBytes(byte[] plainBytes, SymmetricCryptoKey key = null)
{
if(key == null)
{
key = EncKey ?? Key;
}
if(key == null)
{
throw new ArgumentNullException(nameof(key));
}
if(plainBytes == null)
{
throw new ArgumentNullException(nameof(plainBytes));
}
return Crypto.AesCbcEncryptToBytes(plainBytes, key);
}
public string Decrypt(CipherString encyptedValue, SymmetricCryptoKey key = null)
{
try

View file

@ -17,6 +17,7 @@ namespace Bit.App.Services
private readonly IAttachmentRepository _attachmentRepository;
private readonly IAuthService _authService;
private readonly ILoginApiRepository _loginApiRepository;
private readonly ICipherApiRepository _cipherApiRepository;
private readonly ISettingsService _settingsService;
private readonly ICryptoService _cryptoService;
@ -25,6 +26,7 @@ namespace Bit.App.Services
IAttachmentRepository attachmentRepository,
IAuthService authService,
ILoginApiRepository loginApiRepository,
ICipherApiRepository cipherApiRepository,
ISettingsService settingsService,
ICryptoService cryptoService)
{
@ -32,6 +34,7 @@ namespace Bit.App.Services
_attachmentRepository = attachmentRepository;
_authService = authService;
_loginApiRepository = loginApiRepository;
_cipherApiRepository = cipherApiRepository;
_settingsService = settingsService;
_cryptoService = cryptoService;
}
@ -238,7 +241,7 @@ namespace Bit.App.Services
{
return null;
}
if(!string.IsNullOrWhiteSpace(orgId))
{
return _cryptoService.DecryptToBytes(data, _cryptoService.GetOrgKey(orgId));
@ -255,6 +258,47 @@ namespace Bit.App.Services
}
}
public async Task<ApiResult<CipherResponse>> EncryptAndSaveAttachmentAsync(Login login, byte[] data, string fileName)
{
var encFileName = fileName.Encrypt(login.OrganizationId);
var encBytes = _cryptoService.EncryptToBytes(data,
login.OrganizationId != null ? _cryptoService.GetOrgKey(login.OrganizationId) : null);
var response = await _cipherApiRepository.PostAttachmentAsync(login.Id, encBytes, encFileName.EncryptedString);
if(response.Succeeded)
{
var attachmentData = response.Result.Attachments.Select(a => new AttachmentData(a, login.Id));
foreach(var attachment in attachmentData)
{
await _attachmentRepository.UpsertAsync(attachment);
}
login.Attachments = response.Result.Attachments.Select(a => new Attachment(a));
}
else if(response.StatusCode == System.Net.HttpStatusCode.Forbidden
|| response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
MessagingCenter.Send(Application.Current, "Logout", (string)null);
}
return response;
}
public async Task<ApiResult> DeleteAttachmentAsync(Login login, string attachmentId)
{
var response = await _cipherApiRepository.DeleteAttachmentAsync(login.Id, attachmentId);
if(response.Succeeded)
{
await _attachmentRepository.DeleteAsync(attachmentId);
}
else if(response.StatusCode == System.Net.HttpStatusCode.Forbidden
|| response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
MessagingCenter.Send(Application.Current, "Logout", (string)null);
}
return response;
}
private string WebUriFromAndroidAppUri(string androidAppUriString)
{
if(!UriIsAndroidApp(androidAppUriString))

View file

@ -10,6 +10,26 @@ namespace Bit.App.Utilities
public static class Crypto
{
public static CipherString AesCbcEncrypt(byte[] plainBytes, SymmetricCryptoKey key)
{
var parts = AesCbcEncryptToParts(plainBytes, key);
return new CipherString(parts.Item1, Convert.ToBase64String(parts.Item2),
Convert.ToBase64String(parts.Item4), Convert.ToBase64String(parts.Item3));
}
public static byte[] AesCbcEncryptToBytes(byte[] plainBytes, SymmetricCryptoKey key)
{
var parts = AesCbcEncryptToParts(plainBytes, key);
var encBytes = new byte[1 + parts.Item2.Length + parts.Item3.Length + parts.Item4.Length];
encBytes[0] = (byte)parts.Item1;
parts.Item2.CopyTo(encBytes, 1);
parts.Item3.CopyTo(encBytes, 1 + parts.Item2.Length);
parts.Item4.CopyTo(encBytes, 1 + parts.Item2.Length + parts.Item3.Length);
return encBytes;
}
private static Tuple<EncryptionType, byte[], byte[], byte[]> AesCbcEncryptToParts(byte[] plainBytes,
SymmetricCryptoKey key)
{
if(key == null)
{
@ -24,11 +44,10 @@ namespace Bit.App.Utilities
var provider = WinRTCrypto.SymmetricKeyAlgorithmProvider.OpenAlgorithm(SymmetricAlgorithm.AesCbcPkcs7);
var cryptoKey = provider.CreateSymmetricKey(key.EncKey);
var iv = RandomBytes(provider.BlockLength);
var encryptedBytes = WinRTCrypto.CryptographicEngine.Encrypt(cryptoKey, plainBytes, iv);
var mac = key.MacKey != null ? ComputeMacBase64(encryptedBytes, iv, key.MacKey) : null;
var ct = WinRTCrypto.CryptographicEngine.Encrypt(cryptoKey, plainBytes, iv);
var mac = key.MacKey != null ? ComputeMac(ct, iv, key.MacKey) : null;
return new CipherString(key.EncryptionType, Convert.ToBase64String(iv),
Convert.ToBase64String(encryptedBytes), mac);
return new Tuple<EncryptionType, byte[], byte[], byte[]>(key.EncryptionType, iv, mac, ct);
}
public static byte[] AesCbcDecrypt(CipherString encyptedValue, SymmetricCryptoKey key)
@ -84,12 +103,6 @@ namespace Bit.App.Utilities
return WinRTCrypto.CryptographicBuffer.GenerateRandom(length);
}
public static string ComputeMacBase64(byte[] ctBytes, byte[] ivBytes, byte[] macKey)
{
var mac = ComputeMac(ctBytes, ivBytes, macKey);
return Convert.ToBase64String(mac);
}
public static byte[] ComputeMac(byte[] ctBytes, byte[] ivBytes, byte[] macKey)
{
if(ctBytes == null)

View file

@ -5,6 +5,9 @@ using Foundation;
using System.IO;
using MobileCoreServices;
using Bit.App.Resources;
using Xamarin.Forms;
using Photos;
using System.Net;
namespace Bit.iOS.Services
{
@ -86,7 +89,7 @@ namespace Bit.iOS.Services
return tmp;
}
public byte[] SelectFile()
public void SelectFile()
{
var controller = GetVisibleViewController();
var picker = new UIDocumentMenuViewController(new string[] { UTType.Data }, UIDocumentPickerMode.Import);
@ -114,18 +117,25 @@ namespace Bit.iOS.Services
};
controller.PresentViewController(picker, true, null);
return null;
}
private void ImagePicker_FinishedPickingMedia(object sender, UIImagePickerMediaPickedEventArgs e)
{
if(sender is UIImagePickerController picker)
{
//var image = (UIImage)e.Info.ObjectForKey(new NSString("UIImagePickerControllerOriginalImage"));
string fileName = null;
NSObject urlObj;
if(e.Info.TryGetValue(UIImagePickerController.ReferenceUrl, out urlObj))
{
var result = PHAsset.FetchAssets(new NSUrl[] { (urlObj as NSUrl) }, null);
fileName = result?.firstObject?.ValueForKey(new NSString("filename"))?.ToString();
}
// TODO: determine if JPG or PNG from extension. Get filename somehow?
fileName = fileName ?? $"photo_{DateTime.UtcNow.ToString("yyyyMMddHHmmss")}.jpg";
var lowerFilename = fileName?.ToLowerInvariant();
byte[] data;
if(false)
if(lowerFilename != null && (lowerFilename.EndsWith(".jpg") || lowerFilename.EndsWith(".jpeg")))
{
using(var imageData = e.OriginalImage.AsJPEG())
{
@ -144,6 +154,7 @@ namespace Bit.iOS.Services
}
}
SelectFileResult(data, fileName);
picker.DismissViewController(true, null);
}
}
@ -159,17 +170,35 @@ namespace Bit.iOS.Services
private void DocumentPicker_DidPickDocument(object sender, UIDocumentPickedEventArgs e)
{
e.Url.StartAccessingSecurityScopedResource();
var doc = new UIDocument(e.Url);
var fileName = doc.LocalizedName;
if(string.IsNullOrWhiteSpace(fileName))
{
var path = doc.FileUrl?.ToString();
if(path != null)
{
path = WebUtility.UrlDecode(path);
var split = path.LastIndexOf('/');
fileName = path.Substring(split + 1);
}
}
var fileCoordinator = new NSFileCoordinator();
// TODO: get filename?
NSError error;
fileCoordinator.CoordinateRead(e.Url, NSFileCoordinatorReadingOptions.WithoutChanges, out error, (url) =>
{
var data = NSData.FromUrl(url).ToArray();
SelectFileResult(data, fileName ?? "unknown_file_name");
});
e.Url.StopAccessingSecurityScopedResource();
}
private void SelectFileResult(byte[] data, string fileName)
{
MessagingCenter.Send(Xamarin.Forms.Application.Current, "SelectFileResult",
new Tuple<byte[], string>(data, fileName));
}
}
}

View file

@ -2170,6 +2170,9 @@ namespace Bit.Android.Test
// aapt resource value: 0x7f080038
public const int collapseActionView = 2131230776;
// aapt resource value: 0x7f0800b7
public const int contentFrame = 2131230903;
// aapt resource value: 0x7f08004c
public const int contentPanel = 2131230796;
@ -2790,6 +2793,12 @@ namespace Bit.Android.Test
// aapt resource value: 0x7f03003e
public const int test_suite = 2130903102;
// aapt resource value: 0x7f03003f
public const int zxingscanneractivitylayout = 2130903103;
// aapt resource value: 0x7f030040
public const int zxingscannerfragmentlayout = 2130903104;
static Layout()
{
global::Android.Runtime.ResourceIdManager.UpdateIdValues();