Skip to content
This repository has been archived by the owner on Apr 3, 2019. It is now read-only.

Backport ECDH key validation from private repo #1918

Merged
merged 5 commits into from
May 29, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
<a name="1.87.1"></a>
## [1.87.1](https://github.com/mozilla/fxa-auth-server/compare/v1.87.0...v1.87.1) (2017-05-26)


### Bug Fixes

* **push:** add extra logs ([5362c64](https://github.com/mozilla/fxa-auth-server/commit/5362c64))
* **push:** Validate push public keys at registration time. ([8920a01](https://github.com/mozilla/fxa-auth-server/commit/8920a01))



<a name="1.87.0"></a>
# [1.87.0](https://github.com/mozilla/fxa-auth-server/compare/v1.86.0...v1.87.0) (2017-05-17)

Expand Down
55 changes: 54 additions & 1 deletion lib/push.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
* 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/. */

var crypto = require('crypto')
var base64url = require('base64url')
var webpush = require('web-push')
var P = require('./promise')

Expand Down Expand Up @@ -169,7 +171,49 @@ module.exports = function (log, db, config) {
}, {})
}

/**
* Checks whether the given string is a valid public key for push.
* This is a little tricky because we need to work around a bug in nodejs
* where using an invalid ECDH key can cause a later (unrelated) attempt
* to generate an RSA signature to fail:
*
* https://github.com/nodejs/node/pull/13275
*
* @param key
* The public key as a b64url string.
*/

var dummySigner = crypto.createSign('RSA-SHA256')
var dummyKey = Buffer.alloc(0)
var dummyCurve = crypto.createECDH('prime256v1')
dummyCurve.generateKeys()

function isValidPublicKey(publicKey) {
// Try to use the key in an ECDH agreement.
// If the key is invalid then this will throw an error.
try {
dummyCurve.computeSecret(base64url.toBuffer(publicKey))
return true
} catch (err) {
log.info({
op: 'push.isValidPublicKey',
name: 'Bad public key detected'
})
// However! The above call might have left some junk
// sitting around on the openssl error stack.
// Clear it by deliberately triggering a signing error
// before anything yields the event loop.
try {
dummySigner.sign(dummyKey)
} catch (e) {}
return false
}
}

return {

isValidPublicKey: isValidPublicKey,

/**
* Notifies all devices that there was an update to the account
*
Expand Down Expand Up @@ -413,10 +457,19 @@ module.exports = function (log, db, config) {
incrementPushAction(events.success)
},
function (err) {
// If we've stored an invalid key in the db for some reason, then we
// might get an encryption failure here. Check the key, which also
// happens to work around bugginess in node's handling of said failures.
var keyWasInvalid = false
if (! err.statusCode && device.pushPublicKey) {
if (! isValidPublicKey(device.pushPublicKey)) {
keyWasInvalid = true
}
}
// 404 or 410 error from the push servers means
// the push settings need to be reset.
// the clients will check this and re-register push endpoints
if (err.statusCode === 404 || err.statusCode === 410) {
if (err.statusCode === 404 || err.statusCode === 410 || keyWasInvalid) {
// reset device push configuration
// Warning: this method is called without any session tokens or auth validation.
device.pushCallback = ''
Expand Down
7 changes: 7 additions & 0 deletions lib/routes/account.js
Original file line number Diff line number Diff line change
Expand Up @@ -1256,6 +1256,13 @@ module.exports = (
var payload = request.payload
var sessionToken = request.auth.credentials

// Some additional, slightly tricky validation to detect bad public keys.
if (payload.pushPublicKey) {
if (! push.isValidPublicKey(payload.pushPublicKey)) {
throw error.invalidRequestParameter('invalid pushPublicKey')
}
}

if (payload.id) {
// Don't write out the update if nothing has actually changed.
if (isSpuriousUpdate(payload, sessionToken)) {
Expand Down
2 changes: 1 addition & 1 deletion npm-shrinkwrap.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fxa-auth-server",
"version": "1.87.0",
"version": "1.87.1",
"description": "Firefox Accounts, an identity provider for Mozilla cloud services",
"bin": {
"fxa-auth": "./bin/key_server.js"
Expand Down
100 changes: 95 additions & 5 deletions test/local/push.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ var fs = require('fs')
var path = require('path')

const P = require(`${ROOT_DIR}/lib/promise`)
const mockLog = require('../mocks').mockLog
const mocks = require('../mocks')
const mockLog = mocks.mockLog
var mockUid = Buffer.from('foo')
var mockConfig = {}

Expand All @@ -36,7 +37,7 @@ var mockDevices = [
'name': 'My Phone',
'type': 'mobile',
'pushCallback': 'https://updates.push.services.mozilla.com/update/abcdef01234567890abcdefabcdef01234567890abcdef',
'pushPublicKey': 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEJXwAdITiPFcSUsaRI2nlzTNRn++q36F38XrH8m8sf28DQ+2Oob5SUzvgjVS0e70pIqH6bSXDgPc8mKtSs9Zi26Q==',
'pushPublicKey': mocks.MOCK_PUSH_KEY,
'pushAuthKey': 'w3b14Zjc-Afj2SDOLOyong=='
},
{
Expand All @@ -46,7 +47,7 @@ var mockDevices = [
'name': 'My Desktop',
'type': null,
'pushCallback': 'https://updates.push.services.mozilla.com/update/d4c5b1e3f5791ef83896c27519979b93a45e6d0da34c75',
'pushPublicKey': 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEJXwAdITiPFcSUsaRI2nlzTNRn++q36F38XrH8m8sf28DQ+2Oob5SUzvgjVS0e70pIqH6bSXDgPc8mKtSs9Zi26Q==',
'pushPublicKey': mocks.MOCK_PUSH_KEY,
'pushAuthKey': 'w3b14Zjc-Afj2SDOLOyong=='
}
]
Expand Down Expand Up @@ -401,6 +402,88 @@ describe('push', () => {
}
}

var push = proxyquire(pushModulePath, mocks)(thisMockLog, mockDb, mockConfig)
// Careful, the argument gets modified in-place.
var device = JSON.parse(JSON.stringify(mockDevices[0]))
return push.sendPush(mockUid, [device], 'accountVerify')
.then(() => {
assert.equal(count, 1)
})
}
)

it(
'push resets device push data when a failure is caused by bad encryption keys',
() => {
var mockDb = {
updateDevice: sinon.spy(function () {
return P.resolve()
})
}

let count = 0
var thisMockLog = mockLog({
info: function (log) {
if (log.name === 'push.account_verify.reset_settings') {
// web-push failed
assert.equal(mockDb.updateDevice.callCount, 1, 'db.updateDevice was called once')
var args = mockDb.updateDevice.args[0]
assert.equal(args.length, 3, 'db.updateDevice was passed three arguments')
assert.equal(args[1], null, 'sessionTokenId argument was null')
count++
}
}
})

var mocks = {
'web-push': {
sendNotification: function (sub, payload, options) {
var err = new Error('Failed')
return P.reject(err)
}
}
}

var push = proxyquire(pushModulePath, mocks)(thisMockLog, mockDb, mockConfig)
// Careful, the argument gets modified in-place.
var device = JSON.parse(JSON.stringify(mockDevices[0]))
device.pushPublicKey = 'E' + device.pushPublicKey.substring(1) // make the key invalid
return push.sendPush(mockUid, [device], 'accountVerify')
.then(() => {
assert.equal(count, 1)
})
}
)

it(
'push does not reset device push data after an unexpected failure',
() => {
var mockDb = {
updateDevice: sinon.spy(function () {
return P.resolve()
})
}

let count = 0
var thisMockLog = mockLog({
info: function (log) {
if (log.name === 'push.account_verify.failed') {
// web-push failed
assert.equal(mockDb.updateDevice.callCount, 0, 'db.updateDevice was not called')
count++
}
}
})

var mocks = {
'web-push': {
sendNotification: function (sub, payload, options) {
var err = new Error('Failed')
return P.reject(err)
}
}
}

var push = proxyquire(pushModulePath, mocks)(thisMockLog, mockDb, mockConfig)
return push.sendPush(mockUid, [mockDevices[0]], 'accountVerify')
.then(() => {
Expand Down Expand Up @@ -476,7 +559,14 @@ describe('push', () => {
it(
'notifyDeviceDisconnected calls pushToAllDevices',
() => {
var push = require(pushModulePath)(mockLog(), mockDbResult, mockConfig)
var mocks = {
'web-push': {
sendNotification: function (sub, payload, options) {
return P.resolve()
}
}
}
var push = proxyquire(pushModulePath, mocks)(mockLog(), mockDbResult, mockConfig)
sinon.spy(push, 'pushToAllDevices')
var idToDisconnect = mockDevices[0].id
var expectedData = {
Expand Down Expand Up @@ -649,7 +739,7 @@ describe('push', () => {
var push = proxyquire(pushModulePath, mocks)(thisMockLog, mockDbResult, mockConfig)
return push.sendPush(mockUid, mockDevices, 'accountVerify')
.then(() => {
assert.equal(count, 1)
assert.equal(count, 2)
})
}
)
Expand Down
2 changes: 1 addition & 1 deletion test/local/routes/account_devices.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ describe('/account/device', function () {
payload.name = 'my even awesomer device'
payload.type = 'phone'
payload.pushCallback = 'https://push.services.mozilla.com/123456'
payload.pushPublicKey = 'SomeEncodedBinaryStuffThatDoesntGetValidedByThisTest'
payload.pushPublicKey = mocks.MOCK_PUSH_KEY

return runTest(route, mockRequest, function (response) {
assert.equal(mockLog.increment.callCount, 5, 'the counters were incremented')
Expand Down
1 change: 1 addition & 0 deletions test/mocks.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ const PUSH_METHOD_NAMES = [
]

module.exports = {
MOCK_PUSH_KEY: 'BDLugiRzQCANNj5KI1fAqui8ELrE7qboxzfa5K_R0wnUoJ89xY1D_SOXI_QJKNmellykaW_7U2BZ7hnrPW3A3LM',
generateMetricsContext: generateMetricsContext,
mockBounces: mockObject(['check']),
mockCustoms: mockObject(CUSTOMS_METHOD_NAMES),
Expand Down
59 changes: 56 additions & 3 deletions test/remote/device_tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ var config = require('../../config').getProperties()
var crypto = require('crypto')
var base64url = require('base64url')
var P = require('../../lib/promise')
var mocks = require('../mocks')

describe('remote device', function() {
this.timeout(15000)
Expand Down Expand Up @@ -262,7 +263,7 @@ describe('remote device', function() {
name: 'test device',
type: 'desktop',
pushCallback: badPushCallback,
pushPublicKey: base64url(Buffer.concat([Buffer.from('\x04'), crypto.randomBytes(64)])),
pushPublicKey: mocks.MOCK_PUSH_KEY,
pushAuthKey: base64url(crypto.randomBytes(16))
}
return Client.create(config.publicUrl, email, password)
Expand Down Expand Up @@ -372,7 +373,7 @@ describe('remote device', function() {
name: 'test device',
type: 'desktop',
pushCallback: badPushCallback,
pushPublicKey: base64url(Buffer.concat([Buffer.from('\x04'), crypto.randomBytes(64)])),
pushPublicKey: mocks.MOCK_PUSH_KEY,
pushAuthKey: base64url(crypto.randomBytes(16))
}
return Client.create(config.publicUrl, email, password)
Expand Down Expand Up @@ -476,7 +477,7 @@ describe('remote device', function() {
name: 'test device',
type: 'desktop',
pushCallback: 'https://updates.push.services.mozilla.com/qux',
pushPublicKey: base64url(Buffer.concat([Buffer.from('\x04'), crypto.randomBytes(64)])),
pushPublicKey: mocks.MOCK_PUSH_KEY,
pushAuthKey: base64url(crypto.randomBytes(16))
}
return Client.create(config.publicUrl, email, password)
Expand Down Expand Up @@ -516,6 +517,58 @@ describe('remote device', function() {
}
)

it(
'invalid public keys are cleanly rejected',
() => {
var email = server.uniqueEmail()
var password = 'test password'
var invalidPublicKey = Buffer.alloc(65)
invalidPublicKey.fill('\0')
var deviceInfo = {
name: 'test device',
type: 'desktop',
pushCallback: 'https://updates.push.services.mozilla.com/qux',
pushPublicKey: base64url(invalidPublicKey),
pushAuthKey: base64url(crypto.randomBytes(16))
}
return Client.createAndVerify(config.publicUrl, email, password, server.mailbox)
.then(
function (client) {
return client.updateDevice(deviceInfo)
.then(
function () {
assert(false, 'request should have failed')
},
function (err) {
assert.equal(err.code, 400, 'err.code was 400')
assert.equal(err.errno, 107, 'err.errno was 107')
}
)
// A rather strange nodejs bug means that invalid push keys
// can cause a subsequent /certificate/sign to fail.
// Test that we've successfully mitigated that bug.
.then(
function () {
var publicKey = {
'algorithm': 'RS',
'n': '4759385967235610503571494339196749614544606692567785' +
'7909539347682027142806529730913413168629935827890798' +
'72007974809511698859885077002492642203267408776123',
'e': '65537'
}
return client.sign(publicKey, 1000 * 60 * 5)
}
)
.then(
function (cert) {
assert.equal(typeof(cert), 'string', 'cert was successfully signed')
}
)
}
)
}
)

after(() => {
return TestServer.stop(server)
})
Expand Down
2 changes: 1 addition & 1 deletion test/remote/password_forgot_tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ describe('remote password forgot', function() {
name: 'baz',
type: 'mobile',
pushCallback: 'https://updates.push.services.mozilla.com/qux',
pushPublicKey: base64url(Buffer.concat([Buffer.from('\x04'), crypto.randomBytes(64)])),
pushPublicKey: mocks.MOCK_PUSH_KEY,
pushAuthKey: base64url(crypto.randomBytes(16))
})
}
Expand Down