From ae5b6377867a012723339b4146c104ad1312394e Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Thu, 2 Jun 2016 00:18:47 -0400 Subject: [PATCH] added webview support for app extension. moved safari extension to same code as webview. --- src/iOS.Extension/ActionViewController.cs | 228 ++++++++++++---- src/iOS.Extension/extension.js | 313 +++++----------------- src/iOS.Extension/iOS.Extension.csproj | 8 +- src/iOS.Extension/packages.config | 1 + 4 files changed, 252 insertions(+), 298 deletions(-) diff --git a/src/iOS.Extension/ActionViewController.cs b/src/iOS.Extension/ActionViewController.cs index e0f4ff7c9..90b9717ed 100644 --- a/src/iOS.Extension/ActionViewController.cs +++ b/src/iOS.Extension/ActionViewController.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.Linq; using Bit.App.Abstractions; @@ -9,6 +10,7 @@ using CoreGraphics; using Foundation; using Microsoft.Practices.Unity; using MobileCoreServices; +using Newtonsoft.Json; using UIKit; using XLabs.Ioc; using XLabs.Ioc.Unity; @@ -37,6 +39,9 @@ namespace Bit.iOS.Extension private const string AppExtensionGeneratedPasswordRequireSymbolsKey = "password_require_symbols"; private const string AppExtensionGeneratedPasswordForbiddenCharactersKey = "password_forbidden_characters"; + private const string AppExtensionWebViewPageFillScript = "fillScript"; + private const string AppExtensionWebViewPageDetails = "pageDetails"; + private const string UTTypeAppExtensionFindLoginAction = "org.appextension.find-login-action"; private const string UTTypeAppExtensionSaveLoginAction = "org.appextension.save-login-action"; private const string UTTypeAppExtensionChangePasswordAction = "org.appextension.change-password-action"; @@ -59,6 +64,7 @@ namespace Bit.iOS.Extension public string OldPassword { get; set; } public string Notes { get; set; } public PasswordGenerationOptions PasswordOptions { get; set; } + public PageDetails Details { get; set; } private void SetIoc() { @@ -90,34 +96,29 @@ namespace Bit.iOS.Extension Resolver.SetResolver(new UnityResolver(container)); } - public override void DidReceiveMemoryWarning() - { - base.DidReceiveMemoryWarning(); - } - public override void LoadView() { foreach(var item in ExtensionContext.InputItems) { + var processed = false; foreach(var itemProvider in item.Attachments) { - if(ProcessWebUrlProvider(itemProvider)) - { - break; - } - else if(ProcessFindLoginProvider(itemProvider)) - { - break; - } - else if(ProcessSaveLoginProvider(itemProvider)) - { - break; - } - else if(ProcessChangePasswordProvider(itemProvider)) + if(ProcessWebUrlProvider(itemProvider) + || ProcessFindLoginProvider(itemProvider) + || ProcessFindLoginBrowserProvider(itemProvider, UTTypeAppExtensionFillBrowserAction) + || ProcessFindLoginBrowserProvider(itemProvider, UTTypeAppExtensionFillWebViewAction) + || ProcessSaveLoginProvider(itemProvider) + || ProcessChangePasswordProvider(itemProvider)) { + processed = true; break; } } + + if(processed) + { + break; + } } View = new UIView(new CGRect(x: 0.0, y: 0, width: 320.0, height: 200.0)); @@ -130,19 +131,20 @@ namespace Bit.iOS.Extension private void Button_TouchUpInside(object sender, EventArgs e) { NSDictionary itemData = null; - if(ProviderType == UTType.PropertyList) - { - itemData = new NSDictionary( - "username", "me@example.com", - "password", "mypassword", - "autoSubmit", true); - } - else if(ProviderType == UTTypeAppExtensionFindLoginAction) + if(ProviderType == UTTypeAppExtensionFindLoginAction) { itemData = new NSDictionary( AppExtensionUsernameKey, "me@example.com", AppExtensionPasswordKey, "mypassword"); } + else if(ProviderType == UTType.PropertyList + || ProviderType == UTTypeAppExtensionFillBrowserAction + || ProviderType == UTTypeAppExtensionFillWebViewAction) + { + var fillScript = new FillScript(Details); + var scriptJson = JsonConvert.SerializeObject(fillScript); + itemData = new NSDictionary(AppExtensionWebViewPageFillScript, scriptJson); + } else if(ProviderType == UTTypeAppExtensionSaveLoginAction) { itemData = new NSDictionary( @@ -155,10 +157,6 @@ namespace Bit.iOS.Extension AppExtensionPasswordKey, "mynewpassword", AppExtensionOldPasswordKey, "myoldpassword"); } - else - { - return; - } var resultsProvider = new NSItemProvider(itemData, UTType.PropertyList); var resultsItem = new NSExtensionItem { Attachments = new NSItemProvider[] { resultsProvider } }; @@ -197,6 +195,7 @@ namespace Bit.iOS.Extension Debug.WriteLine("BW LOG, Password: " + Password); Debug.WriteLine("BW LOG, Old Password: " + OldPassword); Debug.WriteLine("BW LOG, Notes: " + Notes); + Debug.WriteLine("BW LOG, Details: " + Details); if(PasswordOptions != null) { @@ -221,7 +220,9 @@ namespace Bit.iOS.Extension return; } - Url = new Uri(result.ValueForKey(new NSString("url")) as NSString); + Url = new Uri(result.ValueForKey(new NSString(AppExtensionUrlStringKey)) as NSString); + var jsonStr = result.ValueForKey(new NSString(AppExtensionWebViewPageDetails)) as NSString; + Details = DeserializeString(jsonStr); }); } @@ -239,6 +240,21 @@ namespace Bit.iOS.Extension }); } + private bool ProcessFindLoginBrowserProvider(NSItemProvider itemProvider, string action) + { + return ProcessItemProvider(itemProvider, action, (dict) => + { + var version = dict[AppExtensionVersionNumberKey] as NSNumber; + var url = dict[AppExtensionUrlStringKey] as NSString; + if(url != null) + { + Url = new Uri(url); + } + + Details = DeserializeDictionary(dict[AppExtensionWebViewPageDetails] as NSDictionary); + }); + } + private bool ProcessSaveLoginProvider(NSItemProvider itemProvider) { return ProcessItemProvider(itemProvider, UTTypeAppExtensionSaveLoginAction, (dict) => @@ -251,7 +267,6 @@ namespace Bit.iOS.Extension var password = dict[AppExtensionPasswordKey] as NSString; var notes = dict[AppExtensionNotesKey] as NSString; var fields = dict[AppExtensionFieldsKey] as NSDictionary; - var passwordGenerationOptions = dict[AppExtensionPasswordGeneratorOptionsKey] as NSDictionary; if(url != null) { @@ -263,7 +278,7 @@ namespace Bit.iOS.Extension Username = username; Password = password; Notes = notes; - PasswordOptions = new PasswordGenerationOptions(passwordGenerationOptions); + PasswordOptions = DeserializeDictionary(dict[AppExtensionPasswordGeneratorOptionsKey] as NSDictionary); }); } @@ -280,7 +295,6 @@ namespace Bit.iOS.Extension var oldPassword = dict[AppExtensionOldPasswordKey] as NSString; var notes = dict[AppExtensionNotesKey] as NSString; var fields = dict[AppExtensionFieldsKey] as NSDictionary; - var passwordGenerationOptions = dict[AppExtensionPasswordGeneratorOptionsKey] as NSDictionary; if(url != null) { @@ -292,31 +306,147 @@ namespace Bit.iOS.Extension Password = password; OldPassword = oldPassword; Notes = notes; - PasswordOptions = new PasswordGenerationOptions(passwordGenerationOptions); + PasswordOptions = DeserializeDictionary(dict[AppExtensionPasswordGeneratorOptionsKey] as NSDictionary); }); } + private T DeserializeDictionary(NSDictionary dict) + { + if(dict != null) + { + NSError jsonError; + var jsonData = NSJsonSerialization.Serialize(dict, NSJsonWritingOptions.PrettyPrinted, out jsonError); + if(jsonData != null) + { + var jsonString = new NSString(jsonData, NSStringEncoding.UTF8); + return DeserializeString(jsonString); + } + } + + return default(T); + } + + private T DeserializeString(NSString jsonString) + { + if(jsonString != null) + { + var convertedObject = JsonConvert.DeserializeObject(jsonString.ToString()); + return convertedObject; + } + + return default(T); + } + public class PasswordGenerationOptions { - public PasswordGenerationOptions(NSDictionary dict) - { - if(dict == null) - { - throw new ArgumentNullException(nameof(dict)); - } - - MinLength = (dict[AppExtensionGeneratedPasswordMinLengthKey] as NSNumber)?.Int32Value ?? 0; - MaxLength = (dict[AppExtensionGeneratedPasswordMaxLengthKey] as NSNumber)?.Int32Value ?? 0; - RequireDigits = (dict[AppExtensionGeneratedPasswordRequireDigitsKey] as NSNumber)?.BoolValue ?? false; - RequireSymbols = (dict[AppExtensionGeneratedPasswordRequireSymbolsKey] as NSNumber)?.BoolValue ?? false; - ForbiddenCharacters = (dict[AppExtensionGeneratedPasswordForbiddenCharactersKey] as NSString)?.ToString(); - } - public int MinLength { get; set; } public int MaxLength { get; set; } public bool RequireDigits { get; set; } public bool RequireSymbols { get; set; } public string ForbiddenCharacters { get; set; } } + + public class PageDetails + { + public string DocumentUUID { get; set; } + public string Title { get; set; } + public string Url { get; set; } + public string DocumentUrl { get; set; } + public string TabUrl { get; set; } + public Dictionary Forms { get; set; } + public List Fields { get; set; } + public long CollectedTimestamp { get; set; } + + public class Form + { + public string OpId { get; set; } + public string HtmlName { get; set; } + public string HtmlId { get; set; } + public string HtmlAction { get; set; } + public string HtmlMethod { get; set; } + } + + public class Field + { + public string OpId { get; set; } + public int ElementNumber { get; set; } + public bool Visible { get; set; } + public bool Viewable { get; set; } + public string HtmlId { get; set; } + public string HtmlName { get; set; } + public string HtmlClass { get; set; } + public string LabelRight { get; set; } + public string LabelLeft { get; set; } + public string Type { get; set; } + public string Value { get; set; } + public bool Disabled { get; set; } + public bool Readonly { get; set; } + public string OnePasswordFieldType { get; set; } + public string Form { get; set; } + } + } + + public class FillScript + { + public FillScript(PageDetails pageDetails) + { + if(pageDetails == null) + { + return; + } + + DocumentUUID = pageDetails.DocumentUUID; + + var loginForm = pageDetails.Forms.FirstOrDefault(form => pageDetails.Fields.Any(f => f.Form == form.Key && f.Type == "password")).Value; + if(loginForm == null) + { + return; + } + + Script = new List>(); + + var password = pageDetails.Fields.FirstOrDefault(f => + f.Form == loginForm.OpId + && f.Type == "password"); + + var username = pageDetails.Fields.LastOrDefault(f => + f.Form == loginForm.OpId + && (f.Type == "text" || f.Type == "email") + && f.ElementNumber < password.ElementNumber); + + if(username != null) + { + Script.Add(new List { "click_on_opid", username.OpId }); + Script.Add(new List { "fill_by_opid", username.OpId, "me@example.com" }); + } + + Script.Add(new List { "click_on_opid", password.OpId }); + Script.Add(new List { "fill_by_opid", password.OpId, "mypassword" }); + + if(loginForm.HtmlAction != null) + { + AutoSubmit = new Submit { FocusOpId = password.OpId }; + } + } + + [JsonProperty(PropertyName = "script")] + public List> Script { get; set; } + [JsonProperty(PropertyName = "autosubmit")] + public Submit AutoSubmit { get; set; } + [JsonProperty(PropertyName = "documentUUID")] + public object DocumentUUID { get; set; } + [JsonProperty(PropertyName = "properties")] + public object Properties { get; set; } = new object(); + [JsonProperty(PropertyName = "options")] + public object Options { get; set; } = new object(); + [JsonProperty(PropertyName = "metadata")] + public object MetaData { get; set; } = new object(); + + public class Submit + { + [JsonProperty(PropertyName = "focusOpid")] + public string FocusOpId { get; set; } + } + } } } \ No newline at end of file diff --git a/src/iOS.Extension/extension.js b/src/iOS.Extension/extension.js index 490872cc9..8d453050d 100644 --- a/src/iOS.Extension/extension.js +++ b/src/iOS.Extension/extension.js @@ -6,270 +6,87 @@ BitwardenExtension.prototype = { console.log(arguments); var args = { - url: document.URL + 'url_string': document.URL, + pageDetails: this.collect(document) }; + arguments.completionFunction(args); }, finalize: function (arguments) { console.log('Finalize'); console.log(arguments); - if (arguments.username || arguments.password) { - this.fillDocument(arguments.username, arguments.password, arguments.autoSubmit); + if (arguments.fillScript) { + this.fill(document, JSON.parse(arguments.fillScript)); } }, - getSubmitButton: function (form) { - var button; - for (var i = 0; i < form.elements.length; i++) { - if (form.elements[i].type == 'submit') { - button = form.elements[i]; - break; - } - } - - if (!button) { - console.log('cannot locate submit button'); - return null; - } - - return button; - }, - - // Thanks Mozilla! - // ref: http://mxr.mozilla.org/firefox/source/toolkit/components/passwordmgr/src/nsLoginManager.js?raw=1 - - /* ***** BEGIN LICENSE BLOCK ***** - * Version: MPL 1.1/GPL 2.0/LGPL 2.1 - * - * The contents of this file are subject to the Mozilla Public License Version - * 1.1 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * http://www.mozilla.org/MPL/ - * - * Software distributed under the License is distributed on an "AS IS" basis, - * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License - * for the specific language governing rights and limitations under the - * License. - * - * The Original Code is mozilla.org code. - * - * The Initial Developer of the Original Code is Mozilla Corporation. - * Portions created by the Initial Developer are Copyright (C) 2007 - * the Initial Developer. All Rights Reserved. - * - * Contributor(s): - * Justin Dolske (original author) - * - * Alternatively, the contents of this file may be used under the terms of - * either the GNU General Public License Version 2 or later (the "GPL"), or - * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"), - * in which case the provisions of the GPL or the LGPL are applicable instead - * of those above. If you wish to allow use of your version of this file only - * under the terms of either the GPL or the LGPL, and not to allow others to - * use your version of this file under the terms of the MPL, indicate your - * decision by deleting the provisions above and replace them with the notice - * and other provisions required by the GPL or the LGPL. If you do not delete - * the provisions above, a recipient may use your version of this file under - * the terms of any one of the MPL, the GPL or the LGPL. - * - * ***** END LICENSE BLOCK ***** */ - /* - * Returns an array of password field elements for the specified form. - * If no pw fields are found, or if more than 3 are found, then null - * is returned. - * - * skipEmptyFields can be set to ignore password fields with no value. - */ - getPasswordFields: function (form, skipEmptyFields) { - // Locate the password fields in the form. - var pwFields = []; - for (var i = 0; i < form.elements.length; i++) { - if (form.elements[i].type != 'password') { - continue; - } + 1Password Extension - if (skipEmptyFields && !form.elements[i].value) { - continue; - } + Lovingly handcrafted by Dave Teare, Michael Fey, Rad Azzouz, and Roustem Karimov. + Copyright (c) 2014 AgileBits. All rights reserved. - pwFields[pwFields.length] = { - index: i, - element: form.elements[i] - }; - } + ================================================================================ - // If too few or too many fields, bail out. - if (pwFields.length == 0) { - console.log('form ignored -- no password fields.'); - return null; - } - else if (pwFields.length > 3) { - console.log('form ignored -- too many password fields. got ' + pwFields.length + '.'); - return null; - } + Copyright (c) 2014 AgileBits Inc. - return pwFields; + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + */ + + collect: function(document, undefined) { + document.elementsByOPID={}; + function n(d,e){function f(a,b){var c=a[b];if('string'==typeof c)return c;c=a.getAttribute(b);return'string'==typeof c?c:null}function h(a,b){if(-1===['text','password'].indexOf(b.type.toLowerCase())||!(l.test(a.value)||l.test(a.htmlID)||l.test(a.htmlName)||l.test(a.placeholder)||l.test(a['label-tag'])||l.test(a['label-data'])||l.test(a['label-aria'])))return!1;if(!a.visible)return!0;if('password'==b.type.toLowerCase())return!1;var c=b.type,d=b.value;b.focus();b.value!==d&&(b.value=d);return c!== + b.type}function r(a){switch(m(a.type)){case 'checkbox':return a.checked?'✓':'';case 'hidden':a=a.value;if(!a||'number'!=typeof a.length)return'';254\\?]/mg,''):null;return[c?c:null,a.value]}),{options:a}):null}function F(a){var b;for(a=a.parentElement||a.parentNode;a&& + 'td'!=m(a.tagName);)a=a.parentElement||a.parentNode;if(!a||void 0===a)return null;b=a.parentElement||a.parentNode;if('tr'!=b.tagName.toLowerCase())return null;b=b.previousElementSibling;if(!b||'tr'!=(b.tagName+'').toLowerCase()||b.cells&&a.cellIndex>=b.cells.length)return null;a=s(b.cells[a.cellIndex]);return a=u(a)}function A(a){var b=d.documentElement,c=a.getBoundingClientRect(),e=b.getBoundingClientRect(),f=c.left-b.clientLeft,b=c.top-b.clientTop;return a.offsetParent?0>f||f>e.width||0>b||b>e.height? + w(a):(e=a.ownerDocument.elementFromPoint(f+3,b+3))?'label'===m(e.tagName)?e===B(a):e.tagName===a.tagName:!1:!1}function w(a){for(var b;a!==d&&a;a=a.parentNode){b=t.getComputedStyle?t.getComputedStyle(a,null):a.style;if(!b)return!0;if('none'===b.display||'hidden'==b.visibility)return!1}return a===d}function B(a){var b=[];a.id&&(b=b.concat(Array.prototype.slice.call(x(d,'label[for='+JSON.stringify(a.id)+']'))));a.name&&(b=b.concat(Array.prototype.slice.call(x(d,'label[for='+JSON.stringify(a.name)+']')))); + if(0= 0; i--) { - if (form.elements[i].type == 'text' - || form.elements[i].type == 'email' - || form.elements[i].type == 'tel') { - usernameField = form.elements[i]; - break; - } - } - - if (!usernameField) { - console.log('form -- no username field found'); - } - - // If we're not submitting a form (it's a page load), there are no - // password field values for us to use for identifying fields. So, - // just assume the first password field is the one to be filled in. - if (!isSubmission || pwFields.length == 1) { - return [usernameField, pwFields[0].element, null, submitButton]; - } - - // Try to figure out WTF is in the form based on the password values. - var oldPasswordField, newPasswordField; - var pw1 = pwFields[0].element.value; - var pw2 = pwFields[1].element.value; - var pw3 = (pwFields[2] ? pwFields[2].element.value : null); - - if (pwFields.length == 3) { - // Look for two identical passwords, that's the new password - - if (pw1 == pw2 && pw2 == pw3) { - // All 3 passwords the same? Weird! Treat as if 1 pw field. - newPasswordField = pwFields[0].element; - oldPasswordField = null; - } - else if (pw1 == pw2) { - newPasswordField = pwFields[0].element; - oldPasswordField = pwFields[2].element; - } - else if (pw2 == pw3) { - oldPasswordField = pwFields[0].element; - newPasswordField = pwFields[2].element; - } - else if (pw1 == pw3) { - // A bit odd, but could make sense with the right page layout. - newPasswordField = pwFields[0].element; - oldPasswordField = pwFields[1].element; - } - else { - // We can't tell which of the 3 passwords should be saved. - console.log('form ignored -- all 3 pw fields differ'); - return [null, null, null, null]; - } - } - else { // pwFields.length == 2 - if (pw1 == pw2) { - // Treat as if 1 pw field - newPasswordField = pwFields[0].element; - oldPasswordField = null; - } - else { - // Just assume that the 2nd password is the new password - oldPasswordField = pwFields[0].element; - newPasswordField = pwFields[1].element; - } - } - - return [usernameField, newPasswordField, oldPasswordField, submitButton]; - }, - fillDocument: function (username, password, autoSubmit) { - if (!password) { - return; - } - - if (!document.forms || document.forms.length === 0) { - return; - } - - for (var i = 0; i < document.forms.length; i++) { - var fields = this.getFormFields(document.forms[i], false); - var usernameField = fields[0], - passwordField = fields[1], - submitButton = fields[3]; - - if (!usernameField && !passwordField) { - console.log('cannot locate fields in form #' + i); - continue; - } - - var maxUsernameLength = Number.MAX_VALUE, - maxPasswordLength = Number.MAX_VALUE; - - var filledUsername = false, - filledPassword = false; - - if (username && usernameField) { - if (usernameField.maxLength >= 0) { - maxUsernameLength = usernameField.maxLength; - } - if (username.length <= maxUsernameLength) { - usernameField.value = username; - filledUsername = true; - } - } - - if (passwordField) { - if (passwordField.maxLength >= 0) { - maxPasswordLength = passwordField.maxLength; - } - if (password.length <= maxPasswordLength) { - passwordField.value = password; - filledPassword = true; - } - } - - if (autoSubmit && filledPassword && filledPassword) { - setTimeout(function () { - if (submitButton) { - submitButton.click(); - } - else { - document.forms[i].submit(); - } - }, 500); - - break; - } - } + fill: function(document, fillScript, undefined) { + var f=!0,h=!0; + function l(a){var b=null;return a?0===a.indexOf('https://')&&'http:'===document.location.protocol&&(b=document.querySelectorAll('input[type=password]'),0 - + + Designer + @@ -110,6 +112,10 @@ ..\..\packages\Unity.3.5.1405-prerelease\lib\portable-net45+wp80+win8+wpa81+MonoAndroid10+MonoTouch10\Microsoft.Practices.Unity.dll True + + ..\..\packages\Newtonsoft.Json.8.0.3\lib\portable-net40+sl5+wp80+win8+wpa81\Newtonsoft.Json.dll + True + ..\..\packages\sqlite-net-pcl.1.1.1\lib\portable-net45+wp8+wpa81+win8+MonoAndroid10+MonoTouch10+Xamarin.iOS10\SQLite-net.dll True diff --git a/src/iOS.Extension/packages.config b/src/iOS.Extension/packages.config index 17f26c40e..9a37a8117 100644 --- a/src/iOS.Extension/packages.config +++ b/src/iOS.Extension/packages.config @@ -1,6 +1,7 @@  +