Bug 1547995 - Add option to Remote Settings get() to verify signatures on read r=glasserc

Differential Revision: https://phabricator.services.mozilla.com/D30357

--HG--
extra : moz-landing-system : lando
This commit is contained in:
Mathieu Leplatre 2019-05-09 16:38:57 +00:00
Родитель 62f3958c34
Коммит ef330e79c1
2 изменённых файлов: 109 добавлений и 80 удалений

Просмотреть файл

@ -68,43 +68,6 @@ class ClientEnvironment extends ClientEnvironmentBase {
}
}
/**
* Retrieve the Autograph signature information from the collection metadata.
*
* @param {String} bucket Bucket name.
* @param {String} collection Collection name.
* @param {int} expectedTimestamp Timestamp to be used for cache busting.
* @returns {Promise<{String, String}>}
*/
async function fetchCollectionSignature(bucket, collection, expectedTimestamp) {
const client = new KintoHttpClient(gServerURL);
const { signature: signaturePayload } = await client.bucket(bucket)
.collection(collection)
.getData({ query: { _expected: expectedTimestamp } });
if (!signaturePayload) {
throw new RemoteSettingsClient.MissingSignatureError(`${bucket}/${collection}`);
}
const { x5u, signature } = signaturePayload;
const certChainResponse = await fetch(x5u);
const certChain = await certChainResponse.text();
return { signature, certChain };
}
/**
* Retrieve the current list of remote records.
*
* @param {String} bucket Bucket name.
* @param {String} collection Collection name.
* @param {int} expectedTimestamp Timestamp to be used for cache busting.
*/
async function fetchRemoteRecords(bucket, collection, expectedTimestamp) {
const client = new KintoHttpClient(gServerURL);
return client.bucket(bucket)
.collection(collection)
.listRecords({ sort: "id", filters: { _expected: expectedTimestamp } });
}
/**
* Minimalist event emitter.
*
@ -188,6 +151,7 @@ class RemoteSettingsClient extends EventEmitter {
this.filterFunc = filterFunc;
this.localFields = localFields;
this._lastCheckTimePref = lastCheckTimePref;
this._verifier = null;
// This attribute allows signature verification to be disabled, when running tests
// or when pulling data from a dev server.
@ -229,10 +193,11 @@ class RemoteSettingsClient extends EventEmitter {
/**
* Lists settings.
*
* @param {Object} options The options object.
* @param {Object} options.filters Filter the results (default: `{}`).
* @param {Object} options.order The order to apply (eg. `"-last_modified"`).
* @param {Object} options.syncIfEmpty Synchronize from server if local data is empty (default: `true`).
* @param {Object} options The options object.
* @param {Object} options.filters Filter the results (default: `{}`).
* @param {String} options.order The order to apply (eg. `"-last_modified"`).
* @param {boolean} options.syncIfEmpty Synchronize from server if local data is empty (default: `true`).
* @param {boolean} options.verifySignature Verify the signature of the local data (default: `false`).
* @return {Promise}
*/
async get(options = {}) {
@ -241,6 +206,7 @@ class RemoteSettingsClient extends EventEmitter {
order = "", // not sorted by default.
syncIfEmpty = true,
} = options;
let { verifySignature = false } = options;
if (syncIfEmpty && !(await Utils.hasLocalData(this))) {
try {
@ -253,6 +219,8 @@ class RemoteSettingsClient extends EventEmitter {
// There is no JSON dump, force a synchronization from the server.
await this.sync({ loadDump: false });
}
// Either from trusted dump, or already done during sync.
verifySignature = false;
} catch (e) {
// Report but return an empty list since there will be no data anyway.
Cu.reportError(e);
@ -261,8 +229,20 @@ class RemoteSettingsClient extends EventEmitter {
}
// Read from the local DB.
const kintoCol = await this.openCollection();
const { data } = await kintoCol.list({ filters, order });
const kintoCollection = await this.openCollection();
const { data } = await kintoCollection.list({ filters, order });
// Verify signature of local data.
if (verifySignature) {
const localRecords = data.map(r => kintoCollection.cleanLocalFields(r));
const timestamp = await kintoCollection.db.getLastModified();
const metadata = await kintoCollection.metadata();
await this._validateCollectionSignature([],
timestamp,
metadata,
{ localRecords });
}
// Filter the records based on `this.filterFunc` results.
return this._filterEntries(data);
}
@ -336,10 +316,15 @@ class RemoteSettingsClient extends EventEmitter {
// for incoming changes that validates the signature.
if (this.verifySignature) {
kintoCollection.hooks["incoming-changes"] = [async (payload, collection) => {
await this._validateCollectionSignature(payload.changes,
payload.lastModified,
collection,
{ expectedTimestamp });
const { changes: remoteRecords, lastModified: timestamp } = payload;
const { data } = await kintoCollection.list({ order: "" }); // no need to sort.
const metadata = await collection.metadata();
// Local fields are stripped to compute the collection signature (server does not have them).
const localRecords = data.map(r => kintoCollection.cleanLocalFields(r));
await this._validateCollectionSignature(remoteRecords,
timestamp,
metadata,
{ localRecords });
// In case the signature is valid, apply the changes locally.
return payload;
}];
@ -433,37 +418,35 @@ class RemoteSettingsClient extends EventEmitter {
*
* @param {Array<Object>} remoteRecords The list of changes to apply to the local database.
* @param {int} timestamp The timestamp associated with the list of remote records.
* @param {Collection} kintoCollection Kinto.js Collection instance.
* @param {Object} metadata The collection metadata, that contains the signature payload.
* @param {Object} options
* @param {int} options.expectedTimestamp Cache busting of collection metadata
* @param {Boolean} options.ignoreLocal When the signature verification is retried, since we refetch
* the whole collection, we don't take into account the local
* data (default: `false`)
* @param {Array<Object>} options.localRecords List of additional local records to take into account (default: `[]`).
* @returns {Promise}
*/
async _validateCollectionSignature(remoteRecords, timestamp, kintoCollection, options = {}) {
const { expectedTimestamp, ignoreLocal = false } = options;
// this is a content-signature field from an autograph response.
const { name: collection, bucket } = kintoCollection;
const { signature, certChain } = await fetchCollectionSignature(bucket, collection, expectedTimestamp);
async _validateCollectionSignature(remoteRecords, timestamp, metadata, options = {}) {
const { localRecords = [] } = options;
let localRecords = [];
if (!ignoreLocal) {
const { data } = await kintoCollection.list({ order: "" }); // no need to sort.
// Local fields are stripped to compute the collection signature (server does not have them).
localRecords = data.map(r => kintoCollection.cleanLocalFields(r));
if (!metadata || !metadata.signature) {
throw new RemoteSettingsClient.MissingSignatureError(this.identifier);
}
if (!this._verifier) {
this._verifier = Cc["@mozilla.org/security/contentsignatureverifier;1"]
.createInstance(Ci.nsIContentSignatureVerifier);
}
// This is a content-signature field from an autograph response.
const { signature: { x5u, signature } } = metadata;
const certChain = await (await fetch(x5u)).text();
// Merge remote records with local ones and serialize as canonical JSON.
const serialized = await RemoteSettingsWorker.canonicalStringify(localRecords,
remoteRecords,
timestamp);
const verifier = Cc["@mozilla.org/security/contentsignatureverifier;1"]
.createInstance(Ci.nsIContentSignatureVerifier);
if (!await verifier.asyncVerifyContentSignature(serialized,
"p384ecdsa=" + signature,
certChain,
this.signerName)) {
throw new RemoteSettingsClient.InvalidSignatureError(`${bucket}/${collection}`);
if (!await this._verifier.asyncVerifyContentSignature(serialized,
"p384ecdsa=" + signature,
certChain,
this.signerName)) {
throw new RemoteSettingsClient.InvalidSignatureError(this.identifier);
}
}
@ -478,14 +461,23 @@ class RemoteSettingsClient extends EventEmitter {
* @returns {Promise<Object>} the computed sync result.
*/
async _retrySyncFromScratch(kintoCollection, expectedTimestamp) {
const payload = await fetchRemoteRecords(kintoCollection.bucket, kintoCollection.name, expectedTimestamp);
await this._validateCollectionSignature(payload.data,
payload.last_modified,
kintoCollection,
{ expectedTimestamp, ignoreLocal: true });
// Fetch collection metadata.
const api = new KintoHttpClient(gServerURL);
const client = await api.bucket(this.bucketName).collection(this.collectionName);
const metadata = await client.getData({ query: { _expected: expectedTimestamp }});
// Fetch whole list of records.
const {
data: remoteRecords,
last_modified: timestamp,
} = await client.listRecords({ sort: "id", filters: { _expected: expectedTimestamp } });
// Verify signature of remote content, before importing it locally.
await this._validateCollectionSignature(remoteRecords,
timestamp,
metadata);
// The signature is good (we haven't thrown).
// Now we will Inspect what we had locally.
// The signature of this remote content is good (we haven't thrown).
// Now we will store it locally. In order to replicate what `.sync()` returns
// we will inspect what we had locally.
const { data: oldData } = await kintoCollection.list({ order: "" }); // no need to sort.
// We build a sync result as if a diff-based sync was performed.
@ -494,14 +486,15 @@ class RemoteSettingsClient extends EventEmitter {
// If the remote last_modified is newer than the local last_modified,
// replace the local data
const localLastModified = await kintoCollection.db.getLastModified();
if (payload.last_modified >= localLastModified) {
const { data: newData } = payload;
if (timestamp >= localLastModified) {
await kintoCollection.clear();
await kintoCollection.loadDump(newData);
await kintoCollection.loadDump(remoteRecords);
await kintoCollection.db.saveLastModified(timestamp);
await kintoCollection.db.saveMetadata(metadata);
// Compare local and remote to populate the sync result
const oldById = new Map(oldData.map(e => [e.id, e]));
for (const r of newData) {
for (const r of remoteRecords) {
const old = oldById.get(r.id);
if (old) {
if (old.last_modified != r.last_modified) {

Просмотреть файл

@ -185,6 +185,42 @@ add_task(async function test_get_ignores_synchronization_errors() {
});
add_task(clear_state);
add_task(async function test_get_can_verify_signature() {
// No signature in metadata.
let error;
try {
await client.get({ verifySignature: true, syncIfEmpty: false });
} catch (e) {
error = e;
}
equal(error.message, "Missing signature (main/password-fields)");
// Populate the local DB (record and metadata)
await client.maybeSync(2000);
// It validates signature that was stored in local DB.
let calledSignature;
client._verifier = {
async asyncVerifyContentSignature(serialized, signature) {
calledSignature = signature;
return JSON.parse(serialized).data.length == 1;
},
};
await client.get({ verifySignature: true });
ok(calledSignature.endsWith("abcdef"));
// It throws when signature does not verify.
const col = await client.openCollection();
await col.delete("9d500963-d80e-3a91-6e74-66f3811b99cc");
try {
await client.get({ verifySignature: true });
} catch (e) {
error = e;
}
equal(error.message, "Invalid content signature (main/password-fields)");
});
add_task(clear_state);
add_task(async function test_sync_event_provides_information_about_records() {
let eventData;
client.on("sync", ({ data }) => eventData = data);