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

Commit f2a3d15

Browse files
authored
Merge pull request #1918 from mozilla/public-87.1-backport
Backport ECDH key validation from private repo
2 parents f10655d + 0503479 commit f2a3d15

File tree

10 files changed

+228
-13
lines changed

10 files changed

+228
-13
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
<a name="1.87.1"></a>
2+
## [1.87.1](https://github.com/mozilla/fxa-auth-server/compare/v1.87.0...v1.87.1) (2017-05-26)
3+
4+
5+
### Bug Fixes
6+
7+
* **push:** add extra logs ([5362c64](https://github.com/mozilla/fxa-auth-server/commit/5362c64))
8+
* **push:** Validate push public keys at registration time. ([8920a01](https://github.com/mozilla/fxa-auth-server/commit/8920a01))
9+
10+
11+
112
<a name="1.87.0"></a>
213
# [1.87.0](https://github.com/mozilla/fxa-auth-server/compare/v1.86.0...v1.87.0) (2017-05-17)
314

lib/push.js

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
* License, v. 2.0. If a copy of the MPL was not distributed with this
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

5+
var crypto = require('crypto')
6+
var base64url = require('base64url')
57
var webpush = require('web-push')
68
var P = require('./promise')
79

@@ -169,7 +171,49 @@ module.exports = function (log, db, config) {
169171
}, {})
170172
}
171173

174+
/**
175+
* Checks whether the given string is a valid public key for push.
176+
* This is a little tricky because we need to work around a bug in nodejs
177+
* where using an invalid ECDH key can cause a later (unrelated) attempt
178+
* to generate an RSA signature to fail:
179+
*
180+
* https://github.com/nodejs/node/pull/13275
181+
*
182+
* @param key
183+
* The public key as a b64url string.
184+
*/
185+
186+
var dummySigner = crypto.createSign('RSA-SHA256')
187+
var dummyKey = Buffer.alloc(0)
188+
var dummyCurve = crypto.createECDH('prime256v1')
189+
dummyCurve.generateKeys()
190+
191+
function isValidPublicKey(publicKey) {
192+
// Try to use the key in an ECDH agreement.
193+
// If the key is invalid then this will throw an error.
194+
try {
195+
dummyCurve.computeSecret(base64url.toBuffer(publicKey))
196+
return true
197+
} catch (err) {
198+
log.info({
199+
op: 'push.isValidPublicKey',
200+
name: 'Bad public key detected'
201+
})
202+
// However! The above call might have left some junk
203+
// sitting around on the openssl error stack.
204+
// Clear it by deliberately triggering a signing error
205+
// before anything yields the event loop.
206+
try {
207+
dummySigner.sign(dummyKey)
208+
} catch (e) {}
209+
return false
210+
}
211+
}
212+
172213
return {
214+
215+
isValidPublicKey: isValidPublicKey,
216+
173217
/**
174218
* Notifies all devices that there was an update to the account
175219
*
@@ -413,10 +457,19 @@ module.exports = function (log, db, config) {
413457
incrementPushAction(events.success)
414458
},
415459
function (err) {
460+
// If we've stored an invalid key in the db for some reason, then we
461+
// might get an encryption failure here. Check the key, which also
462+
// happens to work around bugginess in node's handling of said failures.
463+
var keyWasInvalid = false
464+
if (! err.statusCode && device.pushPublicKey) {
465+
if (! isValidPublicKey(device.pushPublicKey)) {
466+
keyWasInvalid = true
467+
}
468+
}
416469
// 404 or 410 error from the push servers means
417470
// the push settings need to be reset.
418471
// the clients will check this and re-register push endpoints
419-
if (err.statusCode === 404 || err.statusCode === 410) {
472+
if (err.statusCode === 404 || err.statusCode === 410 || keyWasInvalid) {
420473
// reset device push configuration
421474
// Warning: this method is called without any session tokens or auth validation.
422475
device.pushCallback = ''

lib/routes/account.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1256,6 +1256,13 @@ module.exports = (
12561256
var payload = request.payload
12571257
var sessionToken = request.auth.credentials
12581258

1259+
// Some additional, slightly tricky validation to detect bad public keys.
1260+
if (payload.pushPublicKey) {
1261+
if (! push.isValidPublicKey(payload.pushPublicKey)) {
1262+
throw error.invalidRequestParameter('invalid pushPublicKey')
1263+
}
1264+
}
1265+
12591266
if (payload.id) {
12601267
// Don't write out the update if nothing has actually changed.
12611268
if (isSpuriousUpdate(payload, sessionToken)) {

npm-shrinkwrap.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "fxa-auth-server",
3-
"version": "1.87.0",
3+
"version": "1.87.1",
44
"description": "Firefox Accounts, an identity provider for Mozilla cloud services",
55
"bin": {
66
"fxa-auth": "./bin/key_server.js"

test/local/push.js

Lines changed: 95 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ var fs = require('fs')
1414
var path = require('path')
1515

1616
const P = require(`${ROOT_DIR}/lib/promise`)
17-
const mockLog = require('../mocks').mockLog
17+
const mocks = require('../mocks')
18+
const mockLog = mocks.mockLog
1819
var mockUid = Buffer.from('foo')
1920
var mockConfig = {}
2021

@@ -36,7 +37,7 @@ var mockDevices = [
3637
'name': 'My Phone',
3738
'type': 'mobile',
3839
'pushCallback': 'https://updates.push.services.mozilla.com/update/abcdef01234567890abcdefabcdef01234567890abcdef',
39-
'pushPublicKey': 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEJXwAdITiPFcSUsaRI2nlzTNRn++q36F38XrH8m8sf28DQ+2Oob5SUzvgjVS0e70pIqH6bSXDgPc8mKtSs9Zi26Q==',
40+
'pushPublicKey': mocks.MOCK_PUSH_KEY,
4041
'pushAuthKey': 'w3b14Zjc-Afj2SDOLOyong=='
4142
},
4243
{
@@ -46,7 +47,7 @@ var mockDevices = [
4647
'name': 'My Desktop',
4748
'type': null,
4849
'pushCallback': 'https://updates.push.services.mozilla.com/update/d4c5b1e3f5791ef83896c27519979b93a45e6d0da34c75',
49-
'pushPublicKey': 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEJXwAdITiPFcSUsaRI2nlzTNRn++q36F38XrH8m8sf28DQ+2Oob5SUzvgjVS0e70pIqH6bSXDgPc8mKtSs9Zi26Q==',
50+
'pushPublicKey': mocks.MOCK_PUSH_KEY,
5051
'pushAuthKey': 'w3b14Zjc-Afj2SDOLOyong=='
5152
}
5253
]
@@ -401,6 +402,88 @@ describe('push', () => {
401402
}
402403
}
403404

405+
var push = proxyquire(pushModulePath, mocks)(thisMockLog, mockDb, mockConfig)
406+
// Careful, the argument gets modified in-place.
407+
var device = JSON.parse(JSON.stringify(mockDevices[0]))
408+
return push.sendPush(mockUid, [device], 'accountVerify')
409+
.then(() => {
410+
assert.equal(count, 1)
411+
})
412+
}
413+
)
414+
415+
it(
416+
'push resets device push data when a failure is caused by bad encryption keys',
417+
() => {
418+
var mockDb = {
419+
updateDevice: sinon.spy(function () {
420+
return P.resolve()
421+
})
422+
}
423+
424+
let count = 0
425+
var thisMockLog = mockLog({
426+
info: function (log) {
427+
if (log.name === 'push.account_verify.reset_settings') {
428+
// web-push failed
429+
assert.equal(mockDb.updateDevice.callCount, 1, 'db.updateDevice was called once')
430+
var args = mockDb.updateDevice.args[0]
431+
assert.equal(args.length, 3, 'db.updateDevice was passed three arguments')
432+
assert.equal(args[1], null, 'sessionTokenId argument was null')
433+
count++
434+
}
435+
}
436+
})
437+
438+
var mocks = {
439+
'web-push': {
440+
sendNotification: function (sub, payload, options) {
441+
var err = new Error('Failed')
442+
return P.reject(err)
443+
}
444+
}
445+
}
446+
447+
var push = proxyquire(pushModulePath, mocks)(thisMockLog, mockDb, mockConfig)
448+
// Careful, the argument gets modified in-place.
449+
var device = JSON.parse(JSON.stringify(mockDevices[0]))
450+
device.pushPublicKey = 'E' + device.pushPublicKey.substring(1) // make the key invalid
451+
return push.sendPush(mockUid, [device], 'accountVerify')
452+
.then(() => {
453+
assert.equal(count, 1)
454+
})
455+
}
456+
)
457+
458+
it(
459+
'push does not reset device push data after an unexpected failure',
460+
() => {
461+
var mockDb = {
462+
updateDevice: sinon.spy(function () {
463+
return P.resolve()
464+
})
465+
}
466+
467+
let count = 0
468+
var thisMockLog = mockLog({
469+
info: function (log) {
470+
if (log.name === 'push.account_verify.failed') {
471+
// web-push failed
472+
assert.equal(mockDb.updateDevice.callCount, 0, 'db.updateDevice was not called')
473+
count++
474+
}
475+
}
476+
})
477+
478+
var mocks = {
479+
'web-push': {
480+
sendNotification: function (sub, payload, options) {
481+
var err = new Error('Failed')
482+
return P.reject(err)
483+
}
484+
}
485+
}
486+
404487
var push = proxyquire(pushModulePath, mocks)(thisMockLog, mockDb, mockConfig)
405488
return push.sendPush(mockUid, [mockDevices[0]], 'accountVerify')
406489
.then(() => {
@@ -476,7 +559,14 @@ describe('push', () => {
476559
it(
477560
'notifyDeviceDisconnected calls pushToAllDevices',
478561
() => {
479-
var push = require(pushModulePath)(mockLog(), mockDbResult, mockConfig)
562+
var mocks = {
563+
'web-push': {
564+
sendNotification: function (sub, payload, options) {
565+
return P.resolve()
566+
}
567+
}
568+
}
569+
var push = proxyquire(pushModulePath, mocks)(mockLog(), mockDbResult, mockConfig)
480570
sinon.spy(push, 'pushToAllDevices')
481571
var idToDisconnect = mockDevices[0].id
482572
var expectedData = {
@@ -649,7 +739,7 @@ describe('push', () => {
649739
var push = proxyquire(pushModulePath, mocks)(thisMockLog, mockDbResult, mockConfig)
650740
return push.sendPush(mockUid, mockDevices, 'accountVerify')
651741
.then(() => {
652-
assert.equal(count, 1)
742+
assert.equal(count, 2)
653743
})
654744
}
655745
)

test/local/routes/account_devices.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ describe('/account/device', function () {
122122
payload.name = 'my even awesomer device'
123123
payload.type = 'phone'
124124
payload.pushCallback = 'https://push.services.mozilla.com/123456'
125-
payload.pushPublicKey = 'SomeEncodedBinaryStuffThatDoesntGetValidedByThisTest'
125+
payload.pushPublicKey = mocks.MOCK_PUSH_KEY
126126

127127
return runTest(route, mockRequest, function (response) {
128128
assert.equal(mockLog.increment.callCount, 5, 'the counters were incremented')

test/mocks.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ const PUSH_METHOD_NAMES = [
115115
]
116116

117117
module.exports = {
118+
MOCK_PUSH_KEY: 'BDLugiRzQCANNj5KI1fAqui8ELrE7qboxzfa5K_R0wnUoJ89xY1D_SOXI_QJKNmellykaW_7U2BZ7hnrPW3A3LM',
118119
generateMetricsContext: generateMetricsContext,
119120
mockBounces: mockObject(['check']),
120121
mockCustoms: mockCustoms,

test/remote/device_tests.js

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ var config = require('../../config').getProperties()
1111
var crypto = require('crypto')
1212
var base64url = require('base64url')
1313
var P = require('../../lib/promise')
14+
var mocks = require('../mocks')
1415

1516
describe('remote device', function() {
1617
this.timeout(15000)
@@ -262,7 +263,7 @@ describe('remote device', function() {
262263
name: 'test device',
263264
type: 'desktop',
264265
pushCallback: badPushCallback,
265-
pushPublicKey: base64url(Buffer.concat([Buffer.from('\x04'), crypto.randomBytes(64)])),
266+
pushPublicKey: mocks.MOCK_PUSH_KEY,
266267
pushAuthKey: base64url(crypto.randomBytes(16))
267268
}
268269
return Client.create(config.publicUrl, email, password)
@@ -372,7 +373,7 @@ describe('remote device', function() {
372373
name: 'test device',
373374
type: 'desktop',
374375
pushCallback: badPushCallback,
375-
pushPublicKey: base64url(Buffer.concat([Buffer.from('\x04'), crypto.randomBytes(64)])),
376+
pushPublicKey: mocks.MOCK_PUSH_KEY,
376377
pushAuthKey: base64url(crypto.randomBytes(16))
377378
}
378379
return Client.create(config.publicUrl, email, password)
@@ -476,7 +477,7 @@ describe('remote device', function() {
476477
name: 'test device',
477478
type: 'desktop',
478479
pushCallback: 'https://updates.push.services.mozilla.com/qux',
479-
pushPublicKey: base64url(Buffer.concat([Buffer.from('\x04'), crypto.randomBytes(64)])),
480+
pushPublicKey: mocks.MOCK_PUSH_KEY,
480481
pushAuthKey: base64url(crypto.randomBytes(16))
481482
}
482483
return Client.create(config.publicUrl, email, password)
@@ -516,6 +517,58 @@ describe('remote device', function() {
516517
}
517518
)
518519

520+
it(
521+
'invalid public keys are cleanly rejected',
522+
() => {
523+
var email = server.uniqueEmail()
524+
var password = 'test password'
525+
var invalidPublicKey = Buffer.alloc(65)
526+
invalidPublicKey.fill('\0')
527+
var deviceInfo = {
528+
name: 'test device',
529+
type: 'desktop',
530+
pushCallback: 'https://updates.push.services.mozilla.com/qux',
531+
pushPublicKey: base64url(invalidPublicKey),
532+
pushAuthKey: base64url(crypto.randomBytes(16))
533+
}
534+
return Client.createAndVerify(config.publicUrl, email, password, server.mailbox)
535+
.then(
536+
function (client) {
537+
return client.updateDevice(deviceInfo)
538+
.then(
539+
function () {
540+
assert(false, 'request should have failed')
541+
},
542+
function (err) {
543+
assert.equal(err.code, 400, 'err.code was 400')
544+
assert.equal(err.errno, 107, 'err.errno was 107')
545+
}
546+
)
547+
// A rather strange nodejs bug means that invalid push keys
548+
// can cause a subsequent /certificate/sign to fail.
549+
// Test that we've successfully mitigated that bug.
550+
.then(
551+
function () {
552+
var publicKey = {
553+
'algorithm': 'RS',
554+
'n': '4759385967235610503571494339196749614544606692567785' +
555+
'7909539347682027142806529730913413168629935827890798' +
556+
'72007974809511698859885077002492642203267408776123',
557+
'e': '65537'
558+
}
559+
return client.sign(publicKey, 1000 * 60 * 5)
560+
}
561+
)
562+
.then(
563+
function (cert) {
564+
assert.equal(typeof(cert), 'string', 'cert was successfully signed')
565+
}
566+
)
567+
}
568+
)
569+
}
570+
)
571+
519572
after(() => {
520573
return TestServer.stop(server)
521574
})

test/remote/password_forgot_tests.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,7 @@ describe('remote password forgot', function() {
387387
name: 'baz',
388388
type: 'mobile',
389389
pushCallback: 'https://updates.push.services.mozilla.com/qux',
390-
pushPublicKey: base64url(Buffer.concat([Buffer.from('\x04'), crypto.randomBytes(64)])),
390+
pushPublicKey: mocks.MOCK_PUSH_KEY,
391391
pushAuthKey: base64url(crypto.randomBytes(16))
392392
})
393393
}

0 commit comments

Comments
 (0)