From 24daeb1035d699137b5ea68fd2184981bce76a31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20Jim=C3=A9nez?= Date: Sat, 7 Jun 2014 19:30:19 +0200 Subject: [PATCH] Bug 988469 - MSISDN verification API for privileged apps. Part 4: Mobile ID service. r=markh, jedp --- b2g/app/b2g.js | 2 + b2g/chrome/content/shell.js | 1 + b2g/installer/package-manifest.in | 3 +- services/mobileid/MobileIdentityClient.jsm | 158 ++++ services/mobileid/MobileIdentityCommon.jsm | 143 +++ .../MobileIdentityCredentialsStore.jsm | 173 ++++ services/mobileid/MobileIdentityManager.jsm | 824 ++++++++++++++++++ .../MobileIdentitySmsMoMtVerificationFlow.jsm | 89 ++ .../MobileIdentitySmsMtVerificationFlow.jsm | 49 ++ .../MobileIdentitySmsVerificationFlow.jsm | 120 +++ .../mobileid/MobileIdentityUIGlueCommon.jsm | 31 + .../MobileIdentityVerificationFlow.jsm | 213 +++++ services/mobileid/interfaces/moz.build | 11 + .../interfaces/nsIMobileIdentityUIGlue.idl | 77 ++ services/mobileid/moz.build | 22 + services/moz.build | 5 +- 16 files changed, 1919 insertions(+), 2 deletions(-) create mode 100644 services/mobileid/MobileIdentityClient.jsm create mode 100644 services/mobileid/MobileIdentityCommon.jsm create mode 100644 services/mobileid/MobileIdentityCredentialsStore.jsm create mode 100644 services/mobileid/MobileIdentityManager.jsm create mode 100644 services/mobileid/MobileIdentitySmsMoMtVerificationFlow.jsm create mode 100644 services/mobileid/MobileIdentitySmsMtVerificationFlow.jsm create mode 100644 services/mobileid/MobileIdentitySmsVerificationFlow.jsm create mode 100644 services/mobileid/MobileIdentityUIGlueCommon.jsm create mode 100644 services/mobileid/MobileIdentityVerificationFlow.jsm create mode 100644 services/mobileid/interfaces/moz.build create mode 100644 services/mobileid/interfaces/nsIMobileIdentityUIGlue.idl create mode 100644 services/mobileid/moz.build diff --git a/b2g/app/b2g.js b/b2g/app/b2g.js index f281efd303e..eacb8e75e53 100644 --- a/b2g/app/b2g.js +++ b/b2g/app/b2g.js @@ -988,5 +988,7 @@ pref("services.sync.fxaccounts.enabled", true); pref("identity.fxaccounts.enabled", true); #endif +pref("services.mobileid.server.uri", "http://msisdn.dev.mozaws.net"); + // Enable mapped array buffer pref("dom.mapped_arraybuffer.enabled", true); diff --git a/b2g/chrome/content/shell.js b/b2g/chrome/content/shell.js index c36c130f723..344aba37a8f 100644 --- a/b2g/chrome/content/shell.js +++ b/b2g/chrome/content/shell.js @@ -30,6 +30,7 @@ Cu.import('resource://gre/modules/FxAccountsMgmtService.jsm'); #endif Cu.import('resource://gre/modules/DownloadsAPI.jsm'); +Cu.import('resource://gre/modules/MobileIdentityManager.jsm'); XPCOMUtils.defineLazyModuleGetter(this, "SystemAppProxy", "resource://gre/modules/SystemAppProxy.jsm"); diff --git a/b2g/installer/package-manifest.in b/b2g/installer/package-manifest.in index e5b71e21807..02d5839540e 100644 --- a/b2g/installer/package-manifest.in +++ b/b2g/installer/package-manifest.in @@ -831,8 +831,9 @@ bin/components/@DLL_PREFIX@nkgnomevfs@DLL_SUFFIX@ @BINPATH@/components/MobileIdentity.manifest @BINPATH@/components/MobileIdentity.js -@BINPATH@/components/MobileIdentityUIGlue.js @BINPATH@/components/dom_mobileidentity.xpt +@BINPATH@/components/MobileIdentityUIGlue.js +@BINPATH@/components/services_mobileidentity.xpt #ifdef MOZ_WEBSPEECH @BINPATH@/components/dom_webspeechsynth.xpt diff --git a/services/mobileid/MobileIdentityClient.jsm b/services/mobileid/MobileIdentityClient.jsm new file mode 100644 index 00000000000..af1f95e00c9 --- /dev/null +++ b/services/mobileid/MobileIdentityClient.jsm @@ -0,0 +1,158 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// REST client for +// https://github.com/mozilla-services/msisdn-gateway/blob/master/API.md + +"use strict"; + +this.EXPORTED_SYMBOLS = ["MobileIdentityClient"]; + +const {classes: Cc, interfaces: Ci, utils: Cu} = Components; + +Cu.import("resource://services-common/hawkclient.js"); +Cu.import("resource://services-common/utils.js"); +Cu.import("resource://services-crypto/utils.js"); +Cu.import("resource://gre/modules/MobileIdentityCommon.jsm"); +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +this.MobileIdentityClient = function(aServerUrl) { + let serverUrl = aServerUrl || SERVER_URL; + let forceHttps = false; + try { + // TODO: Force https in production. Bug 1021595. + forceHttps = Services.prefs.getBoolPref(PREF_FORCE_HTTPS); + } catch(e) { + log.warn("Getting force HTTPS pref failed. If this was not intentional " + + "check that " + PREF_FORCE_HTTPS + " is defined"); + } + + log.debug("Force HTTPS " + forceHttps); + + if (forceHttps && !/^https/.exec(serverUrl.toLowerCase())) { + throw new Error(ERROR_INTERNAL_HTTP_NOT_ALLOWED); + } + + this.hawk = new HawkClient(SERVER_URL); + this.hawk.observerPrefix = "MobileId:hawk"; +}; + +this.MobileIdentityClient.prototype = { + + discover: function(aMsisdn, aMcc, aMnc, aRoaming) { + return this._request(DISCOVER, "POST", null, { + msisdn: aMsisdn || undefined, + mcc: aMcc, + mnc: aMnc, + roaming: aRoaming + }); + }, + + register: function() { + return this._request(REGISTER, "POST", null, {}); + }, + + smsMtVerify: function(aSessionToken, aMsisdn, aWantShortCode = false) { + let credentials = this._deriveHawkCredentials(aSessionToken); + return this._request(SMS_MT_VERIFY, "POST", credentials, { + msisdn: aMsisdn, + shortVerificationCode: aWantShortCode + }); + }, + + verifyCode: function(aSessionToken, aVerificationCode) { + log.debug("verificationCode " + aVerificationCode); + let credentials = this._deriveHawkCredentials(aSessionToken); + return this._request(SMS_VERIFY_CODE, "POST", credentials, { + code: aVerificationCode + }); + }, + + sign: function(aSessionToken, aDuration, aPublicKey) { + let credentials = this._deriveHawkCredentials(aSessionToken); + return this._request(SIGN, "POST", credentials, { + duration: aDuration, + publicKey: aPublicKey + }); + }, + + unregister: function(aSessionToken) { + let credentials = this._deriveHawkCredentials(aSessionToken); + return this._request(UNREGISTER, "POST", credentials, {}); + }, + + /** + * The MobileID server expects requests to certain endpoints to be + * authorized using Hawk. + * + * Hawk credentials are derived using shared secrets. + * + * @param tokenHex + * The current session token encoded in hex + * @param context + * A context for the credentials + * @param size + * The size in bytes of the expected derived buffer + * @return credentials + * Returns an object: + * { + * algorithm: sha256 + * id: the Hawk id (from the first 32 bytes derived) + * key: the Hawk key (from bytes 32 to 64) + * } + */ + _deriveHawkCredentials: function(aSessionToken) { + let token = CommonUtils.hexToBytes(aSessionToken); + let out = CryptoUtils.hkdf(token, undefined, + CREDENTIALS_DERIVATION_INFO, + CREDENTIALS_DERIVATION_SIZE); + return { + algorithm: "sha256", + key: CommonUtils.bytesAsHex(out.slice(32, 64)), + id: CommonUtils.bytesAsHex(out.slice(0, 32)) + }; + }, + + /** + * A general method for sending raw API calls to the mobile id verification + * server. + * All request bodies and responses are JSON. + * + * @param path + * API endpoint path + * @param method + * The HTTP request method + * @param credentials + * Hawk credentials + * @param jsonPayload + * A JSON payload + * @return Promise + * Returns a promise that resolves to the JSON response of the API + * call, or is rejected with an error. + */ + _request: function(path, method, credentials, jsonPayload) { + let deferred = Promise.defer(); + + this.hawk.request(path, method, credentials, jsonPayload).then( + (responseText) => { + log.debug("MobileIdentityClient -> responseText " + responseText); + try { + let response = JSON.parse(responseText); + deferred.resolve(response); + } catch (err) { + deferred.reject({error: err}); + } + }, + + (error) => { + log.error("MobileIdentityClient -> Error ${}", error); + deferred.reject(SERVER_ERRNO_TO_ERROR[error.errno] || ERROR_UNKNOWN); + } + ); + + return deferred.promise; + }, + +}; diff --git a/services/mobileid/MobileIdentityCommon.jsm b/services/mobileid/MobileIdentityCommon.jsm new file mode 100644 index 00000000000..ed2b04ed109 --- /dev/null +++ b/services/mobileid/MobileIdentityCommon.jsm @@ -0,0 +1,143 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { interfaces: Ci, utils: Cu } = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Log.jsm"); + +// loglevel should be one of "Fatal", "Error", "Warn", "Info", "Config", +// "Debug", "Trace" or "All". If none is specified, "Error" will be used by +// default. +const PREF_LOG_LEVEL = "services.mobileid.loglevel"; + +XPCOMUtils.defineLazyGetter(this, "log", function() { + let log = Log.repository.getLogger("MobileId"); + log.addAppender(new Log.DumpAppender()); + log.level = Log.Level.Error; + try { + let level = + Services.prefs.getPrefType(PREF_LOG_LEVEL) == Ci.nsIPrefBranch.PREF_STRING + && Services.prefs.getCharPref(PREF_LOG_LEVEL); + log.level = Log.Level[level] || Log.Level.Error; + } catch (e) { + log.error(e); + } + + return log; +}); + +this.PREF_FORCE_HTTPS = "services.mobileid.forcehttps"; + +// Permission. +this.MOBILEID_PERM = "mobileid"; + +// IPC messages. +this.GET_ASSERTION_IPC_MSG = "MobileId:GetAssertion"; + +// Verification methods. +this.SMS_MT = "sms/mt"; +this.SMS_MO_MT = "sms/momt"; + +// Server endpoints. +this.DISCOVER = "/discover"; +this.REGISTER = "/register"; +this.SMS_MT_VERIFY = "/" + this.SMS_MT + "/verify"; +this.SMS_MO_MT_VERIFY = "/" + this.SMS_MO_MT + "/verify"; +this.SMS_VERIFY_CODE = "/sms/verify_code"; +this.SIGN = "/certificate/sign"; +this.UNREGISTER = "/unregister"; + +// Server consts. +this.SERVER_URL = Services.prefs.getCharPref("services.mobileid.server.uri"); +this.CREDENTIALS_DERIVATION_INFO = "identity.mozilla.com/picl/v1/sessionToken"; +this.CREDENTIALS_DERIVATION_SIZE = 2 * 32; + +this.SILENT_SMS_RECEIVED_TOPIC = "silent-sms-received"; + +this.ASSERTION_LIFETIME = 1000 * 60 * 5; // 5 minutes. +this.CERTIFICATE_LIFETIME = 1000 * 3600 * 6; // 6 hours. +this.KEY_LIFETIME = 1000 * 3600 * 12; // 12 hours. + +this.VERIFICATIONCODE_TIMEOUT = 60000; +this.VERIFICATIONCODE_RETRIES = 3; + +// Internal Errors. +this.ERROR_INTERNAL_CANNOT_CREATE_VERIFICATION_FLOW = "INTERNAL_CANNOT_CREATE_VERIFICATION_FLOW"; +this.ERROR_INTERNAL_CANNOT_GENERATE_ASSERTION = "INTERNAL_CANNOT_GENERATE_ASSERTION"; +this.ERROR_INTERNAL_CANNOT_VERIFY_SELECTION = "INTERNAL_CANNOT_VERIFY_SELECTION"; +this.ERROR_INTERNAL_DB_ERROR = "INTERNAL_DB_ERROR"; +this.ERROR_INTERNAL_HTTP_NOT_ALLOWED = "INTERNAL_HTTP_NOT_ALLOWED"; +this.ERROR_INTERNAL_INVALID_CERTIFICATE = "INTERNAL_INVALID_CERTIFICATE"; +this.ERROR_INTERNAL_INVALID_PROMPT_RESULT = "INTERNAL_INVALID_PROMPT_RESULT"; +this.ERROR_INTERNAL_INVALID_USER_SELECTION = "INTERNAL_INVALID_USER_SELECTION"; +this.ERROR_INTERNAL_INVALID_VERIFICATION_FLOW = "INTERNAL_INVALID_VERIFICATION_FLOW"; +this.ERROR_INTERNAL_INVALID_VERIFICATION_RESULT = "INTERNAL_INVALID_VERIFICATION_RESULT"; +this.ERROR_INTERNAL_UNEXPECTED = "INTERNAL_UNEXPECTED"; + +// Errors. +this.ERROR_ENDPOINT_NOT_SUPPORTED = "ENDPOINT_NOT_SUPPORTED"; +this.ERROR_INVALID_ASSERTION = "INVALID_ASSERTION"; +this.ERROR_INVALID_AUTH_TOKEN = "INVALID_AUTH_TOKEN"; +this.ERROR_INVALID_BODY_JSON = "INVALID_BODY_JSON"; +this.ERROR_INVALID_BODY_MISSING_PARAMS = "INVALID_BODY_MISSING_PARAMS"; +this.ERROR_INVALID_BODY_PARAMS = "INVALID_BODY_PARAMS"; +this.ERROR_INVALID_PHONE_NUMBER = "INVALID_PHONE_NUMBER"; +this.ERROR_INVALID_PROMPT_RESULT = "INVALID_PROMPT_RESULT"; +this.ERROR_INVALID_REQUEST_SIGNATURE = "INVALID_REQUEST_SIGNATURE"; +this.ERROR_INVALID_VERIFICATION_CODE = "INVALID_VERIFICATION_CODE"; +this.ERROR_MISSING_CONTENT_LENGTH_HEADER = "MISSING_CONTENT_LENGTH_HEADER"; +this.ERROR_NO_RETRIES_LEFT = "NO_RETRIES_LEFT"; +this.ERROR_OFFLINE = "OFFLINE"; +this.ERROR_REQUEST_BODY_TOO_LARGE = "REQUEST_BODY_TOO_LARGE"; +this.ERROR_SERVICE_TEMPORARILY_UNAVAILABLE = "SERVICE_TEMPORARILY_UNAVAILABLE"; +this.ERROR_TOO_MANY_REQUESTS_MSISDN = "TOO_MANY_REQUESTS_MSISDN"; +this.ERROR_TOO_MANY_REQUESTS_UNSPECIFIED = "TOO_MANY_REQUESTS_UNSPECIFIED"; +this.ERROR_TOO_MANY_REQUESTS_VERIFICAITON_CODE = "TOO_MANY_REQUESTS_VERIFICATION_CODE"; +this.ERROR_TOO_MANY_REQUESTS_VERIFICATION_METHOD = "TOO_MANY_REQUESTS_VERIFICATION_METHOD"; +this.ERROR_UNKNOWN = "UNKNOWN"; +this.ERROR_UNVERIFIED_ACCOUNT = "UNVERIFIED_ACCOUNT"; +this.ERROR_VERIFICATION_CODE_TIMEOUT = "VERIFICATION_CODE_TIMEOUT"; + +// Server errno. +// From https://github.com/mozilla-services/msisdn-gateway/blob/master/API.md#response-format +this.ERRNO_UNVERIFIED_ACCOUNT = 104; +this.ERRNO_INVALID_VERIFICATION_CODE = 105; +this.ERRNO_INVALID_BODY_JSON = 106; +this.ERRNO_INVALID_BODY_INVALID_PARAMS = 107; +this.ERRNO_INVALID_BODY_MISSING_PARAMS = 108; +this.ERRNO_INVALID_REQUEST_SIGNATURE = 109; +this.ERRNO_INVALID_AUTH_TOKEN = 110; +this.ERRNO_ENDPOINT_NOT_SUPPORTED = 111; +this.ERRNO_MISSING_CONTENT_LENGTH_HEADER = 112; +this.ERRNO_REQUEST_BODY_TOO_LARGE = 113; +this.ERRNO_TOO_MANY_REQUESTS_VERIFICATION_CODE = 114; +this.ERRNO_TOO_MANY_REQUESTS_MSISDN = 115; +this.ERRNO_TOO_MANY_REQUESTS_VERIFICATION_METHOD = 116; +this.ERRNO_TOO_MANY_REQUESTS_UNSPECIFIED = 117; +this.ERRNO_SERVICE_TEMPORARILY_UNAVAILABLE = 201; +this.ERRNO_UNKNOWN_ERROR = 999; + +// Error matching. +this.SERVER_ERRNO_TO_ERROR = {}; +SERVER_ERRNO_TO_ERROR[ERRNO_UNVERIFIED_ACCOUNT] = ERROR_UNVERIFIED_ACCOUNT; +SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_VERIFICATION_CODE] = ERROR_INVALID_VERIFICATION_CODE; +SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_BODY_JSON] = ERROR_INVALID_BODY_JSON; +SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_BODY_INVALID_PARAMS] = ERROR_INVALID_BODY_PARAMS; +SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_BODY_MISSING_PARAMS] = ERROR_INVALID_BODY_MISSING_PARAMS; +SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_REQUEST_SIGNATURE] = ERROR_INVALID_REQUEST_SIGNATURE; +SERVER_ERRNO_TO_ERROR[ERRNO_INVALID_AUTH_TOKEN] = ERROR_INVALID_AUTH_TOKEN; +SERVER_ERRNO_TO_ERROR[ERRNO_ENDPOINT_NOT_SUPPORTED] = ERROR_ENDPOINT_NOT_SUPPORTED; +SERVER_ERRNO_TO_ERROR[ERRNO_MISSING_CONTENT_LENGTH_HEADER] = ERROR_MISSING_CONTENT_LENGTH_HEADER; +SERVER_ERRNO_TO_ERROR[ERRNO_REQUEST_BODY_TOO_LARGE] = ERROR_REQUEST_BODY_TOO_LARGE; +SERVER_ERRNO_TO_ERROR[ERRNO_TOO_MANY_REQUESTS_VERIFICATION_CODE] = ERROR_TOO_MANY_REQUESTS_VERIFICAITON_CODE; +SERVER_ERRNO_TO_ERROR[ERRNO_TOO_MANY_REQUESTS_MSISDN] = ERROR_TOO_MANY_REQUESTS_MSISDN;; +SERVER_ERRNO_TO_ERROR[ERRNO_TOO_MANY_REQUESTS_VERIFICATION_METHOD] = ERROR_TOO_MANY_REQUESTS_VERIFICATION_METHOD;; +SERVER_ERRNO_TO_ERROR[ERRNO_TOO_MANY_REQUESTS_UNSPECIFIED] = ERROR_TOO_MANY_REQUESTS_UNSPECIFIED;; +SERVER_ERRNO_TO_ERROR[ERRNO_SERVICE_TEMPORARILY_UNAVAILABLE] = ERROR_SERVICE_TEMPORARILY_UNAVAILABLE; +SERVER_ERRNO_TO_ERROR[ERRNO_UNKNOWN_ERROR] = ERROR_UNKNOWN; + +// Allow this file to be imported via Components.utils.import(). +this.EXPORTED_SYMBOLS = Object.keys(this); diff --git a/services/mobileid/MobileIdentityCredentialsStore.jsm b/services/mobileid/MobileIdentityCredentialsStore.jsm new file mode 100644 index 00000000000..1424a9964da --- /dev/null +++ b/services/mobileid/MobileIdentityCredentialsStore.jsm @@ -0,0 +1,173 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["MobileIdentityCredentialsStore"]; + +const {classes: Cc, interfaces: Ci, utils: Cu} = Components; + +Cu.import("resource://gre/modules/IndexedDBHelper.jsm"); +Cu.import("resource://gre/modules/MobileIdentityCommon.jsm"); +Cu.import("resource://gre/modules/Promise.jsm"); + +const CREDENTIALS_DB_NAME = "mobile-id-credentials"; +const CREDENTIALS_DB_VERSION = 1; +const CREDENTIALS_STORE_NAME = "credentials-store"; + +this.MobileIdentityCredentialsStore = function() { +}; + +this.MobileIdentityCredentialsStore.prototype = { + + __proto__: IndexedDBHelper.prototype, + + init: function() { + log.debug("MobileIdentityCredentialsStore init"); + this.initDBHelper(CREDENTIALS_DB_NAME, + CREDENTIALS_DB_VERSION, + [CREDENTIALS_STORE_NAME]); + }, + + upgradeSchema: function(aTransaction, aDb, aOldVersion, aNewVersion) { + log.debug("upgradeSchema"); + /** + * We will be storing objects like: + * { + * msisdn: (key), + * iccId: (index), + * origin: (index), + * msisdnSessionToken: + * } + */ + let objectStore = aDb.createObjectStore(CREDENTIALS_STORE_NAME, { + keyPath: "msisdn" + }); + + objectStore.createIndex("iccId", "iccId", { unique: true }); + objectStore.createIndex("origin", "origin", { unique: true, multiEntry: true }); + }, + + add: function(aIccId, aMsisdn, aOrigin, aSessionToken) { + log.debug("put " + aIccId + ", " + aMsisdn + ", " + aOrigin + ", " + + aSessionToken); + if (!aOrigin || !aSessionToken) { + return Promise.reject(ERROR_INTERNAL_DB_ERROR); + } + + let deferred = Promise.defer(); + + // We first try get an existing record for the given MSISDN. + this.newTxn( + "readwrite", + CREDENTIALS_STORE_NAME, + (aTxn, aStore) => { + let range = IDBKeyRange.only(aMsisdn); + let cursorReq = aStore.openCursor(range); + cursorReq.onsuccess = function(aEvent) { + let cursor = aEvent.target.result; + let record; + // If we already have a record of this MSISDN, we add the origin to + // the list of allowed origins. + if (cursor && cursor.value) { + record = cursor.value; + if (record.origin.indexOf(aOrigin) == -1) { + record.origin.push(aOrigin); + } + cursor.update(record); + } else { + // Otherwise, we store a new record. + record = { + iccId: aIccId, + msisdn: aMsisdn, + origin: [aOrigin], + sessionToken: aSessionToken + }; + aStore.add(record); + } + deferred.resolve(); + }; + cursorReq.onerror = function(aEvent) { + log.error(aEvent.target.error); + deferred.reject(ERROR_INTERNAL_DB_ERROR); + }; + }, null, deferred.reject); + + return deferred.promise; + }, + + getByMsisdn: function(aMsisdn) { + log.debug("getByMsisdn " + aMsisdn); + if (!aMsisdn) { + return Promise.resolve(null); + } + + let deferred = Promise.defer(); + this.newTxn( + "readonly", + CREDENTIALS_STORE_NAME, + (aTxn, aStore) => { + aStore.get(aMsisdn).onsuccess = function(aEvent) { + aTxn.result = aEvent.target.result; + }; + }, + function(result) { + deferred.resolve(result); + }, + deferred.reject + ); + return deferred.promise; + }, + + getByIndex: function(aIndex, aValue) { + log.debug("getByIndex " + aIndex + ", " + aValue); + if (!aValue || !aIndex) { + return Promise.resolve(null); + } + + let deferred = Promise.defer(); + this.newTxn( + "readonly", + CREDENTIALS_STORE_NAME, + (aTxn, aStore) => { + let index = aStore.index(aIndex); + index.get(aValue).onsuccess = function(aEvent) { + aTxn.result = aEvent.target.result; + }; + }, + function(result) { + deferred.resolve(result); + }, + deferred.reject + ); + return deferred.promise; + }, + + getByOrigin: function(aOrigin) { + return this.getByIndex("origin", aOrigin); + }, + + getByIccId: function(aIccId) { + return this.getByIndex("iccId", aIccId); + }, + + delete: function(aMsisdn) { + log.debug("delete " + aMsisdn); + if (!aMsisdn) { + return Promise.resolve(); + } + + let deferred = Promise.defer(); + this.newTxn( + "readwrite", + CREDENTIALS_STORE_NAME, + (aTxn, aStore) => { + aStore.delete(aMsisdn); + }, + deferred.resolve, + deferred.reject + ); + return deferred.promise; + } +}; diff --git a/services/mobileid/MobileIdentityManager.jsm b/services/mobileid/MobileIdentityManager.jsm new file mode 100644 index 00000000000..66637307fe7 --- /dev/null +++ b/services/mobileid/MobileIdentityManager.jsm @@ -0,0 +1,824 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = []; + +const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + +Cu.import("resource://gre/modules/MobileIdentityCommon.jsm"); +Cu.import("resource://gre/modules/MobileIdentityUIGlueCommon.jsm"); +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "MobileIdentityCredentialsStore", + "resource://gre/modules/MobileIdentityCredentialsStore.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "MobileIdentityClient", + "resource://gre/modules/MobileIdentityClient.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "MobileIdentitySmsMtVerificationFlow", + "resource://gre/modules/MobileIdentitySmsMtVerificationFlow.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "MobileIdentitySmsMoMtVerificationFlow", + "resource://gre/modules/MobileIdentitySmsMoMtVerificationFlow.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "PhoneNumberUtils", + "resource://gre/modules/PhoneNumberUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "jwcrypto", + "resource://gre/modules/identity/jwcrypto.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "uuidgen", + "@mozilla.org/uuid-generator;1", + "nsIUUIDGenerator"); + +XPCOMUtils.defineLazyServiceGetter(this, "ppmm", + "@mozilla.org/parentprocessmessagemanager;1", + "nsIMessageListenerManager"); + +XPCOMUtils.defineLazyServiceGetter(this, "permissionManager", + "@mozilla.org/permissionmanager;1", + "nsIPermissionManager"); + +XPCOMUtils.defineLazyServiceGetter(this, "securityManager", + "@mozilla.org/scriptsecuritymanager;1", + "nsIScriptSecurityManager"); + +XPCOMUtils.defineLazyServiceGetter(this, "appsService", + "@mozilla.org/AppsService;1", + "nsIAppsService"); + +#ifdef MOZ_B2G_RIL +XPCOMUtils.defineLazyServiceGetter(this, "gRil", + "@mozilla.org/ril;1", + "nsIRadioInterfaceLayer"); + +XPCOMUtils.defineLazyServiceGetter(this, "iccProvider", + "@mozilla.org/ril/content-helper;1", + "nsIIccProvider"); +#endif + + +let MobileIdentityManager = { + + init: function() { + log.debug("MobileIdentityManager init"); + Services.obs.addObserver(this, "xpcom-shutdown", false); + ppmm.addMessageListener(GET_ASSERTION_IPC_MSG, this); + this.messageManagers = {}; + // TODO: Store keyPairs and certificates in disk. Bug 1021605. + this.keyPairs = {}; + this.certificates = {}; + }, + + receiveMessage: function(aMessage) { + log.debug("Received " + aMessage.name); + + if (aMessage.name !== GET_ASSERTION_IPC_MSG) { + return; + } + + let msg = aMessage.json; + + // We save the message target message manager so we can later dispatch + // back messages without broadcasting to all child processes. + let promiseId = msg.promiseId; + this.messageManagers[promiseId] = aMessage.target; + + this.getMobileIdAssertion(aMessage.principal, promiseId, + msg.msisdn, msg.prompt); + }, + + observe: function(subject, topic, data) { + if (topic != "xpcom-shutdown") { + return; + } + + ppmm.removeMessageListener(GET_ASSERTION_IPC_MSG, this); + Services.obs.removeObserver(this, "xpcom-shutdown"); + this.messageManagers = null; + }, + + + /********************************************************* + * Getters + ********************************************************/ + + get iccInfo() { +#ifdef MOZ_B2G_RIL + if (this._iccInfo) { + return this._iccInfo; + } + + this._iccInfo = []; + for (let i = 0; i < gRil.numRadioInterfaces; i++) { + let rilContext = gRil.getRadioInterface(i).rilContext; + if (!rilContext) { + log.warn("Tried to get the RIL context for an invalid service ID " + i); + continue; + } + let info = rilContext.iccInfo; + if (!info) { + log.warn("No ICC info"); + continue; + } + + let operator = null; + if (rilContext.voice.network && + rilContext.voice.network.shortName && + rilContext.voice.network.shortName.length) { + operator = rilContext.voice.network.shortName; + } else if (rilContext.data.network && + rilContext.data.network.shortName && + rilContext.data.network.shortName.length) { + operator = rilContext.data.network.shortName; + } + + this._iccInfo.push({ + iccId: info.iccid, + mcc: info.mcc, + mnc: info.mnc, + // GSM SIMs may have MSISDN while CDMA SIMs may have MDN + msisdn: info.msisdn || info.mdn || null, + operator: operator, + serviceId: i, + roaming: rilContext.voice.roaming + }); + } + + return this._iccInfo; +#endif + return null; + }, + + get credStore() { + if (!this._credStore) { + this._credStore = new MobileIdentityCredentialsStore(); + this._credStore.init(); + } + return this._credStore; + }, + + get ui() { + if (!this._ui) { + this._ui = Cc["@mozilla.org/services/mobileid-ui-glue;1"] + .createInstance(Ci.nsIMobileIdentityUIGlue); + this._ui.oncancel = this.onUICancel.bind(this); + this._ui.onresendcode = this.onUIResendCode.bind(this); + } + return this._ui; + }, + + get client() { + if (!this._client) { + this._client = new MobileIdentityClient(); + } + return this._client; + }, + + get isMultiSim() { + return this.iccInfo && this.iccInfo.length > 1; + }, + + getVerificationOptionsForIcc: function(aServiceId) { + log.debug("getVerificationOptionsForIcc " + aServiceId); + log.debug("iccInfo ${}", this.iccInfo[aServiceId]); + // First of all we need to check if we already have existing credentials + // for the given SIM information (ICC id or MSISDN). If we have no valid + // credentials, we have to check with the server which options to do we + // have to verify the associated phone number. + return this.credStore.getByIccId(this.iccInfo[aServiceId].iccId) + .then( + (creds) => { + if (creds) { + this.iccInfo[aServiceId].credentials = creds; + return; + } + return this.credStore.getByMsisdn(this.iccInfo[aServiceId].msisdn); + } + ) + .then( + (creds) => { + if (creds) { + this.iccInfo[aServiceId].credentials = creds; + return; + } + // We have no credentials for this SIM, so we need to ask the server + // which options do we have to verify the phone number. + // But we need to be online... + if (Services.io.offline) { + return Promise.reject(ERROR_OFFLINE); + } + return this.client.discover(this.iccInfo[aServiceId].msisdn, + this.iccInfo[aServiceId].mcc, + this.iccInfo[aServiceId].mnc, + this.iccInfo[aServiceId].roaming); + } + ) + .then( + (result) => { + log.debug("Discover result ${}", result); + if (!result || !result.verificationMethods) { + return; + } + this.iccInfo[aServiceId].verificationMethods = result.verificationMethods; + this.iccInfo[aServiceId].verificationDetails = result.verificationDetails; + this.iccInfo[aServiceId].canDoSilentVerification = + (result.verificationMethods.indexOf(SMS_MO_MT) != -1); + return; + } + ); + }, + + getVerificationOptions: function() { + log.debug("getVerificationOptions"); + // We try to get if we already have credentials for any of the inserted + // SIM cards if any is available and we try to get the possible + // verification mechanisms for these SIM cards. + // All this information will be stored in iccInfo. + if (!this.iccInfo || !this.iccInfo.length) { + return Promise.resolve(); + } + + let promises = []; + for (let i = 0; i < this.iccInfo.length; i++) { + promises.push(this.getVerificationOptionsForIcc(i)); + } + return Promise.all(promises); + }, + + getKeyPair: function(aSessionToken) { + if (this.keyPairs[aSessionToken] && + this.keyPairs[aSessionToken].validUntil > this.client.hawk.now()) { + return Promise.resolve(this.keyPairs[aSessionToken].keyPair); + } + + let validUntil = this.client.hawk.now() + KEY_LIFETIME; + let deferred = Promise.defer(); + jwcrypto.generateKeyPair("DS160", (error, kp) => { + if (error) { + return deferred.reject(error); + } + this.keyPairs[aSessionToken] = { + keyPair: kp, + validUntil: validUntil + }; + delete this.certificates[aSessionToken]; + deferred.resolve(kp); + }); + + return deferred.promise; + }, + + getCertificate: function(aSessionToken, aPublicKey) { + if (this.certificates[aSessionToken] && + this.certificates[aSessionToken].validUntil > this.client.hawk.now()) { + return Promise.resolve(this.certificates[aSessionToken].cert); + } + + if (Services.io.offline) { + return Promise.reject(ERROR_OFFLINE); + } + + let validUntil = this.client.hawk.now() + KEY_LIFETIME; + let deferred = Promise.defer(); + this.client.sign(aSessionToken, CERTIFICATE_LIFETIME, + aPublicKey) + .then( + (signedCert) => { + this.certificates[aSessionToken] = { + cert: signedCert.cert, + validUntil: validUntil + }; + deferred.resolve(signedCert.cert); + }, + deferred.reject + ); + return deferred.promise; + }, + + /********************************************************* + * UI callbacks + ********************************************************/ + + onUICancel: function() { + log.debug("UI cancel"); + if (this.activeVerificationFlow) { + this.activeVerificationFlow.cleanup(true); + } + }, + + onUIResendCode: function() { + log.debug("UI resend code"); + if (!this.activeVerificationFlow) { + return; + } + this.doVerification(); + }, + + /********************************************************* + * Permissions helpers + ********************************************************/ + + hasPermission: function(aPrincipal) { + let permission = permissionManager.testPermissionFromPrincipal(aPrincipal, + MOBILEID_PERM); + return permission == Ci.nsIPermissionManager.ALLOW_ACTION; + }, + + addPermission: function(aPrincipal) { + permissionManager.addFromPrincipal(aPrincipal, MOBILEID_PERM, + Ci.nsIPermissionManager.ALLOW_ACTION); + }, + + /********************************************************* + * Phone number verification + ********************************************************/ + + rejectVerification: function(aReason) { + if (!this.activeVerificationDeferred) { + return; + } + this.activeVerificationDeferred.reject(aReason); + this.activeVerificationDeferred = null; + this.cleanupVerification(true); + }, + + resolveVerification: function(aResult) { + if (!this.activeVerificationDeferred) { + return; + } + this.activeVerificationDeferred.resolve(aResult); + this.activeVerificationDeferred = null; + this.cleanupVerification(); + }, + + cleanupVerification: function() { + if (!this.activeVerificationFlow) { + return; + } + this.activeVerificationFlow.cleanup(); + this.activeVerificationFlow = null; + }, + + doVerification: function() { + this.activeVerificationFlow.doVerification() + .then( + (verificationResult) => { + log.debug("onVerificationResult "); + if (!verificationResult || !verificationResult.sessionToken || + !verificationResult.msisdn) { + return this.rejectVerification( + ERROR_INTERNAL_INVALID_VERIFICATION_RESULT + ); + } + this.resolveVerification(verificationResult); + } + ) + .then( + null, + reason => { + // Verification timeout. + log.warn("doVerification " + reason); + } + ); + }, + + _verificationFlow: function(aToVerify, aOrigin) { + log.debug("toVerify ${}", aToVerify); + + // We create the corresponding verification flow and save its instance + // in case that we need to cancel it or retrigger it because the user + // requested its cancelation or a resend of the verification code. + if (aToVerify.verificationMethod.indexOf(SMS_MT) != -1 && + aToVerify.msisdn && + aToVerify.verificationDetails && + aToVerify.verificationDetails.mtSender) { + this.activeVerificationFlow = new MobileIdentitySmsMtVerificationFlow( + aOrigin, + aToVerify.msisdn, + aToVerify.iccId, + aToVerify.serviceId === undefined, // external: the phone number does + // not seem to belong to any of the + // device SIM cards. + aToVerify.verificationDetails.mtSender, + this.ui, + this.client + ); +#ifdef MOZ_B2G_RIL + } else if (aToVerify.verificationMethod.indexOf(SMS_MO_MT) != -1 && + aToVerify.serviceId && + aToVerify.verificationDetails && + aToVerify.verificationDetails.moVerifier && + aToVerify.verificationDetails.mtSender) { + + this.activeVerificationFlow = new MobileIdentitySmsMoMtVerificationFlow( + aOrigin, + aToVerify.serviceId, + aToVerify.iccId, + aToVerify.verificationDetails.mtSender, + aToVerify.verificationDetails.moVerifier, + this.ui, + this.client + ); +#endif + } else { + return Promise.reject(ERROR_INTERNAL_CANNOT_VERIFY_SELECTION); + } + + if (!this.activeVerificationFlow) { + return Promise.reject(ERROR_INTERNAL_CANNOT_CREATE_VERIFICATION_FLOW); + } + + this.activeVerificationDeferred = Promise.defer(); + this.doVerification(); + return this.activeVerificationDeferred.promise; + }, + + verificationFlow: function(aUserSelection, aOrigin) { + log.debug("verificationFlow ${}", aUserSelection); + + if (!aUserSelection) { + return Promise.reject(ERROR_INTERNAL_INVALID_USER_SELECTION); + } + + let serviceId = aUserSelection.serviceId || undefined; + // We check if the user entered phone number corresponds with any of the + // inserted SIMs known phone numbers. + if (aUserSelection.msisdn && this.iccInfo) { + for (let i = 0; i < this.iccInfo.length; i++) { + if (aUserSelection.msisdn == this.iccInfo[i].msisdn) { + serviceId = i; + break; + } + } + } + + let toVerify = {}; + + if (serviceId !== undefined) { + log.debug("iccInfo ${}", this.iccInfo[serviceId]); + toVerify.serviceId = serviceId; + toVerify.iccId = this.iccInfo[serviceId].iccId; + toVerify.msisdn = this.iccInfo[serviceId].msisdn; + toVerify.verificationMethod = + this.iccInfo[serviceId].verificationMethods[0]; + toVerify.verificationDetails = + this.iccInfo[serviceId].verificationDetails[toVerify.verificationMethod]; + return this._verificationFlow(toVerify, aOrigin); + } else { + toVerify.msisdn = aUserSelection.msisdn; + return this.client.discover(aUserSelection.msisdn, + aUserSelection.mcc) + .then( + (discoverResult) => { + if (!discoverResult || !discoverResult.verificationMethods) { + return Promise.reject(ERROR_INTERNAL_UNEXPECTED); + } + log.debug("discoverResult ${}", discoverResult); + toVerify.verificationMethod = discoverResult.verificationMethods[0]; + toVerify.verificationDetails = + discoverResult.verificationDetails[toVerify.verificationMethod]; + return this._verificationFlow(toVerify, aOrigin); + } + ); + } + }, + + + /********************************************************* + * UI prompt functions. + ********************************************************/ + + // The phone number prompt will be used to confirm that the user wants to + // verify and share a known phone number and to allow her to introduce an + // external phone or to select between phone numbers or SIM cards (if the + // phones are not known) in a multi-SIM scenario. + // This prompt will be considered as the permission prompt and its choice + // will be remembered per origin by default. + prompt: function prompt(aPrincipal, aManifestURL, aPhoneInfo) { + log.debug("prompt " + aPrincipal + ", " + aManifestURL + ", " + + aPhoneInfo); + + let phoneInfoArray = []; + + if (aPhoneInfo) { + phoneInfoArray.push(aPhoneInfo); + } + + if (this.iccInfo) { + for (let i = 0; i < this.iccInfo.length; i++) { + // If we don't know the msisdn, there is no previous credentials and + // a silent verification is not possible, we don't allow the user to + // choose this option. + if (!this.iccInfo[i].msisdn && !this.iccInfo[i].credentials && + !this.iccInfo[i].canDoSilentVerification) { + continue; + } + + let phoneInfo = new MobileIdentityUIGluePhoneInfo( + this.iccInfo[i].msisdn, + this.iccInfo[i].operator, + i, // service ID + false, // external + false // primary + ); + phoneInfoArray.push(phoneInfo); + } + } + + return this.ui.startFlow(aManifestURL, phoneInfoArray) + .then( + (result) => { + if (!result || + (!result.phoneNumber && !result.serviceId)) { + return Promise.reject(ERROR_INTERNAL_INVALID_PROMPT_RESULT); + } + + let msisdn; + let mcc; + + // If the user selected one of the existing SIM cards we have to check + // that we either have the MSISDN for that SIM or we can do a silent + // verification that does not require us to have the MSISDN in advance. + if (result.serviceId) { + let icc = this.iccInfo[result.serviceId]; + log.debug("icc ${}", icc); + if (!icc || !icc.msisdn && !icc.canDoSilentVerification) { + return Promise.reject(ERROR_INTERNAL_CANNOT_VERIFY_SELECTION); + } + msisdn = icc.msisdn; + mcc = icc.mcc; + } else { + msisdn = result.prefix ? result.prefix + result.phoneNumber + : result.phoneNumber; + mcc = result.mcc; + } + + // We need to check that the selected phone number is valid and + // if it is not notify the UI about the error and allow the user to + // retry. + if (msisdn && mcc && + !PhoneNumberUtils.parseWithMCC(msisdn, mcc)) { + this.ui.error(ERROR_INVALID_PHONE_NUMBER); + return this.prompt(aPrincipal, aManifestURL, aPhoneInfo); + } + + log.debug("Selected msisdn (if any): " + msisdn + " - " + mcc); + + // The user gave permission for the requester origin, so we store it. + this.addPermission(aPrincipal); + + return { + msisdn: msisdn, + mcc: mcc, + serviceId: result.serviceId + }; + } + ); + }, + + promptAndVerify: function(aPrincipal, aManifestURL, aCreds) { + log.debug("promptAndVerify " + aPrincipal + ", " + aManifestURL + + ", ${}", aCreds); + let userSelection; + + if (Services.io.offline) { + return Promise.reject(ERROR_OFFLINE); + } + + // Before prompting the user we need to check with the server the + // phone number verification methods that are possible with the + // SIMs inserted in the device. + return this.getVerificationOptions() + .then( + () => { + // If we have an exisiting credentials, we add its associated + // phone number information to the list of choices to present + // to the user within the selection prompt. + let phoneInfo; + if (aCreds) { + phoneInfo = new MobileIdentityUIGluePhoneInfo( + aCreds.msisdn, + null, // operator + null, // service ID + !!aCreds.iccId, // external + true // primary + ); + } + return this.prompt(aPrincipal, aManifestURL, phoneInfo); + } + ) + .then( + (promptResult) => { + log.debug("promptResult ${}", promptResult); + // If we had credentials and the user didn't change her + // selection we return them. Otherwise, we need to verify + // the new number. + if (promptResult.msisdn && aCreds && + promptResult.msisdn == aCreds.msisdn) { + return aCreds; + } + + // We might already have credentials for the user selected icc. In + // that case, we update the credentials store with the new origin and + // return the credentials. + if (promptResult.serviceId) { + let creds = this.iccInfo[promptResult.serviceId].credentials; + if (creds) { + this.credStore.add(creds.iccId, creds.msisdn, aPrincipal.origin, + creds.sessionToken); + return creds; + } + } + + // Or we might already have credentials for the selected phone + // number and so we do the same: update the credentials store with the + // new origin and return the credentials. + return this.credStore.getByMsisdn(promptResult.msisdn) + .then( + (creds) => { + if (creds) { + this.credStore.add(creds.iccId, creds.msisdn, aPrincipal.origin, + creds.sessionToken); + return creds; + } + // Otherwise, we need to verify the new number selected by the + // user. + return this.verificationFlow(promptResult, aPrincipal.origin); + } + ); + } + ); + }, + + /********************************************************* + * Assertion generation + ********************************************************/ + + generateAssertion: function(aCredentials, aOrigin) { + if (!aCredentials.sessionToken) { + return Promise.reject(ERROR_INTERNAL_INVALID_TOKEN); + } + + let deferred = Promise.defer(); + + this.getKeyPair(aCredentials.sessionToken) + .then( + (keyPair) => { + log.debug("keyPair " + keyPair.serializedPublicKey); + let options = { + duration: ASSERTION_LIFETIME, + now: this.client.hawk.now(), + localtimeOffsetMsec: this.client.hawk.localtimeOffsetMsec + }; + + this.getCertificate(aCredentials.sessionToken, + keyPair.serializedPublicKey) + .then( + (signedCert) => { + log.debug("generateAssertion " + signedCert); + jwcrypto.generateAssertion(signedCert, keyPair, + aOrigin, options, + (error, assertion) => { + if (error) { + log.error("Error generating assertion " + err); + deferred.reject(error); + return; + } + this.credStore.add(aCredentials.iccId, + aCredentials.msisdn, + aOrigin, + aCredentials.sessionToken) + .then( + () => { + deferred.resolve(assertion); + } + ); + }); + }, deferred.reject + ); + } + ); + + return deferred.promise; + }, + + getMobileIdAssertion: function(aPrincipal, aPromiseId) { + log.debug("getMobileIdAssertion ${}", aPrincipal); + + let uri = Services.io.newURI(aPrincipal.origin, null, null); + let principal = securityManager.getAppCodebasePrincipal( + uri, aPrincipal.appid, aPrincipal.isInBrowserElement); + let manifestURL = appsService.getManifestURLByLocalId(aPrincipal.appId); + + // First of all we look if we already have credentials for this origin. + // If we don't have credentials it means that it is the first time that + // the caller requested an assertion. + return this.credStore.getByOrigin(aPrincipal.origin) + .then( + (creds) => { + log.debug("creds ${creds} - ${origin}", { creds: creds, + origin: aPrincipal.origin}); + if (!creds || !creds.sessionToken) { + log.debug("No credentials"); + return; + } + + // It is possible that the ICC associated with the stored + // credentials is not present in the device anymore, so we ask the + // user if she still wants to use it anyway or if she prefers to use + // another phone number. + // If the credentials are associated with an external SIM or there is + // no SIM in the device, we just return the credentials. + if (this.iccInfo && creds.iccId) { + for (let i = 0; i < this.iccInfo.length; i++) { + if (this.iccInfo[i].iccId == creds.iccId) { + return creds; + } + } + // At this point we know that the SIM associated with the credentials + // is not present in the device any more, so we need to ask the user + // what to do. + return this.promptAndVerify(principal, manifestURL, creds); + } + return creds; + } + ) + .then( + (creds) => { + // Even if we have credentails it is possible that the user has + // removed the permission to share its mobile id with this origin, so + // we check the permission and if it is not granted, we ask the user + // before generating and sharing the assertion. + // If we've just prompted the user in the previous step, the permission + // is already granted and stored so we just progress the credentials. + if (creds) { + if (this.hasPermission(principal)) { + return creds; + } + return this.promptAndVerify(principal, manifestURL, creds); + } + return this.promptAndVerify(principal, manifestURL); + } + ) + .then( + (creds) => { + if (creds) { + return this.generateAssertion(creds, principal.origin); + } + return Promise.reject(ERROR_INTERNAL_CANNOT_GENERATE_ASSERTION); + } + ) + .then( + (assertion) => { + if (!assertion) { + return Promise.reject(ERROR_INTERNAL_CANNOT_GENERATE_ASSERTION); + } + + // Get the verified phone number from the assertion. + let segments = assertion.split("."); + if (!segments) { + return Promise.reject(ERROR_INVALID_ASSERTION); + } + + // We need to translate the base64 alphabet used in JWT to our base64 + // alphabet before calling atob. + let decodedPayload = JSON.parse(atob(segments[1].replace(/-/g, '+') + .replace(/_/g, '/'))); + + if (!decodedPayload || !decodedPayload.verifiedMSISDN) { + return Promise.reject(ERROR_INVALID_ASSERTION); + } + + this.ui.verified(decodedPayload.verifiedMSISDN); + + let mm = this.messageManagers[aPromiseId]; + mm.sendAsyncMessage("MobileId:GetAssertion:Return:OK", { + promiseId: aPromiseId, + result: assertion + }); + } + ) + .then( + null, + (error) => { + log.error("getMobileIdAssertion rejected with " + error); + // Notify the error to the UI. + this.ui.error(error); + + let mm = this.messageManagers[aPromiseId]; + mm.sendAsyncMessage("MobileId:GetAssertion:Return:KO", { + promiseId: aPromiseId, + error: error + }); + } + ); + }, + +}; + +MobileIdentityManager.init(); diff --git a/services/mobileid/MobileIdentitySmsMoMtVerificationFlow.jsm b/services/mobileid/MobileIdentitySmsMoMtVerificationFlow.jsm new file mode 100644 index 00000000000..88589c00918 --- /dev/null +++ b/services/mobileid/MobileIdentitySmsMoMtVerificationFlow.jsm @@ -0,0 +1,89 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["MobileIdentitySmsMoMtVerificationFlow"]; + +const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + +Cu.import("resource://gre/modules/MobileIdentityCommon.jsm"); +Cu.import("resource://gre/modules/MobileIdentitySmsVerificationFlow.jsm"); +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "smsService", + "@mozilla.org/sms/smsservice;1", + "nsISmsService"); + +// In order to send messages through nsISmsService, we need to implement +// nsIMobileMessageCallback, as the WebSMS API implementation is not usable +// from JS. +function SilentSmsRequest(aDeferred) { + this.deferred = aDeferred; +} +SilentSmsRequest.prototype = { + QueryInterface: XPCOMUtils.generateQI([Ci.nsIMobileMessageCallback]), + + classID: Components.ID("{ff46f1a8-e040-4ff4-98a7-d5a5b86a2c3e}"), + + notifyMessageSent: function notifyMessageSent(aMessage) { + log.debug("Silent message successfully sent"); + this.deferred.resolve(aMessage); + }, + + notifySendMessageFailed: function notifySendMessageFailed(aError) { + log.error("Error sending silent message " + aError); + this.deferred.reject(aError); + } +}; + +this.MobileIdentitySmsMoMtVerificationFlow = function(aOrigin, + aServiceId, + aIccId, + aMtSender, + aMoVerifier, + aUI, + aClient) { + + log.debug("MobileIdentitySmsMoMtVerificationFlow"); + + MobileIdentitySmsVerificationFlow.call(this, + aOrigin, + null, //msisdn + aIccId, + aServiceId, + false, // external + aMtSender, + aMoVerifier, + aUI, + aClient, + this.smsVerifyStrategy); +}; + +this.MobileIdentitySmsMoMtVerificationFlow.prototype = { + + __proto__: MobileIdentitySmsVerificationFlow.prototype, + + smsVerifyStrategy: function() { + // In the MO+MT flow we need to send an SMS to the given moVerifier number + // so the server can find out our phone number to send an SMS back with a + // verification code. + let deferred = Promise.defer(); + let silentSmsRequest = new SilentSmsRequest(deferred); + + // The MO SMS body that the server expects contains the API endpoint for + // the MO verify request and the HAWK ID parameter derived via HKDF from + // the session token. These parameters should go unnamed and space limited. + let body = SMS_MO_MT_VERIFY + " " + + this.client._deriveHawkCredentials(this.sessionToken).id; + smsService.send(this.verificationOptions.serviceId, + this.verificationOptions.moVerifier, + body, + true, // silent + silentSmsRequest); + log.debug("Sending " + body + " to " + this.verificationOptions.moVerifier); + return deferred.promise; + } +}; diff --git a/services/mobileid/MobileIdentitySmsMtVerificationFlow.jsm b/services/mobileid/MobileIdentitySmsMtVerificationFlow.jsm new file mode 100644 index 00000000000..64ef8254be7 --- /dev/null +++ b/services/mobileid/MobileIdentitySmsMtVerificationFlow.jsm @@ -0,0 +1,49 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["MobileIdentitySmsMtVerificationFlow"]; + +const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + +Cu.import("resource://gre/modules/MobileIdentityCommon.jsm"); +Cu.import("resource://gre/modules/MobileIdentitySmsVerificationFlow.jsm"); +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +this.MobileIdentitySmsMtVerificationFlow = function(aOrigin, + aMsisdn, + aIccId, + aExternal, + aMtSender, + aUI, + aClient) { + + log.debug("MobileIdentitySmsVerificationFlow " + aMsisdn + ", external: " + + aExternal); + + MobileIdentitySmsVerificationFlow.call(this, + aOrigin, + aMsisdn, + aIccId, + null, // service ID + aExternal, + aMtSender, + null, // moVerifier + aUI, + aClient, + this.smsVerifyStrategy); +}; + +this.MobileIdentitySmsMtVerificationFlow.prototype = { + + __proto__: MobileIdentitySmsVerificationFlow.prototype, + + smsVerifyStrategy: function() { + return this.client.smsMtVerify(this.sessionToken, + this.verificationOptions.msisdn, + this.verificationOptions.external); + } +}; diff --git a/services/mobileid/MobileIdentitySmsVerificationFlow.jsm b/services/mobileid/MobileIdentitySmsVerificationFlow.jsm new file mode 100644 index 00000000000..2c0663ca7b7 --- /dev/null +++ b/services/mobileid/MobileIdentitySmsVerificationFlow.jsm @@ -0,0 +1,120 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["MobileIdentitySmsVerificationFlow"]; + +const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + +Cu.import("resource://gre/modules/MobileIdentityCommon.jsm"); +Cu.import("resource://gre/modules/MobileIdentityVerificationFlow.jsm"); +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +#ifdef MOZ_B2G_RIL +XPCOMUtils.defineLazyServiceGetter(this, "smsService", + "@mozilla.org/sms/smsservice;1", + "nsISmsService"); +#endif + +this.MobileIdentitySmsVerificationFlow = function(aOrigin, + aMsisdn, + aIccId, + aServiceId, + aExternal, + aMtSender, + aMoVerifier, + aUI, + aClient, + aVerifyStrategy) { + + // SMS MT or SMS MO+MT specific verify strategy. + this.smsVerifyStrategy = aVerifyStrategy; + + MobileIdentityVerificationFlow.call(this, { + origin: aOrigin, + msisdn: aMsisdn, + iccId: aIccId, + serviceId: aServiceId, + external: aExternal, + mtSender: aMtSender, + moVerifier: aMoVerifier + }, aUI, aClient, this._verifyStrategy, this._cleanupStrategy); +}; + +this.MobileIdentitySmsVerificationFlow.prototype = { + + __proto__: MobileIdentityVerificationFlow.prototype, + + observedSilentNumber: null, + + onSilentSms: null, + + _verifyStrategy: function() { + if (!this.smsVerifyStrategy) { + return Promise.reject(ERROR_INTERNAL_UNEXPECTED); + } + + // Even if the user selection is given to us as a possible external phone + // number, it is also possible that the phone number introduced by the + // user belongs to one of the SIMs inserted in the device which MSISDN + // is unknown for us, so we always observe for incoming messages coming + // from the given mtSender. + +#ifdef MOZ_B2G_RIL + this.observedSilentNumber = this.verificationOptions.mtSender; + try { + smsService.addSilentNumber(this.observedSilentNumber); + } catch (e) { + log.warn("We are already listening for that number"); + } + + this.onSilentSms = (function(aSubject, aTopic, aData) { + log.debug("Got silent message " + aSubject.sender + " - " + aSubject.body); + // We might have observed a notification of an incoming silent message + // for other number. In that case, we just bail out. + if (aSubject.sender != this.observedSilentNumber) { + return; + } + + // We got the SMS containing the verification code. + + // If the phone number we are trying to verify is or can be an external + // phone number (meaning that it doesn't belong to any of the inserted + // SIMs) we will be receiving an human readable SMS containing a short + // verification code. In this case we need to parse the SMS body to + // extract the verification code. + // Otherwise, we just use the whole SMS body as it should contain a long + // verification code. + let verificationCode = aSubject.body; + if (this.verificationOptions.external) { + // We just take the numerical characters from the body. + verificationCode = aSubject.body.replace(/[^0-9]/g,''); + } + + log.debug("Verification code: " + verificationCode); + + this.verificationCodeDeferred.resolve(verificationCode); + }).bind(this); + + Services.obs.addObserver(this.onSilentSms, + SILENT_SMS_RECEIVED_TOPIC, + false); + log.debug("Observing messages from " + this.observedSilentNumber); +#endif + + return this.smsVerifyStrategy(); + }, + + _cleanupStrategy: function() { +#ifdef MOZ_B2G_RIL + smsService.removeSilentNumber(this.observedSilentNumber); + Services.obs.removeObserver(this.onSilentSms, + SILENT_SMS_RECEIVED_TOPIC); + this.observedSilentNumber = null; + this.onSilentSms = null; +#endif + } +}; diff --git a/services/mobileid/MobileIdentityUIGlueCommon.jsm b/services/mobileid/MobileIdentityUIGlueCommon.jsm new file mode 100644 index 00000000000..7f42cf44d79 --- /dev/null +++ b/services/mobileid/MobileIdentityUIGlueCommon.jsm @@ -0,0 +1,31 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["MobileIdentityUIGluePhoneInfo", + "MobileIdentityUIGluePromptResult"]; + +this.MobileIdentityUIGluePhoneInfo = function (aMsisdn, aOperator, aServiceId, + aExternal, aPrimary) { + this.msisdn = aMsisdn; + this.operator = aOperator; + this.serviceId = aServiceId; + // A phone number is considered "external" when it doesn't or we don't know + // if it does belong to any of the device SIM cards. + this.external = aExternal; + this.primary = aPrimary; +} + +this.MobileIdentityUIGluePhoneInfo.prototype = {}; + +this.MobileIdentityUIGluePromptResult = function (aPhoneNumber, aPrefix, aMcc, + aServiceId) { + this.phoneNumber = aPhoneNumber; + this.prefix = aPrefix; + this.mcc = aMcc; + this.serviceId = aServiceId; +} + +this.MobileIdentityUIGluePromptResult.prototype = {}; diff --git a/services/mobileid/MobileIdentityVerificationFlow.jsm b/services/mobileid/MobileIdentityVerificationFlow.jsm new file mode 100644 index 00000000000..ad7583aaf35 --- /dev/null +++ b/services/mobileid/MobileIdentityVerificationFlow.jsm @@ -0,0 +1,213 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["MobileIdentityVerificationFlow"]; + +const { utils: Cu, classes: Cc, interfaces: Ci } = Components; + +Cu.import("resource://gre/modules/MobileIdentityCommon.jsm"); +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +this.MobileIdentityVerificationFlow = function(aVerificationOptions, + aUI, + aClient, + aVerifyStrategy, + aCleanupStrategy) { + this.verificationOptions = aVerificationOptions; + this.ui = aUI; + this.client = aClient; + this.retries = VERIFICATIONCODE_RETRIES; + this.verifyStrategy = aVerifyStrategy; + this.cleanupStrategy = aCleanupStrategy; +}; + +MobileIdentityVerificationFlow.prototype = { + + doVerification: function() { + log.debug("Start verification flow"); + return this.register() + .then( + (registerResult) => { + log.debug("Register result ${}", registerResult); + if (!registerResult || !registerResult.msisdnSessionToken) { + return Promise.reject(ERROR_INTERNAL_UNEXPECTED); + } + this.sessionToken = registerResult.msisdnSessionToken; + return this._doVerification(); + } + ) + }, + + _doVerification: function() { + log.debug("_doVerification"); + // We save the timestamp of the start of the verification timeout to be + // able to provide to the UI the remaining time on each retry. + if (!this.timer) { + log.debug("Creating verification code timer"); + this.timerCreation = Date.now(); + this.timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + this.timer.initWithCallback(this.onVerificationCodeTimeout.bind(this), + VERIFICATIONCODE_TIMEOUT, + this.timer.TYPE_ONE_SHOT); + } + + if (!this.verifyStrategy) { + return Promise.reject(ERROR_INTERNAL_INVALID_VERIFICATION_FLOW); + } + + this.verificationCodeDeferred = Promise.defer(); + + this.verifyStrategy() + .then( + () => { + // If the verification flow can be for an external phone number, + // we need to ask the user for the verification code. + // In that case we don't do a notification about the verification + // process being done until the user enters the verification code + // in the UI. + if (this.verificationOptions.external) { + let timeLeft = 0; + if (this.timer) { + timeLeft = this.timerCreation + VERIFICATIONCODE_TIMEOUT - + Date.now(); + } + this.ui.verificationCodePrompt(this.retries, + VERIFICATIONCODE_TIMEOUT / 1000, + timeLeft / 1000) + .then( + (verificationCode) => { + if (!verificationCode) { + return this.verificationCodeDeferred.reject( + ERROR_INTERNAL_INVALID_PROMPT_RESULT); + } + // If the user got the verification code that means that the + // introduced phone number didn't belong to any of the inserted + // SIMs. + this.ui.verify(); + this.verificationCodeDeferred.resolve(verificationCode); + } + ); + } else { + this.ui.verify(); + } + }, + (reason) => { + this.verificationCodeDeferred.reject(reason); + } + ); + return this.verificationCodeDeferred.promise.then( + this.onVerificationCode.bind(this) + ); + }, + + // When we receive a verification code from the UI, we check it against + // the server. If the verification code is incorrect, we decrease the + // number of retries left and allow the user to try again. If there is no + // possible retry left, we notify about this error so the UI can allow the + // user to request the resend of a new verification code. + onVerificationCode: function(aVerificationCode) { + log.debug("onVerificationCode " + aVerificationCode); + if (!aVerificationCode) { + this.ui.error(ERROR_INVALID_VERIFICATION_CODE); + return this._doVerification(); + } + + // Before checking the verification code against the server we set the + // "verifying" flag to queue timeout expiration events received before + // the server request is completed. If the server request is positive + // we will discard the timeout event, otherwise we will progress the + // event to the UI to allow the user to retry. + this.verifying = true; + + return this.verifyCode(aVerificationCode) + .then( + (result) => { + if (!result) { + return Promise.reject(INTERNAL_UNEXPECTED); + } + // The code was correct! + // At this point the phone number is verified. + // We return the given verification options with the session token + // to be stored in the credentials store. With this data we will be + // asking the server to give us a certificate to generate assertions. + this.verificationOptions.sessionToken = this.sessionToken; + this.verificationOptions.msisdn = result.msisdn || + this.verificationOptions.msisdn; + return this.verificationOptions; + }, + (error) => { + log.error("Verification code error " + error); + this.retries--; + log.error("Retries left " + this.retries); + if (!this.retries) { + this.ui.error(ERROR_NO_RETRIES_LEFT); + return Promise.reject(ERROR_NO_RETRIES_LEFT); + } + this.verifying = false; + if (this.queuedTimeout) { + this.onVerificationCodeTimeout(); + } + return this._doVerification(); + } + ); + }, + + onVerificationCodeTimeout: function() { + // It is possible that we get the timeout when we are checking a + // verification code with the server. In that case, we queue the + // timeout to be triggered after we receive the reply from the server + // if needed. + if (this.verifying) { + this.queuedTimeout = true; + return; + } + + // When the verification process times out we do a clean up, reject + // the corresponding promise and notify the UI about the timeout. + if (this.verificationCodeDeferred) { + this.verificationCodeDeferred.reject(ERROR_VERIFICATION_CODE_TIMEOUT); + } + this.ui.error(ERROR_VERIFICATION_CODE_TIMEOUT); + }, + + register: function() { + return this.client.register(); + }, + + verifyCode: function(aVerificationCode) { + return this.client.verifyCode(this.sessionToken, aVerificationCode); + }, + + unregister: function() { + return this.client.unregister(this.sessionToken); + }, + + cleanup: function(aUnregister = false) { + log.debug("Verification flow cleanup"); + + this.queuedTimeout = false; + this.retries = VERIFICATIONCODE_RETRIES; + + if (this.timer) { + this.timer.cancel(); + this.timer = null; + } + + if (aUnregister) { + this.unregister(). + then( + () => { + this.sessionToken = null; + } + ); + } + + if (this.cleanupStrategy) { + this.cleanupStrategy(); + } + } +}; diff --git a/services/mobileid/interfaces/moz.build b/services/mobileid/interfaces/moz.build new file mode 100644 index 00000000000..30fb53edcf2 --- /dev/null +++ b/services/mobileid/interfaces/moz.build @@ -0,0 +1,11 @@ +# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +XPIDL_SOURCES += [ + 'nsIMobileIdentityUIGlue.idl' +] + +XPIDL_MODULE = 'services_mobileidentity' diff --git a/services/mobileid/interfaces/nsIMobileIdentityUIGlue.idl b/services/mobileid/interfaces/nsIMobileIdentityUIGlue.idl new file mode 100644 index 00000000000..65f1dcc1e8c --- /dev/null +++ b/services/mobileid/interfaces/nsIMobileIdentityUIGlue.idl @@ -0,0 +1,77 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "nsISupports.idl" + +[scriptable, uuid(6c4c5758-e041-4e0d-98da-67bb552f8018)] +interface nsIMobileIdentityUIGlue : nsISupports +{ + /** + * Request the creation of a Mobile ID UI flow. + * + * The permission prompt starts the verification flow asking the user + * for permission to share her phone number and allowing her to choose + * an already known phone number, a SIM which phone number is unknown + * (even in a multi-SIM scenario) or an external phone number. + * Selecting a phone number implies giving permission to share it with the + * API caller, so the UI should be clear about this. + * + * @manifestURL manifest URL of the mobile ID requester. + * @iccInfo array of objects containing the information about the + * SIM cards available in the device and that can be used for the + * phone number verification and share process. + * + * Returns a Promise. An instance of nsIMobileIdentityUIGluePromptResult will + * be returned as result of the Promise or a single string containing an error + * in case of rejection. + */ + jsval startFlow(in DOMString manifestURL, in jsval iccInfo); + + /** + * Will prompt the user to enter a code used to verify a phone number. + * This will only be called if an external phone number is selected in + * startFlow(). + * + * @retries number of retries left to validate a verification code. + * @timeout the verification code expires after the timeout fires. This is + * the total life time of the verification code. + * @timeLeft we might call verificationCodePrompt more than once for the + * same verification flow (i.e. when the verification code entered + * by the user is incorrect) so we give to the UI the amount of + * time left before the verification code expires. + * + * Returns a Promise. The value of the resolved promise will be the + * verification code introduced through the UI or an error in case of + * rejection of the promise. + */ + jsval verificationCodePrompt(in short retries, + in long timeout, + in long timeLeft); + + /** + * Notify the UI about the start of the verification process. + */ + void verify(); + + /** + * Notify the UI about an error in the verification process. + */ + void error(in DOMString error); + + /** + * Notify the UI about the succesful phone number verification. + */ + void verified(in DOMString verifiedPhoneNumber); + + /** + * Callback to be called when the user cancels the verification flow via UI. + */ + attribute jsval oncancel; + + /** + * Callback to be called when the user requests a resend of a verification + * code. + */ + attribute jsval onresendcode; +}; diff --git a/services/mobileid/moz.build b/services/mobileid/moz.build new file mode 100644 index 00000000000..769148bc06d --- /dev/null +++ b/services/mobileid/moz.build @@ -0,0 +1,22 @@ +# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +PARALLEL_DIRS += ['interfaces'] + +EXTRA_JS_MODULES += [ + 'MobileIdentityClient.jsm', + 'MobileIdentityCommon.jsm', + 'MobileIdentityCredentialsStore.jsm', + 'MobileIdentitySmsMoMtVerificationFlow.jsm', + 'MobileIdentitySmsMtVerificationFlow.jsm', + 'MobileIdentityUIGlueCommon.jsm', + 'MobileIdentityVerificationFlow.jsm' +] + +EXTRA_PP_JS_MODULES += [ + 'MobileIdentityManager.jsm', + 'MobileIdentitySmsVerificationFlow.jsm' +] diff --git a/services/moz.build b/services/moz.build index a5d07d71b78..629969c2e3a 100644 --- a/services/moz.build +++ b/services/moz.build @@ -6,7 +6,7 @@ PARALLEL_DIRS += [ 'common', - 'crypto', + 'crypto' ] if CONFIG['MOZ_WIDGET_TOOLKIT'] != 'android': @@ -27,4 +27,7 @@ if CONFIG['MOZ_SERVICES_FXACCOUNTS']: if CONFIG['MOZ_SERVICES_SYNC']: PARALLEL_DIRS += ['sync'] +if CONFIG['MOZ_B2G']: + PARALLEL_DIRS += ['mobileid'] + SPHINX_TREES['services'] = 'docs'