diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
commit | 2aa4a82499d4becd2284cdb482213d541b8804dd (patch) | |
tree | b80bf8bf13c3766139fbacc530efd0dd9d54394c /browser/extensions/formautofill/FormAutofillStorage.jsm | |
parent | Initial commit. (diff) | |
download | firefox-upstream.tar.xz firefox-upstream.zip |
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/extensions/formautofill/FormAutofillStorage.jsm')
-rw-r--r-- | browser/extensions/formautofill/FormAutofillStorage.jsm | 2203 |
1 files changed, 2203 insertions, 0 deletions
diff --git a/browser/extensions/formautofill/FormAutofillStorage.jsm b/browser/extensions/formautofill/FormAutofillStorage.jsm new file mode 100644 index 0000000000..1d153f2752 --- /dev/null +++ b/browser/extensions/formautofill/FormAutofillStorage.jsm @@ -0,0 +1,2203 @@ +/* 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/. */ + +/* + * Implements an interface of the storage of Form Autofill. + * + * The data is stored in JSON format, without indentation and the computed + * fields, using UTF-8 encoding. With indentation and computed fields applied, + * the schema would look like this: + * + * { + * version: 1, + * addresses: [ + * { + * guid, // 12 characters + * version, // schema version in integer + * + * // address fields + * given-name, + * additional-name, + * family-name, + * organization, // Company + * street-address, // (Multiline) + * address-level3, // Suburb/Sublocality + * address-level2, // City/Town + * address-level1, // Province (Standardized code if possible) + * postal-code, + * country, // ISO 3166 + * tel, // Stored in E.164 format + * email, + * + * // computed fields (These fields are computed based on the above fields + * // and are not allowed to be modified directly.) + * name, + * address-line1, + * address-line2, + * address-line3, + * country-name, + * tel-country-code, + * tel-national, + * tel-area-code, + * tel-local, + * tel-local-prefix, + * tel-local-suffix, + * + * // metadata + * timeCreated, // in ms + * timeLastUsed, // in ms + * timeLastModified, // in ms + * timesUsed + * _sync: { ... optional sync metadata }, + * } + * ], + * creditCards: [ + * { + * guid, // 12 characters + * version, // schema version in integer + * + * // credit card fields + * billingAddressGUID, // An optional GUID of an autofill address record + * which may or may not exist locally. + * + * cc-name, + * cc-number, // will be stored in masked format (************1234) + * // (see details below) + * cc-exp-month, + * cc-exp-year, // 2-digit year will be converted to 4 digits + * // upon saving + * cc-type, // Optional card network id (instrument type) + * + * // computed fields (These fields are computed based on the above fields + * // and are not allowed to be modified directly.) + * cc-given-name, + * cc-additional-name, + * cc-family-name, + * cc-number-encrypted, // encrypted from the original unmasked "cc-number" + * // (see details below) + * cc-exp, + * + * // metadata + * timeCreated, // in ms + * timeLastUsed, // in ms + * timeLastModified, // in ms + * timesUsed + * _sync: { ... optional sync metadata }, + * } + * ] + * } + * + * + * Encrypt-related Credit Card Fields (cc-number & cc-number-encrypted): + * + * When saving or updating a credit-card record, the storage will encrypt the + * value of "cc-number", store the encrypted number in "cc-number-encrypted" + * field, and replace "cc-number" field with the masked number. These all happen + * in "computeFields". We do reverse actions in "_stripComputedFields", which + * decrypts "cc-number-encrypted", restores it to "cc-number", and deletes + * "cc-number-encrypted". Therefore, calling "_stripComputedFields" followed by + * "computeFields" can make sure the encrypt-related fields are up-to-date. + * + * In general, you have to decrypt the number by your own outside FormAutofillStorage + * when necessary. However, you will get the decrypted records when querying + * data with "rawData=true" to ensure they're ready to sync. + * + * + * Sync Metadata: + * + * Records may also have a _sync field, which consists of: + * { + * changeCounter, // integer - the number of changes made since the last + * // sync. + * lastSyncedFields, // object - hashes of the original values for fields + * // changed since the last sync. + * } + * + * Records with such a field have previously been synced. Records without such + * a field are yet to be synced, so are treated specially in some cases (eg, + * they don't need a tombstone, de-duping logic treats them as special etc). + * Records without the field are always considered "dirty" from Sync's POV + * (meaning they will be synced on the next sync), at which time they will gain + * this new field. + */ + +"use strict"; + +// We expose a singleton from this module. Some tests may import the +// constructor via a backstage pass. +this.EXPORTED_SYMBOLS = ["formAutofillStorage"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); + +const { FormAutofill } = ChromeUtils.import( + "resource://formautofill/FormAutofill.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "CreditCard", + "resource://gre/modules/CreditCard.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "JSONFile", + "resource://gre/modules/JSONFile.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "FormAutofillNameUtils", + "resource://formautofill/FormAutofillNameUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "FormAutofillUtils", + "resource://formautofill/FormAutofillUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "OSKeyStore", + "resource://gre/modules/OSKeyStore.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "PhoneNumber", + "resource://formautofill/phonenumberutils/PhoneNumber.jsm" +); + +XPCOMUtils.defineLazyServiceGetter( + this, + "gUUIDGenerator", + "@mozilla.org/uuid-generator;1", + "nsIUUIDGenerator" +); + +const CryptoHash = Components.Constructor( + "@mozilla.org/security/hash;1", + "nsICryptoHash", + "initWithString" +); + +const PROFILE_JSON_FILE_NAME = "autofill-profiles.json"; + +const STORAGE_SCHEMA_VERSION = 1; +const ADDRESS_SCHEMA_VERSION = 1; +const CREDIT_CARD_SCHEMA_VERSION = 3; + +const VALID_ADDRESS_FIELDS = [ + "given-name", + "additional-name", + "family-name", + "organization", + "street-address", + "address-level3", + "address-level2", + "address-level1", + "postal-code", + "country", + "tel", + "email", +]; + +const STREET_ADDRESS_COMPONENTS = [ + "address-line1", + "address-line2", + "address-line3", +]; + +const TEL_COMPONENTS = [ + "tel-country-code", + "tel-national", + "tel-area-code", + "tel-local", + "tel-local-prefix", + "tel-local-suffix", +]; + +const VALID_ADDRESS_COMPUTED_FIELDS = ["name", "country-name"].concat( + STREET_ADDRESS_COMPONENTS, + TEL_COMPONENTS +); + +const VALID_CREDIT_CARD_FIELDS = [ + "billingAddressGUID", + "cc-name", + "cc-number", + "cc-exp-month", + "cc-exp-year", + "cc-type", +]; + +const VALID_CREDIT_CARD_COMPUTED_FIELDS = [ + "cc-given-name", + "cc-additional-name", + "cc-family-name", + "cc-number-encrypted", + "cc-exp", +]; + +const INTERNAL_FIELDS = [ + "guid", + "version", + "timeCreated", + "timeLastUsed", + "timeLastModified", + "timesUsed", +]; + +function sha512(string) { + if (string == null) { + return null; + } + let encoder = new TextEncoder("utf-8"); + let bytes = encoder.encode(string); + let hash = new CryptoHash("sha512"); + hash.update(bytes, bytes.length); + return hash.finish(/* base64 */ true); +} + +/** + * Class that manipulates records in a specified collection. + * + * Note that it is responsible for converting incoming data to a consistent + * format in the storage. For example, computed fields will be transformed to + * the original fields and 2-digit years will be calculated into 4 digits. + */ +class AutofillRecords { + /** + * Creates an AutofillRecords. + * + * @param {JSONFile} store + * An instance of JSONFile. + * @param {string} collectionName + * A key of "store.data". + * @param {Array.<string>} validFields + * A list containing non-metadata field names. + * @param {Array.<string>} validComputedFields + * A list containing computed field names. + * @param {number} schemaVersion + * The schema version for the new record. + */ + constructor( + store, + collectionName, + validFields, + validComputedFields, + schemaVersion + ) { + FormAutofill.defineLazyLogGetter(this, "AutofillRecords:" + collectionName); + + this.VALID_FIELDS = validFields; + this.VALID_COMPUTED_FIELDS = validComputedFields; + + this._store = store; + this._collectionName = collectionName; + this._schemaVersion = schemaVersion; + + this._initializePromise = Promise.all( + this._data.map(async (record, index) => + this._migrateRecord(record, index) + ) + ).then(hasChangesArr => { + let dataHasChanges = hasChangesArr.includes(true); + if (dataHasChanges) { + this._store.saveSoon(); + } + }); + } + + /** + * Gets the schema version number. + * + * @returns {number} + * The current schema version number. + */ + get version() { + return this._schemaVersion; + } + + /** + * Gets the data of this collection. + * + * @returns {array} + * The data object. + */ + get _data() { + return this._store.data[this._collectionName]; + } + + // Ensures that we don't try to apply synced records with newer schema + // versions. This is a temporary measure to ensure we don't accidentally + // bump the schema version without a syncing strategy in place (bug 1377204). + _ensureMatchingVersion(record) { + if (record.version != this.version) { + throw new Error( + `Got unknown record version ${record.version}; want ${this.version}` + ); + } + } + + /** + * Initialize the records in the collection, resolves when the migration completes. + * @returns {Promise} + */ + initialize() { + return this._initializePromise; + } + + /** + * Adds a new record. + * + * @param {Object} record + * The new record for saving. + * @param {boolean} [options.sourceSync = false] + * Did sync generate this addition? + * @returns {Promise<string>} + * The GUID of the newly added item.. + */ + async add(record, { sourceSync = false } = {}) { + this.log.debug("add:", record); + + let recordToSave = this._clone(record); + + if (sourceSync) { + // Remove tombstones for incoming items that were changed on another + // device. Local deletions always lose to avoid data loss. + let index = this._findIndexByGUID(recordToSave.guid, { + includeDeleted: true, + }); + if (index > -1) { + let existing = this._data[index]; + if (existing.deleted) { + this._data.splice(index, 1); + } else { + throw new Error(`Record ${recordToSave.guid} already exists`); + } + } + } else if (!recordToSave.deleted) { + this._normalizeRecord(recordToSave); + // _normalizeRecord shouldn't do any validation (throw) because in the + // `update` case it is called with partial records whereas + // `_validateFields` is called with a complete one. + this._validateFields(recordToSave); + + recordToSave.guid = this._generateGUID(); + recordToSave.version = this.version; + + // Metadata + let now = Date.now(); + recordToSave.timeCreated = now; + recordToSave.timeLastModified = now; + recordToSave.timeLastUsed = 0; + recordToSave.timesUsed = 0; + } + + return this._saveRecord(recordToSave, { sourceSync }); + } + + async _saveRecord(record, { sourceSync = false } = {}) { + if (!record.guid) { + throw new Error("Record missing GUID"); + } + + let recordToSave; + if (record.deleted) { + if (this._findByGUID(record.guid, { includeDeleted: true })) { + throw new Error("a record with this GUID already exists"); + } + recordToSave = { + guid: record.guid, + timeLastModified: record.timeLastModified || Date.now(), + deleted: true, + }; + } else { + this._ensureMatchingVersion(record); + recordToSave = record; + await this.computeFields(recordToSave); + } + + if (sourceSync) { + let sync = this._getSyncMetaData(recordToSave, true); + sync.changeCounter = 0; + } + + this._data.push(recordToSave); + + this.updateUseCountTelemetry(); + + this._store.saveSoon(); + + Services.obs.notifyObservers( + { + wrappedJSObject: { + sourceSync, + guid: record.guid, + collectionName: this._collectionName, + }, + }, + "formautofill-storage-changed", + "add" + ); + return recordToSave.guid; + } + + _generateGUID() { + let guid; + while (!guid || this._findByGUID(guid)) { + guid = gUUIDGenerator + .generateUUID() + .toString() + .replace(/[{}-]/g, "") + .substring(0, 12); + } + return guid; + } + + /** + * Update the specified record. + * + * @param {string} guid + * Indicates which record to update. + * @param {Object} record + * The new record used to overwrite the old one. + * @param {Promise<boolean>} [preserveOldProperties = false] + * Preserve old record's properties if they don't exist in new record. + */ + async update(guid, record, preserveOldProperties = false) { + this.log.debug("update:", guid, record); + + let recordFoundIndex = this._findIndexByGUID(guid); + if (recordFoundIndex == -1) { + throw new Error("No matching record."); + } + + // Clone the record before modifying it to avoid exposing incomplete changes. + let recordFound = this._clone(this._data[recordFoundIndex]); + await this._stripComputedFields(recordFound); + + let recordToUpdate = this._clone(record); + this._normalizeRecord(recordToUpdate, true); + + let hasValidField = false; + for (let field of this.VALID_FIELDS) { + let oldValue = recordFound[field]; + let newValue = recordToUpdate[field]; + + // Resume the old field value in the perserve case + if (preserveOldProperties && newValue === undefined) { + newValue = oldValue; + } + + if (newValue === undefined || newValue === "") { + delete recordFound[field]; + } else { + hasValidField = true; + recordFound[field] = newValue; + } + + this._maybeStoreLastSyncedField(recordFound, field, oldValue); + } + + if (!hasValidField) { + throw new Error("Record contains no valid field."); + } + + // _normalizeRecord above is called with the `record` argument provided to + // `update` which may not contain all resulting fields when + // `preserveOldProperties` is used. This means we need to validate for + // missing fields after we compose the record (`recordFound`) with the stored + // record like we do in the loop above. + this._validateFields(recordFound); + + recordFound.timeLastModified = Date.now(); + let syncMetadata = this._getSyncMetaData(recordFound); + if (syncMetadata) { + syncMetadata.changeCounter += 1; + } + + await this.computeFields(recordFound); + this._data[recordFoundIndex] = recordFound; + + this._store.saveSoon(); + + Services.obs.notifyObservers( + { + wrappedJSObject: { + guid, + collectionName: this._collectionName, + }, + }, + "formautofill-storage-changed", + "update" + ); + } + + /** + * Notifies the storage of the use of the specified record, so we can update + * the metadata accordingly. This does not bump the Sync change counter, since + * we don't sync `timesUsed` or `timeLastUsed`. + * + * @param {string} guid + * Indicates which record to be notified. + */ + notifyUsed(guid) { + this.log.debug("notifyUsed:", guid); + + let recordFound = this._findByGUID(guid); + if (!recordFound) { + throw new Error("No matching record."); + } + + recordFound.timesUsed++; + recordFound.timeLastUsed = Date.now(); + + this.updateUseCountTelemetry(); + + this._store.saveSoon(); + Services.obs.notifyObservers( + { + wrappedJSObject: { + guid, + collectionName: this._collectionName, + }, + }, + "formautofill-storage-changed", + "notifyUsed" + ); + } + + updateUseCountTelemetry() {} + + /** + * Removes the specified record. No error occurs if the record isn't found. + * + * @param {string} guid + * Indicates which record to remove. + * @param {boolean} [options.sourceSync = false] + * Did Sync generate this removal? + */ + remove(guid, { sourceSync = false } = {}) { + this.log.debug("remove:", guid); + + if (sourceSync) { + this._removeSyncedRecord(guid); + } else { + let index = this._findIndexByGUID(guid, { includeDeleted: false }); + if (index == -1) { + this.log.warn("attempting to remove non-existing entry", guid); + return; + } + let existing = this._data[index]; + if (existing.deleted) { + return; // already a tombstone - don't touch it. + } + let existingSync = this._getSyncMetaData(existing); + if (existingSync) { + // existing sync metadata means it has been synced. This means we must + // leave a tombstone behind. + this._data[index] = { + guid, + timeLastModified: Date.now(), + deleted: true, + _sync: existingSync, + }; + existingSync.changeCounter++; + } else { + // If there's no sync meta-data, this record has never been synced, so + // we can delete it. + this._data.splice(index, 1); + } + } + + this.updateUseCountTelemetry(); + + this._store.saveSoon(); + Services.obs.notifyObservers( + { + wrappedJSObject: { + sourceSync, + guid, + collectionName: this._collectionName, + }, + }, + "formautofill-storage-changed", + "remove" + ); + } + + /** + * Returns the record with the specified GUID. + * + * @param {string} guid + * Indicates which record to retrieve. + * @param {boolean} [options.rawData = false] + * Returns a raw record without modifications and the computed fields + * (this includes private fields) + * @returns {Promise<Object>} + * A clone of the record. + */ + async get(guid, { rawData = false } = {}) { + this.log.debug("get:", guid, rawData); + + let recordFound = this._findByGUID(guid); + if (!recordFound) { + return null; + } + + // The record is cloned to avoid accidental modifications from outside. + let clonedRecord = this._cloneAndCleanUp(recordFound); + if (rawData) { + await this._stripComputedFields(clonedRecord); + } else { + this._recordReadProcessor(clonedRecord); + } + return clonedRecord; + } + + /** + * Returns all records. + * + * @param {boolean} [options.rawData = false] + * Returns raw records without modifications and the computed fields. + * @param {boolean} [options.includeDeleted = false] + * Also return any tombstone records. + * @returns {Promise<Array.<Object>>} + * An array containing clones of all records. + */ + async getAll({ rawData = false, includeDeleted = false } = {}) { + this.log.debug("getAll", rawData, includeDeleted); + + let records = this._data.filter(r => !r.deleted || includeDeleted); + // Records are cloned to avoid accidental modifications from outside. + let clonedRecords = records.map(r => this._cloneAndCleanUp(r)); + await Promise.all( + clonedRecords.map(async record => { + if (rawData) { + await this._stripComputedFields(record); + } else { + this._recordReadProcessor(record); + } + }) + ); + return clonedRecords; + } + + /** + * Return all saved field names in the collection. This method + * has to be sync because its caller _updateSavedFieldNames() needs + * to dispatch content message synchronously. + * + * @returns {Set} Set containing saved field names. + */ + getSavedFieldNames() { + this.log.debug("getSavedFieldNames"); + + let records = this._data.filter(r => !r.deleted); + records + .map(record => this._cloneAndCleanUp(record)) + .forEach(record => this._recordReadProcessor(record)); + + let fieldNames = new Set(); + for (let record of records) { + for (let fieldName of Object.keys(record)) { + if (INTERNAL_FIELDS.includes(fieldName) || !record[fieldName]) { + continue; + } + fieldNames.add(fieldName); + } + } + + return fieldNames; + } + + /** + * Functions intended to be used in the support of Sync. + */ + + /** + * Stores a hash of the last synced value for a field in a locally updated + * record. We use this value to rebuild the shared parent, or base, when + * reconciling incoming records that may have changed on another device. + * + * Storing the hash of the values that we last wrote to the Sync server lets + * us determine if a remote change conflicts with a local change. If the + * hashes for the base, current local value, and remote value all differ, we + * have a conflict. + * + * These fields are not themselves synced, and will be removed locally as + * soon as we have successfully written the record to the Sync server - so + * it is expected they will not remain for long, as changes which cause a + * last synced field to be written will itself cause a sync. + * + * We also skip this for updates made by Sync, for internal fields, for + * records that haven't been uploaded yet, and for fields which have already + * been changed since the last sync. + * + * @param {Object} record + * The updated local record. + * @param {string} field + * The field name. + * @param {string} lastSyncedValue + * The last synced field value. + */ + _maybeStoreLastSyncedField(record, field, lastSyncedValue) { + let sync = this._getSyncMetaData(record); + if (!sync) { + // The record hasn't been uploaded yet, so we can't end up with merge + // conflicts. + return; + } + let alreadyChanged = field in sync.lastSyncedFields; + if (alreadyChanged) { + // This field was already changed multiple times since the last sync. + return; + } + let newValue = record[field]; + if (lastSyncedValue != newValue) { + sync.lastSyncedFields[field] = sha512(lastSyncedValue); + } + } + + /** + * Attempts a three-way merge between a changed local record, an incoming + * remote record, and the shared parent that we synthesize from the last + * synced fields - see _maybeStoreLastSyncedField. + * + * @param {Object} strippedLocalRecord + * The changed local record, currently in storage. Computed fields + * are stripped. + * @param {Object} remoteRecord + * The remote record. + * @returns {Object|null} + * The merged record, or `null` if there are conflicts and the + * records can't be merged. + */ + _mergeSyncedRecords(strippedLocalRecord, remoteRecord) { + let sync = this._getSyncMetaData(strippedLocalRecord, true); + + // Copy all internal fields from the remote record. We'll update their + // values in `_replaceRecordAt`. + let mergedRecord = {}; + for (let field of INTERNAL_FIELDS) { + if (remoteRecord[field] != null) { + mergedRecord[field] = remoteRecord[field]; + } + } + + for (let field of this.VALID_FIELDS) { + let isLocalSame = false; + let isRemoteSame = false; + if (field in sync.lastSyncedFields) { + // If the field has changed since the last sync, compare hashes to + // determine if the local and remote values are different. Hashing is + // expensive, but we don't expect this to happen frequently. + let lastSyncedValue = sync.lastSyncedFields[field]; + isLocalSame = lastSyncedValue == sha512(strippedLocalRecord[field]); + isRemoteSame = lastSyncedValue == sha512(remoteRecord[field]); + } else { + // Otherwise, if the field hasn't changed since the last sync, we know + // it's the same locally. + isLocalSame = true; + isRemoteSame = strippedLocalRecord[field] == remoteRecord[field]; + } + + let value; + if (isLocalSame && isRemoteSame) { + // Local and remote are the same; doesn't matter which one we pick. + value = strippedLocalRecord[field]; + } else if (isLocalSame && !isRemoteSame) { + value = remoteRecord[field]; + } else if (!isLocalSame && isRemoteSame) { + // We don't need to bump the change counter when taking the local + // change, because the counter must already be > 0 if we're attempting + // a three-way merge. + value = strippedLocalRecord[field]; + } else if (strippedLocalRecord[field] == remoteRecord[field]) { + // Shared parent doesn't match either local or remote, but the values + // are identical, so there's no conflict. + value = strippedLocalRecord[field]; + } else { + // Both local and remote changed to different values. We'll need to fork + // the local record to resolve the conflict. + return null; + } + + if (value != null) { + mergedRecord[field] = value; + } + } + + return mergedRecord; + } + + /** + * Replaces a local record with a remote or merged record, copying internal + * fields and Sync metadata. + * + * @param {number} index + * @param {Object} remoteRecord + * @param {Promise<boolean>} [options.keepSyncMetadata = false] + * Should we copy Sync metadata? This is true if `remoteRecord` is a + * merged record with local changes that we need to upload. Passing + * `keepSyncMetadata` retains the record's change counter and + * last synced fields, so that we don't clobber the local change if + * the sync is interrupted after the record is merged, but before + * it's uploaded. + */ + async _replaceRecordAt( + index, + remoteRecord, + { keepSyncMetadata = false } = {} + ) { + let localRecord = this._data[index]; + let newRecord = this._clone(remoteRecord); + + await this._stripComputedFields(newRecord); + + this._data[index] = newRecord; + + if (keepSyncMetadata) { + // It's safe to move the Sync metadata from the old record to the new + // record, since we always clone records when we return them, and we + // never hand out references to the metadata object via public methods. + newRecord._sync = localRecord._sync; + } else { + // As a side effect, `_getSyncMetaData` marks the record as syncing if the + // existing `localRecord` is a dupe of `remoteRecord`, and we're replacing + // local with remote. + let sync = this._getSyncMetaData(newRecord, true); + sync.changeCounter = 0; + } + + if ( + !newRecord.timeCreated || + localRecord.timeCreated < newRecord.timeCreated + ) { + newRecord.timeCreated = localRecord.timeCreated; + } + + if ( + !newRecord.timeLastModified || + localRecord.timeLastModified > newRecord.timeLastModified + ) { + newRecord.timeLastModified = localRecord.timeLastModified; + } + + // Copy local-only fields from the existing local record. + for (let field of ["timeLastUsed", "timesUsed"]) { + if (localRecord[field] != null) { + newRecord[field] = localRecord[field]; + } + } + + await this.computeFields(newRecord); + } + + /** + * Clones a local record, giving the clone a new GUID and Sync metadata. The + * original record remains unchanged in storage. + * + * @param {Object} strippedLocalRecord + * The local record. Computed fields are stripped. + * @returns {string} + * A clone of the local record with a new GUID. + */ + async _forkLocalRecord(strippedLocalRecord) { + let forkedLocalRecord = this._cloneAndCleanUp(strippedLocalRecord); + forkedLocalRecord.guid = this._generateGUID(); + + // Give the record fresh Sync metadata and bump its change counter as a + // side effect. This also excludes the forked record from de-duping on the + // next sync, if the current sync is interrupted before the record can be + // uploaded. + this._getSyncMetaData(forkedLocalRecord, true); + + await this.computeFields(forkedLocalRecord); + this._data.push(forkedLocalRecord); + + return forkedLocalRecord; + } + + /** + * Reconciles an incoming remote record into the matching local record. This + * method is only used by Sync; other callers should use `merge`. + * + * @param {Object} remoteRecord + * The incoming record. `remoteRecord` must not be a tombstone, and + * must have a matching local record with the same GUID. Use + * `add` to insert remote records that don't exist locally, and + * `remove` to apply remote tombstones. + * @returns {Promise<Object>} + * A `{forkedGUID}` tuple. `forkedGUID` is `null` if the merge + * succeeded without conflicts, or a new GUID referencing the + * existing locally modified record if the conflicts could not be + * resolved. + */ + async reconcile(remoteRecord) { + this._ensureMatchingVersion(remoteRecord); + if (remoteRecord.deleted) { + throw new Error(`Can't reconcile tombstone ${remoteRecord.guid}`); + } + + let localIndex = this._findIndexByGUID(remoteRecord.guid); + if (localIndex < 0) { + throw new Error(`Record ${remoteRecord.guid} not found`); + } + + let localRecord = this._data[localIndex]; + let sync = this._getSyncMetaData(localRecord, true); + + let forkedGUID = null; + + if (sync.changeCounter === 0) { + // Local not modified. Replace local with remote. + await this._replaceRecordAt(localIndex, remoteRecord, { + keepSyncMetadata: false, + }); + } else { + let strippedLocalRecord = this._clone(localRecord); + await this._stripComputedFields(strippedLocalRecord); + + let mergedRecord = this._mergeSyncedRecords( + strippedLocalRecord, + remoteRecord + ); + if (mergedRecord) { + // Local and remote modified, but we were able to merge. Replace the + // local record with the merged record. + await this._replaceRecordAt(localIndex, mergedRecord, { + keepSyncMetadata: true, + }); + } else { + // Merge conflict. Fork the local record, then replace the original + // with the merged record. + let forkedLocalRecord = await this._forkLocalRecord( + strippedLocalRecord + ); + forkedGUID = forkedLocalRecord.guid; + await this._replaceRecordAt(localIndex, remoteRecord, { + keepSyncMetadata: false, + }); + } + } + + this._store.saveSoon(); + Services.obs.notifyObservers( + { + wrappedJSObject: { + sourceSync: true, + guid: remoteRecord.guid, + forkedGUID, + collectionName: this._collectionName, + }, + }, + "formautofill-storage-changed", + "reconcile" + ); + + return { forkedGUID }; + } + + _removeSyncedRecord(guid) { + let index = this._findIndexByGUID(guid, { includeDeleted: true }); + if (index == -1) { + // Removing a record we don't know about. It may have been synced and + // removed by another device before we saw it. Store the tombstone in + // case the server is later wiped and we need to reupload everything. + let tombstone = { + guid, + timeLastModified: Date.now(), + deleted: true, + }; + + let sync = this._getSyncMetaData(tombstone, true); + sync.changeCounter = 0; + this._data.push(tombstone); + return; + } + + let existing = this._data[index]; + let sync = this._getSyncMetaData(existing, true); + if (sync.changeCounter > 0) { + // Deleting a record with unsynced local changes. To avoid potential + // data loss, we ignore the deletion in favor of the changed record. + this.log.info( + "Ignoring deletion for record with local changes", + existing + ); + return; + } + + if (existing.deleted) { + this.log.info("Ignoring deletion for tombstone", existing); + return; + } + + // Removing a record that's not changed locally, and that's not already + // deleted. Replace the record with a synced tombstone. + this._data[index] = { + guid, + timeLastModified: Date.now(), + deleted: true, + _sync: sync, + }; + } + + /** + * Provide an object that describes the changes to sync. + * + * This is called at the start of the sync process to determine what needs + * to be updated on the server. As the server is updated, sync will update + * entries in the returned object, and when sync is complete it will pass + * the object to pushSyncChanges, which will apply the changes to the store. + * + * @returns {object} + * An object describing the changes to sync. + */ + pullSyncChanges() { + let changes = {}; + + let profiles = this._data; + for (let profile of profiles) { + let sync = this._getSyncMetaData(profile, true); + if (sync.changeCounter < 1) { + if (sync.changeCounter != 0) { + this.log.error("negative change counter", profile); + } + continue; + } + changes[profile.guid] = { + profile, + counter: sync.changeCounter, + modified: profile.timeLastModified, + synced: false, + }; + } + this._store.saveSoon(); + + return changes; + } + + /** + * Apply the metadata changes made by Sync. + * + * This is called with metadata about what was synced - see pullSyncChanges. + * + * @param {object} changes + * The possibly modified object obtained via pullSyncChanges. + */ + pushSyncChanges(changes) { + for (let [guid, { counter, synced }] of Object.entries(changes)) { + if (!synced) { + continue; + } + let recordFound = this._findByGUID(guid, { includeDeleted: true }); + if (!recordFound) { + this.log.warn("No profile found to persist changes for guid " + guid); + continue; + } + let sync = this._getSyncMetaData(recordFound, true); + sync.changeCounter = Math.max(0, sync.changeCounter - counter); + if (sync.changeCounter === 0) { + // Clear the shared parent fields once we've uploaded all pending + // changes, since the server now matches what we have locally. + sync.lastSyncedFields = {}; + } + } + this._store.saveSoon(); + } + + /** + * Reset all sync metadata for all items. + * + * This is called when Sync is disconnected from this device. All sync + * metadata for all items is removed. + */ + resetSync() { + for (let record of this._data) { + delete record._sync; + } + // XXX - we should probably also delete all tombstones? + this.log.info("All sync metadata was reset"); + } + + /** + * Changes the GUID of an item. This should be called only by Sync. There + * must be an existing record with oldID and it must never have been synced + * or an error will be thrown. There must be no existing record with newID. + * + * No tombstone will be created for the old GUID - we check it hasn't + * been synced, so no tombstone is necessary. + * + * @param {string} oldID + * GUID of the existing item to change the GUID of. + * @param {string} newID + * The new GUID for the item. + */ + changeGUID(oldID, newID) { + this.log.debug("changeGUID: ", oldID, newID); + if (oldID == newID) { + throw new Error("changeGUID: old and new IDs are the same"); + } + if (this._findIndexByGUID(newID) >= 0) { + throw new Error("changeGUID: record with destination id exists already"); + } + + let index = this._findIndexByGUID(oldID); + let profile = this._data[index]; + if (!profile) { + throw new Error("changeGUID: no source record"); + } + if (this._getSyncMetaData(profile)) { + throw new Error("changeGUID: existing record has already been synced"); + } + + profile.guid = newID; + + this._store.saveSoon(); + } + + // Used to get, and optionally create, sync metadata. Brand new records will + // *not* have sync meta-data - it will be created when they are first + // synced. + _getSyncMetaData(record, forceCreate = false) { + if (!record._sync && forceCreate) { + // create default metadata and indicate we need to save. + record._sync = { + changeCounter: 1, + lastSyncedFields: {}, + }; + this._store.saveSoon(); + } + return record._sync; + } + + /** + * Finds a local record with matching common fields and a different GUID. + * Sync uses this method to find and update unsynced local records with + * fields that match incoming remote records. This avoids creating + * duplicate profiles with the same information. + * + * @param {Object} remoteRecord + * The remote record. + * @returns {Promise<string|null>} + * The GUID of the matching local record, or `null` if no records + * match. + */ + async findDuplicateGUID(remoteRecord) { + if (!remoteRecord.guid) { + throw new Error("Record missing GUID"); + } + this._ensureMatchingVersion(remoteRecord); + if (remoteRecord.deleted) { + // Tombstones don't carry enough info to de-dupe, and we should have + // handled them separately when applying the record. + throw new Error("Tombstones can't have duplicates"); + } + let localRecords = this._data; + for (let localRecord of localRecords) { + if (localRecord.deleted) { + continue; + } + if (localRecord.guid == remoteRecord.guid) { + throw new Error(`Record ${remoteRecord.guid} already exists`); + } + if (this._getSyncMetaData(localRecord)) { + // This local record has already been uploaded, so it can't be a dupe of + // another incoming item. + continue; + } + + // Ignore computed fields when matching records as they aren't synced at all. + let strippedLocalRecord = this._clone(localRecord); + await this._stripComputedFields(strippedLocalRecord); + + let keys = new Set(Object.keys(remoteRecord)); + for (let key of Object.keys(strippedLocalRecord)) { + keys.add(key); + } + // Ignore internal fields when matching records. Internal fields are synced, + // but almost certainly have different values than the local record, and + // we'll update them in `reconcile`. + for (let field of INTERNAL_FIELDS) { + keys.delete(field); + } + if (!keys.size) { + // This shouldn't ever happen; a valid record will always have fields + // that aren't computed or internal. Sync can't do anything about that, + // so we ignore the dubious local record instead of throwing. + continue; + } + let same = true; + for (let key of keys) { + // For now, we ensure that both (or neither) records have the field + // with matching values. This doesn't account for the version yet + // (bug 1377204). + same = + key in strippedLocalRecord == key in remoteRecord && + strippedLocalRecord[key] == remoteRecord[key]; + if (!same) { + break; + } + } + if (same) { + return strippedLocalRecord.guid; + } + } + return null; + } + + /** + * Internal helper functions. + */ + + _clone(record) { + return Object.assign({}, record); + } + + _cloneAndCleanUp(record) { + let result = {}; + for (let key in record) { + // Do not expose hidden fields and fields with empty value (mainly used + // as placeholders of the computed fields). + if (!key.startsWith("_") && record[key] !== "") { + result[key] = record[key]; + } + } + return result; + } + + _findByGUID(guid, { includeDeleted = false } = {}) { + let found = this._findIndexByGUID(guid, { includeDeleted }); + return found < 0 ? undefined : this._data[found]; + } + + _findIndexByGUID(guid, { includeDeleted = false } = {}) { + return this._data.findIndex(record => { + return record.guid == guid && (!record.deleted || includeDeleted); + }); + } + + async _migrateRecord(record, index) { + let hasChanges = false; + + if (record.deleted) { + return hasChanges; + } + + if (!record.version || isNaN(record.version) || record.version < 1) { + this.log.warn("Invalid record version:", record.version); + + // Force to run the migration. + record.version = 0; + } + + if (record.version < this.version) { + hasChanges = true; + + record = await this._computeMigratedRecord(record); + + if (record.deleted) { + // record is deleted by _computeMigratedRecord(), + // go ahead and put it in the store. + this._data[index] = record; + return hasChanges; + } + + // Compute the computed fields before putting it to store. + await this.computeFields(record); + this._data[index] = record; + + return hasChanges; + } + + hasChanges |= await this.computeFields(record); + return hasChanges; + } + + _normalizeRecord(record, preserveEmptyFields = false) { + this._normalizeFields(record); + + for (let key in record) { + if (!this.VALID_FIELDS.includes(key)) { + throw new Error(`"${key}" is not a valid field.`); + } + if (typeof record[key] !== "string" && typeof record[key] !== "number") { + throw new Error( + `"${key}" contains invalid data type: ${typeof record[key]}` + ); + } + if (!preserveEmptyFields && record[key] === "") { + delete record[key]; + } + } + + if (!Object.keys(record).length) { + throw new Error("Record contains no valid field."); + } + } + + /** + * Merge the record if storage has multiple mergeable records. + * @param {Object} targetRecord + * The record for merge. + * @param {boolean} [strict = false] + * In strict merge mode, we'll treat the subset record with empty field + * as unable to be merged, but mergeable if in non-strict mode. + * @returns {Array.<string>} + * Return an array of the merged GUID string. + */ + async mergeToStorage(targetRecord, strict = false) { + let mergedGUIDs = []; + for (let record of this._data) { + if ( + !record.deleted && + (await this.mergeIfPossible(record.guid, targetRecord, strict)) + ) { + mergedGUIDs.push(record.guid); + } + } + this.log.debug( + "Existing records matching and merging count is", + mergedGUIDs.length + ); + return mergedGUIDs; + } + + /** + * Unconditionally remove all data and tombstones for this collection. + */ + removeAll({ sourceSync = false } = {}) { + this._store.data[this._collectionName] = []; + this._store.saveSoon(); + Services.obs.notifyObservers( + { + wrappedJSObject: { + sourceSync, + collectionName: this._collectionName, + }, + }, + "formautofill-storage-changed", + "removeAll" + ); + } + + /** + * Strip the computed fields based on the record version. + * @param {Object} record The record to migrate + * @returns {Object} Migrated record. + * Record is always cloned, with version updated, + * with computed fields stripped. + * Could be a tombstone record, if the record + * should be discorded. + */ + async _computeMigratedRecord(record) { + if (!record.deleted) { + record = this._clone(record); + await this._stripComputedFields(record); + record.version = this.version; + } + return record; + } + + async _stripComputedFields(record) { + this.VALID_COMPUTED_FIELDS.forEach(field => delete record[field]); + } + + // An interface to be inherited. + _recordReadProcessor(record) {} + + // An interface to be inherited. + async computeFields(record) {} + + /** + * An interface to be inherited to mutate the argument to normalize it. + * + * @param {object} partialRecord containing the record passed by the consumer of + * storage and in the case of `update` with + * `preserveOldProperties` will only include the + * properties that the user is changing so the + * lack of a field doesn't mean that the record + * won't have that field. + */ + _normalizeFields(partialRecord) {} + + /** + * An interface to be inherited to validate that the complete record is + * consistent and isn't missing required fields. Overrides should throw for + * invalid records. + * + * @param {object} record containing the complete record that would be stored + * if this doesn't throw due to an error. + * @throws + */ + _validateFields(record) {} + + // An interface to be inherited. + async mergeIfPossible(guid, record, strict) {} +} + +class Addresses extends AutofillRecords { + constructor(store) { + super( + store, + "addresses", + VALID_ADDRESS_FIELDS, + VALID_ADDRESS_COMPUTED_FIELDS, + ADDRESS_SCHEMA_VERSION + ); + } + + _recordReadProcessor(address) { + if (address.country && !FormAutofill.countries.has(address.country)) { + delete address.country; + delete address["country-name"]; + } + } + + async computeFields(address) { + // NOTE: Remember to bump the schema version number if any of the existing + // computing algorithm changes. (No need to bump when just adding new + // computed fields.) + + // NOTE: Computed fields should be always present in the storage no matter + // it's empty or not. + + let hasNewComputedFields = false; + + if (address.deleted) { + return hasNewComputedFields; + } + + // Compute name + if (!("name" in address)) { + let name = FormAutofillNameUtils.joinNameParts({ + given: address["given-name"], + middle: address["additional-name"], + family: address["family-name"], + }); + address.name = name; + hasNewComputedFields = true; + } + + // Compute address lines + if (!("address-line1" in address)) { + let streetAddress = []; + if (address["street-address"]) { + streetAddress = address["street-address"] + .split("\n") + .map(s => s.trim()); + } + for (let i = 0; i < 3; i++) { + address["address-line" + (i + 1)] = streetAddress[i] || ""; + } + if (streetAddress.length > 3) { + address["address-line3"] = FormAutofillUtils.toOneLineAddress( + streetAddress.splice(2) + ); + } + hasNewComputedFields = true; + } + + // Compute country name + if (!("country-name" in address)) { + if (address.country) { + try { + address[ + "country-name" + ] = Services.intl.getRegionDisplayNames(undefined, [address.country]); + } catch (e) { + address["country-name"] = ""; + } + } else { + address["country-name"] = ""; + } + hasNewComputedFields = true; + } + + // Compute tel + if (!("tel-national" in address)) { + if (address.tel) { + let tel = PhoneNumber.Parse( + address.tel, + address.country || FormAutofill.DEFAULT_REGION + ); + if (tel) { + if (tel.countryCode) { + address["tel-country-code"] = tel.countryCode; + } + if (tel.nationalNumber) { + address["tel-national"] = tel.nationalNumber; + } + + // PhoneNumberUtils doesn't support parsing the components of a telephone + // number so we hard coded the parser for US numbers only. We will need + // to figure out how to parse numbers from other regions when we support + // new countries in the future. + if (tel.nationalNumber && tel.countryCode == "+1") { + let telComponents = tel.nationalNumber.match( + /(\d{3})((\d{3})(\d{4}))$/ + ); + if (telComponents) { + address["tel-area-code"] = telComponents[1]; + address["tel-local"] = telComponents[2]; + address["tel-local-prefix"] = telComponents[3]; + address["tel-local-suffix"] = telComponents[4]; + } + } + } else { + // Treat "tel" as "tel-national" directly if it can't be parsed. + address["tel-national"] = address.tel; + } + } + + TEL_COMPONENTS.forEach(c => { + address[c] = address[c] || ""; + }); + } + + return hasNewComputedFields; + } + + _normalizeFields(address) { + this._normalizeName(address); + this._normalizeAddress(address); + this._normalizeCountry(address); + this._normalizeTel(address); + } + + _normalizeName(address) { + if (address.name) { + let nameParts = FormAutofillNameUtils.splitName(address.name); + if (!address["given-name"] && nameParts.given) { + address["given-name"] = nameParts.given; + } + if (!address["additional-name"] && nameParts.middle) { + address["additional-name"] = nameParts.middle; + } + if (!address["family-name"] && nameParts.family) { + address["family-name"] = nameParts.family; + } + } + delete address.name; + } + + _normalizeAddress(address) { + if (STREET_ADDRESS_COMPONENTS.some(c => !!address[c])) { + // Treat "street-address" as "address-line1" if it contains only one line + // and "address-line1" is omitted. + if ( + !address["address-line1"] && + address["street-address"] && + !address["street-address"].includes("\n") + ) { + address["address-line1"] = address["street-address"]; + delete address["street-address"]; + } + + // Concatenate "address-line*" if "street-address" is omitted. + if (!address["street-address"]) { + address["street-address"] = STREET_ADDRESS_COMPONENTS.map( + c => address[c] + ) + .join("\n") + .replace(/\n+$/, ""); + } + } + STREET_ADDRESS_COMPONENTS.forEach(c => delete address[c]); + } + + _normalizeCountry(address) { + let country; + + if (address.country) { + country = address.country.toUpperCase(); + } else if (address["country-name"]) { + country = FormAutofillUtils.identifyCountryCode(address["country-name"]); + } + + // Only values included in the region list will be saved. + let hasLocalizedName = false; + try { + if (country) { + let localizedName = Services.intl.getRegionDisplayNames(undefined, [ + country, + ]); + hasLocalizedName = localizedName != country; + } + } catch (e) {} + + if (country && hasLocalizedName) { + address.country = country; + } else { + delete address.country; + } + + delete address["country-name"]; + } + + _normalizeTel(address) { + if (address.tel || TEL_COMPONENTS.some(c => !!address[c])) { + FormAutofillUtils.compressTel(address); + + let possibleRegion = address.country || FormAutofill.DEFAULT_REGION; + let tel = PhoneNumber.Parse(address.tel, possibleRegion); + + if (tel && tel.internationalNumber) { + // Force to save numbers in E.164 format if parse success. + address.tel = tel.internationalNumber; + } + } + TEL_COMPONENTS.forEach(c => delete address[c]); + } + + /** + * Merge new address into the specified address if mergeable. + * + * @param {string} guid + * Indicates which address to merge. + * @param {Object} address + * The new address used to merge into the old one. + * @param {boolean} strict + * In strict merge mode, we'll treat the subset record with empty field + * as unable to be merged, but mergeable if in non-strict mode. + * @returns {Promise<boolean>} + * Return true if address is merged into target with specific guid or false if not. + */ + async mergeIfPossible(guid, address, strict) { + this.log.debug("mergeIfPossible:", guid, address); + + let addressFound = this._findByGUID(guid); + if (!addressFound) { + throw new Error("No matching address."); + } + + let addressToMerge = this._clone(address); + this._normalizeRecord(addressToMerge, strict); + let hasMatchingField = false; + + let country = + addressFound.country || + addressToMerge.country || + FormAutofill.DEFAULT_REGION; + let collators = FormAutofillUtils.getSearchCollators(country); + for (let field of this.VALID_FIELDS) { + let existingField = addressFound[field]; + let incomingField = addressToMerge[field]; + if (incomingField !== undefined && existingField !== undefined) { + if (incomingField != existingField) { + // Treat "street-address" as mergeable if their single-line versions + // match each other. + if ( + field == "street-address" && + FormAutofillUtils.compareStreetAddress( + existingField, + incomingField, + collators + ) + ) { + // Keep the street-address in storage if its amount of lines is greater than + // or equal to the incoming one. + if ( + existingField.split("\n").length >= + incomingField.split("\n").length + ) { + // Replace the incoming field with the one in storage so it will + // be further merged back to storage. + addressToMerge[field] = existingField; + } + } else if ( + field != "street-address" && + FormAutofillUtils.strCompare( + existingField, + incomingField, + collators + ) + ) { + addressToMerge[field] = existingField; + } else { + this.log.debug("Conflicts: field", field, "has different value."); + return false; + } + } + hasMatchingField = true; + } + } + + // We merge the address only when at least one field has the same value. + if (!hasMatchingField) { + this.log.debug("Unable to merge because no field has the same value"); + return false; + } + + // Early return if the data is the same or subset. + let noNeedToUpdate = this.VALID_FIELDS.every(field => { + // When addressFound doesn't contain a field, it's unnecessary to update + // if the same field in addressToMerge is omitted or an empty string. + if (addressFound[field] === undefined) { + return !addressToMerge[field]; + } + + // When addressFound contains a field, it's unnecessary to update if + // the same field in addressToMerge is omitted or a duplicate. + return ( + addressToMerge[field] === undefined || + addressFound[field] === addressToMerge[field] + ); + }); + if (noNeedToUpdate) { + return true; + } + + await this.update(guid, addressToMerge, true); + return true; + } +} + +class CreditCards extends AutofillRecords { + constructor(store) { + super( + store, + "creditCards", + VALID_CREDIT_CARD_FIELDS, + VALID_CREDIT_CARD_COMPUTED_FIELDS, + CREDIT_CARD_SCHEMA_VERSION + ); + Services.obs.addObserver(this, "formautofill-storage-changed"); + } + + observe(subject, topic, data) { + switch (topic) { + case "formautofill-storage-changed": + let count = this._data.filter(entry => !entry.deleted).length; + Services.telemetry.scalarSet( + "formautofill.creditCards.autofill_profiles_count", + count + ); + break; + } + } + + async computeFields(creditCard) { + // NOTE: Remember to bump the schema version number if any of the existing + // computing algorithm changes. (No need to bump when just adding new + // computed fields.) + + // NOTE: Computed fields should be always present in the storage no matter + // it's empty or not. + + let hasNewComputedFields = false; + + if (creditCard.deleted) { + return hasNewComputedFields; + } + + if ("cc-number" in creditCard && !("cc-type" in creditCard)) { + let type = CreditCard.getType(creditCard["cc-number"]); + if (type) { + creditCard["cc-type"] = type; + } + } + + // Compute split names + if (!("cc-given-name" in creditCard)) { + let nameParts = FormAutofillNameUtils.splitName(creditCard["cc-name"]); + creditCard["cc-given-name"] = nameParts.given; + creditCard["cc-additional-name"] = nameParts.middle; + creditCard["cc-family-name"] = nameParts.family; + hasNewComputedFields = true; + } + + // Compute credit card expiration date + if (!("cc-exp" in creditCard)) { + if (creditCard["cc-exp-month"] && creditCard["cc-exp-year"]) { + creditCard["cc-exp"] = + String(creditCard["cc-exp-year"]) + + "-" + + String(creditCard["cc-exp-month"]).padStart(2, "0"); + } else { + creditCard["cc-exp"] = ""; + } + hasNewComputedFields = true; + } + + // Encrypt credit card number + if (!("cc-number-encrypted" in creditCard)) { + if ("cc-number" in creditCard) { + let ccNumber = creditCard["cc-number"]; + if (CreditCard.isValidNumber(ccNumber)) { + creditCard["cc-number"] = CreditCard.getLongMaskedNumber(ccNumber); + } else { + // Credit card numbers can be entered on versions of Firefox that don't validate + // the number and then synced to this version of Firefox. Therefore, mask the + // full number if the number is invalid on this version. + creditCard["cc-number"] = "*".repeat(ccNumber.length); + } + creditCard["cc-number-encrypted"] = await OSKeyStore.encrypt(ccNumber); + } else { + creditCard["cc-number-encrypted"] = ""; + } + } + + return hasNewComputedFields; + } + + async _computeMigratedRecord(creditCard) { + if (creditCard["cc-number-encrypted"]) { + switch (creditCard.version) { + case 1: + case 2: { + // We cannot decrypt the data, so silently remove the record for + // the user. + if (creditCard.deleted) { + break; + } + + this.log.warn( + "Removing version", + creditCard.version, + "credit card record to migrate to new encryption:", + creditCard.guid + ); + + // Replace the record with a tombstone record here, + // regardless of existence of sync metadata. + let existingSync = this._getSyncMetaData(creditCard); + creditCard = { + guid: creditCard.guid, + timeLastModified: Date.now(), + deleted: true, + }; + + if (existingSync) { + creditCard._sync = existingSync; + existingSync.changeCounter++; + } + break; + } + + default: + throw new Error( + "Unknown credit card version to migrate: " + creditCard.version + ); + } + } + return super._computeMigratedRecord(creditCard); + } + + async _stripComputedFields(creditCard) { + if (creditCard["cc-number-encrypted"]) { + try { + creditCard["cc-number"] = await OSKeyStore.decrypt( + creditCard["cc-number-encrypted"] + ); + } catch (ex) { + if (ex.result == Cr.NS_ERROR_ABORT) { + throw ex; + } + // Quietly recover from encryption error, + // so existing credit card entry with undecryptable number + // can be updated. + } + } + await super._stripComputedFields(creditCard); + } + + _normalizeFields(creditCard) { + this._normalizeCCName(creditCard); + this._normalizeCCNumber(creditCard); + this._normalizeCCExpirationDate(creditCard); + } + + _normalizeCCName(creditCard) { + if ( + creditCard["cc-given-name"] || + creditCard["cc-additional-name"] || + creditCard["cc-family-name"] + ) { + if (!creditCard["cc-name"]) { + creditCard["cc-name"] = FormAutofillNameUtils.joinNameParts({ + given: creditCard["cc-given-name"], + middle: creditCard["cc-additional-name"], + family: creditCard["cc-family-name"], + }); + } + } + delete creditCard["cc-given-name"]; + delete creditCard["cc-additional-name"]; + delete creditCard["cc-family-name"]; + } + + _normalizeCCNumber(creditCard) { + if (!("cc-number" in creditCard)) { + return; + } + if (!CreditCard.isValidNumber(creditCard["cc-number"])) { + delete creditCard["cc-number"]; + return; + } + let card = new CreditCard({ number: creditCard["cc-number"] }); + creditCard["cc-number"] = card.number; + } + + _normalizeCCExpirationDate(creditCard) { + let normalizedExpiration = CreditCard.normalizeExpiration({ + expirationMonth: creditCard["cc-exp-month"], + expirationYear: creditCard["cc-exp-year"], + expirationString: creditCard["cc-exp"], + }); + if (normalizedExpiration.month) { + creditCard["cc-exp-month"] = normalizedExpiration.month; + } else { + delete creditCard["cc-exp-month"]; + } + if (normalizedExpiration.year) { + creditCard["cc-exp-year"] = normalizedExpiration.year; + } else { + delete creditCard["cc-exp-year"]; + } + delete creditCard["cc-exp"]; + } + + _validateFields(creditCard) { + if (!creditCard["cc-number"]) { + throw new Error("Missing/invalid cc-number"); + } + } + + _ensureMatchingVersion(record) { + if (!record.version || isNaN(record.version) || record.version < 1) { + throw new Error( + `Got invalid record version ${record.version}; want ${this.version}` + ); + } + + if (record.version < this.version) { + switch (record.version) { + case 1: + case 2: + // The difference between version 1 and 2 is only about the encryption + // method used for the cc-number-encrypted field. + // The difference between version 2 and 3 is the name of the OS + // key encryption record. + // As long as the record is already decrypted, it is safe to bump the + // version directly. + if (!record["cc-number-encrypted"]) { + record.version = this.version; + } else { + throw new Error( + "Could not migrate record version:", + record.version, + "->", + this.version + ); + } + break; + default: + throw new Error( + "Unknown credit card version to match: " + record.version + ); + } + } + + return super._ensureMatchingVersion(record); + } + + /** + * Normalize the given record and return the first matched guid if storage has the same record. + * @param {Object} targetCreditCard + * The credit card for duplication checking. + * @returns {Promise<string|null>} + * Return the first guid if storage has the same credit card and null otherwise. + */ + async getDuplicateGuid(targetCreditCard) { + let clonedTargetCreditCard = this._clone(targetCreditCard); + this._normalizeRecord(clonedTargetCreditCard); + if (!clonedTargetCreditCard["cc-number"]) { + return null; + } + + for (let creditCard of this._data) { + if (creditCard.deleted) { + continue; + } + + let decrypted = await OSKeyStore.decrypt( + creditCard["cc-number-encrypted"], + false + ); + + if (decrypted == clonedTargetCreditCard["cc-number"]) { + return creditCard.guid; + } + } + return null; + } + + /** + * Merge new credit card into the specified record if cc-number is identical. + * (Note that credit card records always do non-strict merge.) + * + * @param {string} guid + * Indicates which credit card to merge. + * @param {Object} creditCard + * The new credit card used to merge into the old one. + * @returns {boolean} + * Return true if credit card is merged into target with specific guid or false if not. + */ + async mergeIfPossible(guid, creditCard) { + this.log.debug("mergeIfPossible:", guid, creditCard); + + // Credit card number is required since it also must match. + if (!creditCard["cc-number"]) { + return false; + } + + // Query raw data for comparing the decrypted credit card number + let creditCardFound = await this.get(guid, { rawData: true }); + if (!creditCardFound) { + throw new Error("No matching credit card."); + } + + let creditCardToMerge = this._clone(creditCard); + this._normalizeRecord(creditCardToMerge); + + for (let field of this.VALID_FIELDS) { + let existingField = creditCardFound[field]; + + // Make sure credit card field is existed and have value + if ( + field == "cc-number" && + (!existingField || !creditCardToMerge[field]) + ) { + return false; + } + + if (!creditCardToMerge[field] && typeof existingField != "undefined") { + creditCardToMerge[field] = existingField; + } + + let incomingField = creditCardToMerge[field]; + if (incomingField && existingField) { + if (incomingField != existingField) { + this.log.debug("Conflicts: field", field, "has different value."); + return false; + } + } + } + + // Early return if the data is the same. + let exactlyMatch = this.VALID_FIELDS.every( + field => creditCardFound[field] === creditCardToMerge[field] + ); + if (exactlyMatch) { + return true; + } + + await this.update(guid, creditCardToMerge, true); + return true; + } + + updateUseCountTelemetry() { + let histogram = Services.telemetry.getHistogramById("CREDITCARD_NUM_USES"); + histogram.clear(); + + let records = this._data.filter(r => !r.deleted); + + for (let record of records) { + histogram.add(record.timesUsed); + } + } +} + +function FormAutofillStorage(path) { + this._path = path; + this._initializePromise = null; + this.INTERNAL_FIELDS = INTERNAL_FIELDS; +} + +FormAutofillStorage.prototype = { + get version() { + return STORAGE_SCHEMA_VERSION; + }, + + get addresses() { + if (!this._addresses) { + this._store.ensureDataReady(); + this._addresses = new Addresses(this._store); + } + return this._addresses; + }, + + get creditCards() { + if (!this._creditCards) { + this._store.ensureDataReady(); + this._creditCards = new CreditCards(this._store); + } + return this._creditCards; + }, + + /** + * Loads the profile data from file to memory. + * + * @returns {Promise} + * @resolves When the operation finished successfully. + * @rejects JavaScript exception. + */ + initialize() { + if (!this._initializePromise) { + this._store = new JSONFile({ + path: this._path, + dataPostProcessor: this._dataPostProcessor.bind(this), + }); + this._initializePromise = this._store.load().then(() => { + let initializeAutofillRecords = [this.addresses.initialize()]; + if (FormAutofill.isAutofillCreditCardsAvailable) { + initializeAutofillRecords.push(this.creditCards.initialize()); + } else { + // Make creditCards records unavailable to other modules + // because we never initialize it. + Object.defineProperty(this, "creditCards", { + get() { + throw new Error( + "CreditCards is not initialized. " + + "Please restart if you flip the pref manually." + ); + }, + }); + } + return Promise.all(initializeAutofillRecords); + }); + } + return this._initializePromise; + }, + + _dataPostProcessor(data) { + data.version = this.version; + if (!data.addresses) { + data.addresses = []; + } + if (!data.creditCards) { + data.creditCards = []; + } + return data; + }, + + // For test only. + _saveImmediately() { + return this._store._save(); + }, + + _finalize() { + return this._store.finalize(); + }, +}; + +// The singleton exposed by this module. +this.formAutofillStorage = new FormAutofillStorage( + OS.Path.join(OS.Constants.Path.profileDir, PROFILE_JSON_FILE_NAME) +); |