--- a/dom/push/PushComponents.js
+++ b/dom/push/PushComponents.js
@@ -87,16 +87,22 @@ PushServiceBase.prototype = {
_deliverSubscription(request, props) {
if (!props) {
request.onPushSubscription(Cr.NS_OK, null);
return;
}
request.onPushSubscription(Cr.NS_OK, new PushSubscription(props));
},
+
+ _deliverSubscriptionError(request, error) {
+ let result = typeof error.result == "number" ?
+ error.result : Cr.NS_ERROR_FAILURE;
+ request.onPushSubscription(result, null);
+ },
};
/**
* The parent process implementation of `nsIPushService`. This version loads
* `PushService.jsm` at startup and calls its methods directly. It also
* receives and responds to requests from the content process.
*/
function PushServiceParent() {
@@ -119,22 +125,27 @@ Object.assign(PushServiceParent.prototyp
"Push:NotificationForOriginShown",
"Push:NotificationForOriginClosed",
"Push:ReportError",
],
// nsIPushService methods
subscribe(scope, principal, callback) {
- return this._handleRequest("Push:Register", principal, {
+ this.subscribeWithKey(scope, principal, 0, null, callback);
+ },
+
+ subscribeWithKey(scope, principal, keyLen, key, callback) {
+ this._handleRequest("Push:Register", principal, {
scope: scope,
+ appServerKey: key,
}).then(result => {
this._deliverSubscription(callback, result);
}, error => {
- callback.onPushSubscription(Cr.NS_ERROR_FAILURE, null);
+ this._deliverSubscriptionError(callback, error);
}).catch(Cu.reportError);
},
unsubscribe(scope, principal, callback) {
this._handleRequest("Push:Unregister", principal, {
scope: scope,
}).then(result => {
callback.onUnsubscribe(Cr.NS_OK, result);
@@ -144,17 +155,17 @@ Object.assign(PushServiceParent.prototyp
},
getSubscription(scope, principal, callback) {
return this._handleRequest("Push:Registration", principal, {
scope: scope,
}).then(result => {
this._deliverSubscription(callback, result);
}, error => {
- callback.onPushSubscription(Cr.NS_ERROR_FAILURE, null);
+ this._deliverSubscriptionError(callback, error);
}).catch(Cu.reportError);
},
clearForDomain(domain, callback) {
return this._handleRequest("Push:Clear", null, {
domain: domain,
}).then(result => {
callback.onClear(Cr.NS_OK);
@@ -203,16 +214,17 @@ Object.assign(PushServiceParent.prototyp
return this._handleRequest(name, principal, data).then(result => {
sender.sendAsyncMessage(this._getResponseName(name, "OK"), {
requestID: data.requestID,
result: result
});
}, error => {
sender.sendAsyncMessage(this._getResponseName(name, "KO"), {
requestID: data.requestID,
+ result: error.result,
});
}).catch(Cu.reportError);
},
_handleReady() {
this.service.init();
},
@@ -320,19 +332,24 @@ Object.assign(PushServiceContent.prototy
"PushService:Unregister:KO",
"PushService:Clear:OK",
"PushService:Clear:KO",
],
// nsIPushService methods
subscribe(scope, principal, callback) {
+ this.subscribeWithKey(scope, principal, 0, null, callback);
+ },
+
+ subscribeWithKey(scope, principal, keyLen, key, callback) {
let requestId = this._addRequest(callback);
this._mm.sendAsyncMessage("Push:Register", {
scope: scope,
+ appServerKey: key,
requestID: requestId,
}, null, principal);
},
unsubscribe(scope, principal, callback) {
let requestId = this._addRequest(callback);
this._mm.sendAsyncMessage("Push:Unregister", {
scope: scope,
@@ -401,17 +418,17 @@ Object.assign(PushServiceContent.prototy
switch (name) {
case "PushService:Register:OK":
case "PushService:Registration:OK":
this._deliverSubscription(request, data.result);
break;
case "PushService:Register:KO":
case "PushService:Registration:KO":
- request.onPushSubscription(Cr.NS_ERROR_FAILURE, null);
+ this._deliverSubscriptionError(request, data);
break;
case "PushService:Unregister:OK":
if (typeof data.result === "boolean") {
request.onUnsubscribe(Cr.NS_OK, data.result);
} else {
request.onUnsubscribe(Cr.NS_ERROR_FAILURE, false);
}
@@ -483,21 +500,25 @@ PushSubscription.prototype = {
},
/**
* Returns a key for encrypting messages sent to this subscription. JS
* callers receive the key buffer as a return value, while C++ callers
* receive the key size and buffer as out parameters.
*/
getKey(name, outKeyLen) {
- if (name === "p256dh") {
- return this._getRawKey(this._props.p256dhKey, outKeyLen);
- }
- if (name === "auth") {
- return this._getRawKey(this._props.authenticationSecret, outKeyLen);
+ switch (name) {
+ case "p256dh":
+ return this._getRawKey(this._props.p256dhKey, outKeyLen);
+
+ case "auth":
+ return this._getRawKey(this._props.authenticationSecret, outKeyLen);
+
+ case "appServer":
+ return this._getRawKey(this._props.appServerKey, outKeyLen);
}
return null;
},
_getRawKey(key, outKeyLen) {
if (!key) {
return null;
}
--- a/dom/push/PushCrypto.jsm
+++ b/dom/push/PushCrypto.jsm
@@ -21,16 +21,17 @@ var AESGCM128_ENCRYPT_INFO = UTF8.encode
// New encryption scheme (draft-ietf-httpbis-encryption-encoding-01).
var AESGCM_ENCODING = 'aesgcm';
var AESGCM_ENCRYPT_INFO = UTF8.encode('Content-Encoding: aesgcm');
var NONCE_INFO = UTF8.encode('Content-Encoding: nonce');
var AUTH_INFO = UTF8.encode('Content-Encoding: auth\0'); // note nul-terminus
var P256DH_INFO = UTF8.encode('P-256\0');
var ECDH_KEY = { name: 'ECDH', namedCurve: 'P-256' };
+var ECDSA_KEY = { name: 'ECDSA', namedCurve: 'P-256' };
// A default keyid with a name that won't conflict with a real keyid.
var DEFAULT_KEYID = '';
function getEncryptionKeyParams(encryptKeyField) {
if (!encryptKeyField) {
return null;
}
var params = encryptKeyField.split(',');
@@ -169,16 +170,22 @@ function generateNonce(base, index) {
}
this.PushCrypto = {
generateAuthenticationSecret() {
return crypto.getRandomValues(new Uint8Array(16));
},
+ validateAppServerKey(key) {
+ return crypto.subtle.importKey('raw', key, ECDSA_KEY,
+ true, ['verify'])
+ .then(_ => key);
+ },
+
generateKeys() {
return crypto.subtle.generateKey(ECDH_KEY, true, ['deriveBits'])
.then(cryptoKey =>
Promise.all([
crypto.subtle.exportKey('raw', cryptoKey.publicKey),
crypto.subtle.exportKey('jwk', cryptoKey.privateKey)
]));
},
--- a/dom/push/PushRecord.jsm
+++ b/dom/push/PushRecord.jsm
@@ -37,16 +37,17 @@ function PushRecord(props) {
this.scope = props.scope;
this.originAttributes = props.originAttributes;
this.pushCount = props.pushCount || 0;
this.lastPush = props.lastPush || 0;
this.p256dhPublicKey = props.p256dhPublicKey;
this.p256dhPrivateKey = props.p256dhPrivateKey;
this.authenticationSecret = props.authenticationSecret;
this.systemRecord = !!props.systemRecord;
+ this.appServerKey = props.appServerKey;
this.setQuota(props.quota);
this.ctime = (typeof props.ctime === "number") ? props.ctime : 0;
}
PushRecord.prototype = {
setQuota(suggestedQuota) {
if (this.quotaApplies() && !isNaN(suggestedQuota) && suggestedQuota >= 0) {
this.quota = suggestedQuota;
@@ -226,23 +227,35 @@ PushRecord.prototype = {
this.principal.originAttributes, pattern);
},
hasAuthenticationSecret() {
return !!this.authenticationSecret &&
this.authenticationSecret.byteLength == 16;
},
+ matchesAppServerKey(key) {
+ if (!this.appServerKey) {
+ return !key;
+ }
+ if (!key) {
+ return false;
+ }
+ return this.appServerKey.length === key.length &&
+ this.appServerKey.every((value, index) => value === key[index]);
+ },
+
toSubscription() {
return {
endpoint: this.pushEndpoint,
lastPush: this.lastPush,
pushCount: this.pushCount,
p256dhKey: this.p256dhPublicKey,
authenticationSecret: this.authenticationSecret,
+ appServerKey: this.appServerKey,
quota: this.quotaApplies() ? this.quota : -1,
};
},
};
// Define lazy getters for the principal and scope URI. IndexedDB can't store
// `nsIPrincipal` objects, so we keep them in a private weak map.
var principals = new WeakMap();
--- a/dom/push/PushService.jsm
+++ b/dom/push/PushService.jsm
@@ -89,16 +89,27 @@ const kDROP_NOTIFICATION_REASON_EXPIRED
// This is for starting and stopping service.
const STARTING_SERVICE_EVENT = 0;
const CHANGING_SERVICE_EVENT = 1;
const STOPPING_SERVICE_EVENT = 2;
const UNINIT_EVENT = 3;
/**
+ * Annotates an error with an XPCOM result code. We use this helper
+ * instead of `Components.Exception` because the latter can assert in
+ * `nsXPCComponents_Exception::HasInstance` when inspected at shutdown.
+ */
+function errorWithResult(message, result = Cr.NS_ERROR_FAILURE) {
+ let error = new Error(message);
+ error.result = result;
+ return error;
+}
+
+/**
* The implementation of the push system. It uses WebSockets
* (PushServiceWebSocket) to communicate with the server and PushDB (IndexedDB)
* for persistence.
*/
this.PushService = {
_service: null,
_state: PUSH_SERVICE_UNINIT,
_db: null,
@@ -1087,33 +1098,55 @@ this.PushService = {
return this._checkActivated().then(_ =>
this._db.getByIdentifiers(pageRecord)
);
},
register: function(aPageRecord) {
console.debug("register()", aPageRecord);
- return this._getByPageRecord(aPageRecord)
- .then(record => {
- if (!record) {
- return this._lookupOrPutPendingRequest(aPageRecord);
- }
- if (record.isExpired()) {
- return record.quotaChanged().then(isChanged => {
- if (isChanged) {
- // If the user revisited the site, drop the expired push
- // registration and re-register.
- return this.dropRegistrationAndNotifyApp(record.keyID);
- }
- throw new Error("Push subscription expired");
- }).then(_ => this._lookupOrPutPendingRequest(aPageRecord));
- }
- return record.toSubscription();
- });
+ let keyPromise;
+ if (aPageRecord.appServerKey) {
+ let keyView = new Uint8Array(aPageRecord.appServerKey);
+ keyPromise = PushCrypto.validateAppServerKey(keyView)
+ .catch(error => {
+ // Normalize Web Crypto exceptions. `nsIPushService` will forward the
+ // error result to the DOM API implementation in `PushManager.cpp` or
+ // `Push.js`, which will convert it to the correct `DOMException`.
+ throw errorWithResult("Invalid app server key",
+ Cr.NS_ERROR_DOM_PUSH_INVALID_KEY_ERR);
+ });
+ } else {
+ keyPromise = Promise.resolve(null);
+ }
+
+ return Promise.all([
+ keyPromise,
+ this._getByPageRecord(aPageRecord),
+ ]).then(([appServerKey, record]) => {
+ aPageRecord.appServerKey = appServerKey;
+ if (!record) {
+ return this._lookupOrPutPendingRequest(aPageRecord);
+ }
+ if (!record.matchesAppServerKey(appServerKey)) {
+ throw errorWithResult("Mismatched app server key",
+ Cr.NS_ERROR_DOM_PUSH_MISMATCHED_KEY_ERR);
+ }
+ if (record.isExpired()) {
+ return record.quotaChanged().then(isChanged => {
+ if (isChanged) {
+ // If the user revisited the site, drop the expired push
+ // registration and re-register.
+ return this.dropRegistrationAndNotifyApp(record.keyID);
+ }
+ throw new Error("Push subscription expired");
+ }).then(_ => this._lookupOrPutPendingRequest(aPageRecord));
+ }
+ return record.toSubscription();
+ });
},
/**
* Called on message from the child process.
*
* Why is the record being deleted from the local database before the server
* is told?
*