gecko/toolkit/components/url-classifier/content/url-crypto-key-manager.js

493 lines
16 KiB
JavaScript

# 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/.
// This file implements the tricky business of managing the keys for our
// URL encryption. The protocol is:
//
// - Server generates secret key K_S
// - Client starts up and requests a new key K_C from the server via HTTPS
// - Server generates K_C and WrappedKey, which is K_C encrypted with K_S
// - Server resonse with K_C and WrappedKey
// - When client wants to encrypt a URL, it encrypts it with K_C and sends
// the encrypted URL along with WrappedKey
// - Server decrypts WrappedKey with K_S to get K_C, and the URL with K_C
//
// This is, however, trickier than it sounds for two reasons. First,
// we want to keep the number of HTTPS requests to an aboslute minimum
// (like 1 or 2 per browser session). Second, the HTTPS request at
// startup might fail, for example the user might be offline or a URL
// fetch might need to be issued before the HTTPS request has
// completed.
//
// We implement the following policy:
//
// - Firefox will issue at most two HTTPS getkey requests per session
// - Firefox will issue one HTTPS getkey request at startup if more than 24
// hours has passed since the last getkey request.
// - Firefox will serialize to disk any key it gets
// - Firefox will fall back on this serialized key until it has a
// fresh key
// - The front-end can respond with a flag in a lookup request that tells
// the client to re-key. Firefox will issue a new HTTPS getkey request
// at this time if it has only issued one before
// We store the user key in this file. The key can be used to verify signed
// server updates.
const kKeyFilename = "urlclassifierkey3.txt";
/**
* A key manager for UrlCrypto. There should be exactly one of these
* per appplication, and all UrlCrypto's should share it. This is
* currently implemented by having the manager attach itself to the
* UrlCrypto's prototype at startup. We could've opted for a global
* instead, but I like this better, even though it is spooky action
* at a distance.
* XXX: Should be an XPCOM service
*
* @param opt_keyFilename String containing the name of the
* file we should serialize keys to/from. Used
* mostly for testing.
*
* @param opt_testing Boolean indicating whether we are testing. If we
* are, then we skip trying to read the old key from
* file and automatically trying to rekey; presumably
* the tester will drive these manually.
*
* @constructor
*/
function PROT_UrlCryptoKeyManager(opt_keyFilename, opt_testing) {
this.debugZone = "urlcryptokeymanager";
this.testing_ = !!opt_testing;
this.clientKey_ = null; // Base64-encoded, as fetched from server
this.clientKeyArray_ = null; // Base64-decoded into an array of numbers
this.wrappedKey_ = null; // Opaque websafe base64-encoded server key
this.rekeyTries_ = 0;
this.updating_ = false;
// Don't do anything until keyUrl_ is set.
this.keyUrl_ = null;
this.keyFilename_ = opt_keyFilename ?
opt_keyFilename : kKeyFilename;
this.onNewKey_ = null;
// Convenience properties
this.MAX_REKEY_TRIES = PROT_UrlCryptoKeyManager.MAX_REKEY_TRIES;
this.CLIENT_KEY_NAME = PROT_UrlCryptoKeyManager.CLIENT_KEY_NAME;
this.WRAPPED_KEY_NAME = PROT_UrlCryptoKeyManager.WRAPPED_KEY_NAME;
if (!this.testing_) {
this.maybeLoadOldKey();
}
}
// Do ***** NOT ***** set this higher; HTTPS is expensive
PROT_UrlCryptoKeyManager.MAX_REKEY_TRIES = 2;
// Base pref for keeping track of when we updated our key.
// We store the time as seconds since the epoch.
PROT_UrlCryptoKeyManager.NEXT_REKEY_PREF = "urlclassifier.keyupdatetime.";
// Once every 30 days (interval in seconds)
PROT_UrlCryptoKeyManager.KEY_MIN_UPDATE_TIME = 30 * 24 * 60 * 60;
// These are the names the server will respond with in protocol4 format
PROT_UrlCryptoKeyManager.CLIENT_KEY_NAME = "clientkey";
PROT_UrlCryptoKeyManager.WRAPPED_KEY_NAME = "wrappedkey";
/**
* Called to get ClientKey
* @returns urlsafe-base64-encoded client key or null if we haven't gotten one.
*/
PROT_UrlCryptoKeyManager.prototype.getClientKey = function() {
return this.clientKey_;
}
/**
* Called by a UrlCrypto to get the current K_C
*
* @returns Array of numbers making up the client key or null if we
* have no key
*/
PROT_UrlCryptoKeyManager.prototype.getClientKeyArray = function() {
return this.clientKeyArray_;
}
/**
* Called by a UrlCrypto to get WrappedKey
*
* @returns Opaque base64-encoded WrappedKey or null if we haven't
* gotten one
*/
PROT_UrlCryptoKeyManager.prototype.getWrappedKey = function() {
return this.wrappedKey_;
}
/**
* Change the key url. When we do this, we go ahead and rekey.
* @param keyUrl String
*/
PROT_UrlCryptoKeyManager.prototype.setKeyUrl = function(keyUrl) {
// If it's the same key url, do nothing.
if (keyUrl == this.keyUrl_)
return;
this.keyUrl_ = keyUrl;
this.rekeyTries_ = 0;
// Check to see if we should make a new getkey request.
var prefs = new G_Preferences(PROT_UrlCryptoKeyManager.NEXT_REKEY_PREF);
var nextRekey = prefs.getPref(this.getPrefName_(this.keyUrl_), 0);
if (nextRekey < parseInt(Date.now() / 1000, 10)) {
this.reKey();
}
}
/**
* Given a url, return the pref value to use (pref contains last update time).
* We basically use the url up until query parameters. This avoids duplicate
* pref entries as version number changes over time.
* @param url String getkey URL
*/
PROT_UrlCryptoKeyManager.prototype.getPrefName_ = function(url) {
var queryParam = url.indexOf("?");
if (queryParam != -1) {
return url.substring(0, queryParam);
}
return url;
}
/**
* Tell the manager to re-key. For safety, this method still obeys the
* max-tries limit. Clients should generally use maybeReKey() if they
* want to try a re-keying: it's an error to call reKey() after we've
* hit max-tries, but not an error to call maybeReKey().
*/
PROT_UrlCryptoKeyManager.prototype.reKey = function() {
if (this.updating_) {
G_Debug(this, "Already re-keying, ignoring this request");
return true;
}
if (this.rekeyTries_ > this.MAX_REKEY_TRIES)
throw new Error("Have already rekeyed " + this.rekeyTries_ + " times");
this.rekeyTries_++;
G_Debug(this, "Attempting to re-key");
// If the keyUrl isn't set, we don't do anything.
if (!this.testing_ && this.keyUrl_) {
this.fetcher_ = new PROT_XMLFetcher();
this.fetcher_.get(this.keyUrl_, BindToObject(this.onGetKeyResponse, this));
this.updating_ = true;
// Calculate the next time we're allowed to re-key.
var prefs = new G_Preferences(PROT_UrlCryptoKeyManager.NEXT_REKEY_PREF);
var nextRekey = parseInt(Date.now() / 1000, 10)
+ PROT_UrlCryptoKeyManager.KEY_MIN_UPDATE_TIME;
prefs.setPref(this.getPrefName_(this.keyUrl_), nextRekey);
}
}
/**
* Try to re-key if we haven't already hit our limit. It's OK to call
* this method multiple times, even if we've already tried to rekey
* more than the max. It will simply refuse to do so.
*
* @returns Boolean indicating if it actually issued a rekey request (that
* is, if we haven' already hit the max)
*/
PROT_UrlCryptoKeyManager.prototype.maybeReKey = function() {
if (this.rekeyTries_ > this.MAX_REKEY_TRIES) {
G_Debug(this, "Not re-keying; already at max");
return false;
}
this.reKey();
return true;
}
/**
* Drop the existing set of keys. Resets the rekeyTries variable to
* allow a rekey to succeed.
*/
PROT_UrlCryptoKeyManager.prototype.dropKey = function() {
this.rekeyTries_ = 0;
this.replaceKey_(null, null);
}
/**
* @returns Boolean indicating if we have a key we can use
*/
PROT_UrlCryptoKeyManager.prototype.hasKey = function() {
return this.clientKey_ != null && this.wrappedKey_ != null;
}
PROT_UrlCryptoKeyManager.prototype.unUrlSafe = function(key)
{
return key ? key.replace(/-/g, "+").replace(/_/g, "/") : "";
}
/**
* Set a new key and serialize it to disk.
*
* @param clientKey String containing the base64-encoded client key
* we wish to use
*
* @param wrappedKey String containing the opaque base64-encoded WrappedKey
* the server gave us (i.e., K_C encrypted with K_S)
*/
PROT_UrlCryptoKeyManager.prototype.replaceKey_ = function(clientKey,
wrappedKey) {
if (this.clientKey_)
G_Debug(this, "Replacing " + this.clientKey_ + " with " + clientKey);
this.clientKey_ = clientKey;
this.clientKeyArray_ = Array.map(atob(this.unUrlSafe(clientKey)),
function(c) { return c.charCodeAt(0); });
this.wrappedKey_ = wrappedKey;
this.serializeKey_(this.clientKey_, this.wrappedKey_);
if (this.onNewKey_) {
this.onNewKey_();
}
}
/**
* Try to write the key to disk so we can fall back on it. Fail
* silently if we cannot. The keys are serialized in protocol4 format.
*
* @returns Boolean indicating whether we succeeded in serializing
*/
PROT_UrlCryptoKeyManager.prototype.serializeKey_ = function() {
var map = {};
map[this.CLIENT_KEY_NAME] = this.clientKey_;
map[this.WRAPPED_KEY_NAME] = this.wrappedKey_;
try {
var keyfile = Cc["@mozilla.org/file/directory_service;1"]
.getService(Ci.nsIProperties)
.get("ProfD", Ci.nsILocalFile); /* profile directory */
keyfile.append(this.keyFilename_);
if (!this.clientKey_ || !this.wrappedKey_) {
keyfile.remove(true);
return;
}
var data = (new G_Protocol4Parser()).serialize(map);
try {
var stream = Cc["@mozilla.org/network/file-output-stream;1"]
.createInstance(Ci.nsIFileOutputStream);
stream.init(keyfile,
0x02 | 0x08 | 0x20 /* PR_WRONLY | PR_CREATE_FILE | PR_TRUNCATE */,
-1 /* default perms */, 0 /* no special behavior */);
stream.write(data, data.length);
} finally {
stream.close();
}
return true;
} catch(e) {
G_Error(this, "Failed to serialize new key: " + e);
return false;
}
}
/**
* Invoked when we've received a protocol4 response to our getkey
* request. Try to parse it and set this key as the new one if we can.
*
* @param responseText String containing the protocol4 getkey response
*/
PROT_UrlCryptoKeyManager.prototype.onGetKeyResponse = function(responseText) {
var response = (new G_Protocol4Parser).parse(responseText);
var clientKey = response[this.CLIENT_KEY_NAME];
var wrappedKey = response[this.WRAPPED_KEY_NAME];
this.updating_ = false;
this.fetcher_ = null;
if (response && clientKey && wrappedKey) {
G_Debug(this, "Got new key from: " + responseText);
this.replaceKey_(clientKey, wrappedKey);
} else {
G_Debug(this, "Not a valid response for /newkey");
}
}
/**
* Set the callback to be called whenever we get a new key.
*
* @param callback The callback.
*/
PROT_UrlCryptoKeyManager.prototype.onNewKey = function(callback)
{
this.onNewKey_ = callback;
}
/**
* Attempt to read a key we've previously serialized from disk, so
* that we can fall back on it in case we can't get one from the
* server. If we get a key, only use it if we don't already have one
* (i.e., if our startup HTTPS request died or hasn't yet completed).
*
* This method should be invoked early, like when the user's profile
* becomes available.
*/
PROT_UrlCryptoKeyManager.prototype.maybeLoadOldKey = function() {
var oldKey = null;
try {
var keyfile = Cc["@mozilla.org/file/directory_service;1"]
.getService(Ci.nsIProperties)
.get("ProfD", Ci.nsILocalFile); /* profile directory */
keyfile.append(this.keyFilename_);
if (keyfile.exists()) {
try {
var fis = Cc["@mozilla.org/network/file-input-stream;1"]
.createInstance(Ci.nsIFileInputStream);
fis.init(keyfile, 0x01 /* PR_RDONLY */, 0444, 0);
var stream = Cc["@mozilla.org/scriptableinputstream;1"]
.createInstance(Ci.nsIScriptableInputStream);
stream.init(fis);
oldKey = stream.read(stream.available());
} finally {
if (stream)
stream.close();
}
}
} catch(e) {
G_Debug(this, "Caught " + e + " trying to read keyfile");
return;
}
if (!oldKey) {
G_Debug(this, "Couldn't find old key.");
return;
}
oldKey = (new G_Protocol4Parser).parse(oldKey);
var clientKey = oldKey[this.CLIENT_KEY_NAME];
var wrappedKey = oldKey[this.WRAPPED_KEY_NAME];
if (oldKey && clientKey && wrappedKey && !this.hasKey()) {
G_Debug(this, "Read old key from disk.");
this.replaceKey_(clientKey, wrappedKey);
}
}
PROT_UrlCryptoKeyManager.prototype.shutdown = function() {
if (this.fetcher_) {
this.fetcher_.cancel();
this.fetcher_ = null;
}
}
#ifdef DEBUG
/**
* Cheesey tests
*/
function TEST_PROT_UrlCryptoKeyManager() {
if (G_GDEBUG) {
var z = "urlcryptokeymanager UNITTEST";
G_debugService.enableZone(z);
G_Debug(z, "Starting");
// Let's not clobber any real keyfile out there
var kf = "keytest.txt";
// Let's be able to clean up after ourselves
function removeTestFile(f) {
var file = Cc["@mozilla.org/file/directory_service;1"]
.getService(Ci.nsIProperties)
.get("ProfD", Ci.nsILocalFile); /* profile directory */
file.append(f);
if (file.exists())
file.remove(false /* do not recurse */);
};
removeTestFile(kf);
var km = new PROT_UrlCryptoKeyManager(kf, true /* testing */);
// CASE: simulate nothing on disk, then get something from server
G_Assert(z, !km.hasKey(), "KM already has key?");
km.maybeLoadOldKey();
G_Assert(z, !km.hasKey(), "KM loaded nonexistent key?");
km.onGetKeyResponse(null);
G_Assert(z, !km.hasKey(), "KM got key from null response?");
km.onGetKeyResponse("");
G_Assert(z, !km.hasKey(), "KM got key from empty response?");
km.onGetKeyResponse("aslkaslkdf:34:a230\nskdjfaljsie");
G_Assert(z, !km.hasKey(), "KM got key from garbage response?");
var realResponse = "clientkey:24:zGbaDbx1pxoYe7siZYi8VA==\n" +
"wrappedkey:24:MTr1oDt6TSOFQDTvKCWz9PEn";
km.onGetKeyResponse(realResponse);
// Will have written it to file as a side effect
G_Assert(z, km.hasKey(), "KM couldn't get key from real response?");
G_Assert(z, km.clientKey_ == "zGbaDbx1pxoYe7siZYi8VA==",
"Parsed wrong client key from response?");
G_Assert(z, km.wrappedKey_ == "MTr1oDt6TSOFQDTvKCWz9PEn",
"Parsed wrong wrapped key from response?");
// CASE: simulate something on disk, then get something from server
km = new PROT_UrlCryptoKeyManager(kf, true /* testing */);
G_Assert(z, !km.hasKey(), "KM already has key?");
km.maybeLoadOldKey();
G_Assert(z, km.hasKey(), "KM couldn't load existing key from disk?");
G_Assert(z, km.clientKey_ == "zGbaDbx1pxoYe7siZYi8VA==",
"Parsed wrong client key from disk?");
G_Assert(z, km.wrappedKey_ == "MTr1oDt6TSOFQDTvKCWz9PEn",
"Parsed wrong wrapped key from disk?");
var realResponse2 = "clientkey:24:dtmbEN1kgN/LmuEoYifaFw==\n" +
"wrappedkey:24:MTpPH3pnLDKihecOci+0W5dk";
km.onGetKeyResponse(realResponse2);
// Will have written it to disk
G_Assert(z, km.hasKey(), "KM couldn't replace key from server response?");
G_Assert(z, km.clientKey_ == "dtmbEN1kgN/LmuEoYifaFw==",
"Replace client key from server failed?");
G_Assert(z, km.wrappedKey == "MTpPH3pnLDKihecOci+0W5dk",
"Replace wrapped key from server failed?");
// CASE: check overwriting a key on disk
km = new PROT_UrlCryptoKeyManager(kf, true /* testing */);
G_Assert(z, !km.hasKey(), "KM already has key?");
km.maybeLoadOldKey();
G_Assert(z, km.hasKey(), "KM couldn't load existing key from disk?");
G_Assert(z, km.clientKey_ == "dtmbEN1kgN/LmuEoYifaFw==",
"Replace client on from disk failed?");
G_Assert(z, km.wrappedKey_ == "MTpPH3pnLDKihecOci+0W5dk",
"Replace wrapped key on disk failed?");
// Test that we only fetch at most two getkey's per lifetime of the manager
km = new PROT_UrlCryptoKeyManager(kf, true /* testing */);
km.reKey();
for (var i = 0; i < km.MAX_REKEY_TRIES; i++)
G_Assert(z, km.maybeReKey(), "Couldn't rekey?");
G_Assert(z, !km.maybeReKey(), "Rekeyed when max hit");
removeTestFile(kf);
G_Debug(z, "PASSED");
}
}
#endif