test_remote_settings_signatures.js (29554B)
1 /* import-globals-from ../../../common/tests/unit/head_helpers.js */ 2 "use strict"; 3 4 const PREF_SETTINGS_SERVER = "services.settings.server"; 5 const SIGNER_NAME = "onecrl.content-signature.mozilla.org"; 6 const TELEMETRY_COMPONENT = "remotesettings"; 7 8 const CERT_DIR = "test_remote_settings_signatures/"; 9 const CHAIN_FILES = ["collection_signing_ee.pem", "collection_signing_int.pem"]; 10 11 function getFileData(file) { 12 const stream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance( 13 Ci.nsIFileInputStream 14 ); 15 stream.init(file, -1, 0, 0); 16 const data = NetUtil.readInputStreamToString(stream, stream.available()); 17 stream.close(); 18 return data; 19 } 20 21 function getCertChain() { 22 const chain = []; 23 for (let file of CHAIN_FILES) { 24 chain.push(getFileData(do_get_file(CERT_DIR + file))); 25 } 26 return chain.join("\n"); 27 } 28 29 let server; 30 let client; 31 32 add_setup(() => { 33 // Signature verification is enabled by default. We use a custom signer 34 // because these tests were originally written for OneCRL. 35 client = RemoteSettings("signed", { signerName: SIGNER_NAME }); 36 37 Services.prefs.setStringPref("services.settings.loglevel", "debug"); 38 39 // Set up an HTTP Server 40 server = new HttpServer(); 41 server.start(-1); 42 43 registerCleanupFunction(() => { 44 Services.prefs.clearUserPref("services.settings.loglevel"); 45 Services.prefs.clearUserPref(PREF_SETTINGS_SERVER); 46 server.stop(() => {}); 47 }); 48 }); 49 50 add_task(async function test_check_signatures() { 51 // First, perform a signature verification with known data and signature 52 // to ensure things are working correctly 53 let verifier = Cc[ 54 "@mozilla.org/security/contentsignatureverifier;1" 55 ].createInstance(Ci.nsIContentSignatureVerifier); 56 57 const emptyData = "[]"; 58 const emptySignature = 59 "p384ecdsa=zbugm2FDitsHwk5-IWsas1PpWwY29f0Fg5ZHeqD8fzep7AVl2vfcaHA7LdmCZ28qZLOioGKvco3qT117Q4-HlqFTJM7COHzxGyU2MMJ0ZTnhJrPOC1fP3cVQjU1PTWi9"; 60 61 ok( 62 await verifier.asyncVerifyContentSignature( 63 emptyData, 64 emptySignature, 65 getCertChain(), 66 SIGNER_NAME, 67 Ci.nsIX509CertDB.AppXPCShellRoot 68 ) 69 ); 70 71 const collectionData = 72 '[{"details":{"bug":"https://bugzilla.mozilla.org/show_bug.cgi?id=1155145","created":"2016-01-18T14:43:37Z","name":"GlobalSign certs","who":".","why":"."},"enabled":true,"id":"97fbf7c4-3ef2-f54f-0029-1ba6540c63ea","issuerName":"MHExKDAmBgNVBAMTH0dsb2JhbFNpZ24gUm9vdFNpZ24gUGFydG5lcnMgQ0ExHTAbBgNVBAsTFFJvb3RTaWduIFBhcnRuZXJzIENBMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMQswCQYDVQQGEwJCRQ==","last_modified":2000,"serialNumber":"BAAAAAABA/A35EU="},{"details":{"bug":"https://bugzilla.mozilla.org/show_bug.cgi?id=1155145","created":"2016-01-18T14:48:11Z","name":"GlobalSign certs","who":".","why":"."},"enabled":true,"id":"e3bd531e-1ee4-7407-27ce-6fdc9cecbbdc","issuerName":"MIGBMQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTElMCMGA1UECxMcUHJpbWFyeSBPYmplY3QgUHVibGlzaGluZyBDQTEwMC4GA1UEAxMnR2xvYmFsU2lnbiBQcmltYXJ5IE9iamVjdCBQdWJsaXNoaW5nIENB","last_modified":3000,"serialNumber":"BAAAAAABI54PryQ="}]'; 73 const collectionSignature = 74 "p384ecdsa=f4pA2tYM5jQgWY6YUmhUwQiBLj6QO5sHLD_5MqLePz95qv-7cNCuQoZnPQwxoptDtW8hcWH3kLb0quR7SB-r82gkpR9POVofsnWJRA-ETb0BcIz6VvI3pDT49ZLlNg3p"; 75 76 ok( 77 await verifier.asyncVerifyContentSignature( 78 collectionData, 79 collectionSignature, 80 getCertChain(), 81 SIGNER_NAME, 82 Ci.nsIX509CertDB.AppXPCShellRoot 83 ) 84 ); 85 }); 86 87 add_task(async function test_bad_signature_does_not_lead_to_empty_list() { 88 Services.prefs.setStringPref( 89 PREF_SETTINGS_SERVER, 90 `http://localhost:${server.identity.primaryPort}/v1` 91 ); 92 const x5u = `http://localhost:${server.identity.primaryPort}/x5u.pem`; 93 94 const networkCalls = []; 95 96 server.registerPathHandler( 97 "/v1/buckets/monitor/collections/changes/changeset", 98 (request, response) => { 99 response.write( 100 JSON.stringify({ 101 changes: [ 102 { 103 bucket: "main", 104 collection: "no-dump-no-local-data", 105 last_modified: 42, 106 }, 107 ], 108 }) 109 ); 110 response.setHeader("Content-Type", "application/json; charset=UTF-8"); 111 response.setStatusLine(null, 200, "OK"); 112 } 113 ); 114 server.registerPathHandler( 115 "/v1/buckets/monitor/collections/changes/changeset", 116 (request, response) => { 117 networkCalls.push(request); 118 response.write( 119 JSON.stringify({ 120 changes: [ 121 { 122 bucket: "main", 123 collection: "no-dump-no-local-data", 124 last_modified: 42, 125 }, 126 ], 127 }) 128 ); 129 response.setHeader("Content-Type", "application/json; charset=UTF-8"); 130 response.setStatusLine(null, 200, "OK"); 131 } 132 ); 133 server.registerPathHandler( 134 "/v1/buckets/main/collections/no-dump-no-local-data/changeset", 135 (request, response) => { 136 response.write( 137 JSON.stringify({ 138 timestamp: 42, 139 changes: [], 140 metadata: { 141 signatures: [ 142 { 143 signature: "bad-signature", 144 x5u, 145 }, 146 ], 147 }, 148 }) 149 ); 150 response.setHeader("Content-Type", "application/json; charset=UTF-8"); 151 response.setStatusLine(null, 200, "OK"); 152 } 153 ); 154 server.registerPathHandler("/x5u.pem", (request, response) => { 155 response.write(getCertChain()); // At least cert will be valid. 156 response.setHeader("Content-Type", "text/plain; charset=UTF-8"); 157 response.setStatusLine(null, 200, "OK"); 158 }); 159 160 const clientEmpty = RemoteSettings("no-dump-no-local-data"); 161 clientEmpty.verifySignature = true; // default 162 163 // Check that client.get() will initiate a sync, 164 // and that it will throw since the signature is bad, 165 // and not return an empty list (`emptyListFallback: false`) 166 let error; 167 try { 168 await clientEmpty.get({ 169 emptyListFallback: false, 170 syncIfEmpty: true, // default value 171 }); 172 } catch (exc) { 173 error = exc; 174 } 175 equal(error.name, "InvalidSignatureError"); 176 177 // Even running client.sync() will throw and won't leave 178 // anything in the database. 179 error = null; 180 try { 181 await clientEmpty.sync(); 182 } catch (exc) { 183 error = exc; 184 } 185 equal(error.name, "InvalidSignatureError"); 186 equal(await clientEmpty.db.getLastModified(), null); 187 188 // Call .get() again will initiate another sync. 189 networkCalls.length = 0; 190 try { 191 await clientEmpty.get({ 192 emptyListFallback: false, 193 syncIfEmpty: true, // default value 194 }); 195 } catch (exc) { 196 error = exc; 197 } 198 Assert.greater(networkCalls.length, 0, "Network calls were made"); 199 equal(error.name, "InvalidSignatureError"); 200 }); 201 202 add_task(async function test_check_synchronization_with_signatures() { 203 const port = server.identity.primaryPort; 204 205 const x5u = `http://localhost:${port}/test_remote_settings_signatures/test_cert_chain.pem`; 206 207 // Telemetry reports. 208 const TELEMETRY_SOURCE = client.identifier; 209 210 function registerHandlers(responses) { 211 function handleResponse(serverTimeMillis, request, response) { 212 const key = `${request.method}:${request.path}?${request.queryString}`; 213 const available = responses[key]; 214 const sampled = available.length > 1 ? available.shift() : available[0]; 215 if (!sampled) { 216 do_throw( 217 `unexpected ${request.method} request for ${request.path}?${request.queryString}` 218 ); 219 } 220 221 response.setStatusLine( 222 null, 223 sampled.status.status, 224 sampled.status.statusText 225 ); 226 // send the headers 227 for (let headerLine of sampled.sampleHeaders) { 228 let headerElements = headerLine.split(":"); 229 response.setHeader(headerElements[0], headerElements[1].trimLeft()); 230 } 231 232 // set the server date 233 response.setHeader("Date", new Date(serverTimeMillis).toUTCString()); 234 235 response.write(sampled.responseBody); 236 } 237 238 for (let key of Object.keys(responses)) { 239 const keyParts = key.split(":"); 240 const valueParts = keyParts[1].split("?"); 241 const path = valueParts[0]; 242 243 server.registerPathHandler(path, handleResponse.bind(null, 2000)); 244 } 245 } 246 247 // set up prefs so the kinto updater talks to the test server 248 Services.prefs.setStringPref( 249 PREF_SETTINGS_SERVER, 250 `http://localhost:${server.identity.primaryPort}/v1` 251 ); 252 253 // These are records we'll use in the test collections 254 const RECORD1 = { 255 details: { 256 bug: "https://bugzilla.mozilla.org/show_bug.cgi?id=1155145", 257 created: "2016-01-18T14:43:37Z", 258 name: "GlobalSign certs", 259 who: ".", 260 why: ".", 261 }, 262 enabled: true, 263 id: "97fbf7c4-3ef2-f54f-0029-1ba6540c63ea", 264 issuerName: 265 "MHExKDAmBgNVBAMTH0dsb2JhbFNpZ24gUm9vdFNpZ24gUGFydG5lcnMgQ0ExHTAbBgNVBAsTFFJvb3RTaWduIFBhcnRuZXJzIENBMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMQswCQYDVQQGEwJCRQ==", 266 last_modified: 2000, 267 serialNumber: "BAAAAAABA/A35EU=", 268 }; 269 270 const RECORD2 = { 271 details: { 272 bug: "https://bugzilla.mozilla.org/show_bug.cgi?id=1155145", 273 created: "2016-01-18T14:48:11Z", 274 name: "GlobalSign certs", 275 who: ".", 276 why: ".", 277 }, 278 enabled: true, 279 id: "e3bd531e-1ee4-7407-27ce-6fdc9cecbbdc", 280 issuerName: 281 "MIGBMQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTElMCMGA1UECxMcUHJpbWFyeSBPYmplY3QgUHVibGlzaGluZyBDQTEwMC4GA1UEAxMnR2xvYmFsU2lnbiBQcmltYXJ5IE9iamVjdCBQdWJsaXNoaW5nIENB", 282 last_modified: 3000, 283 serialNumber: "BAAAAAABI54PryQ=", 284 }; 285 286 const RECORD3 = { 287 details: { 288 bug: "https://bugzilla.mozilla.org/show_bug.cgi?id=1155145", 289 created: "2016-01-18T14:48:11Z", 290 name: "GlobalSign certs", 291 who: ".", 292 why: ".", 293 }, 294 enabled: true, 295 id: "c7c49b69-a4ab-418e-92a9-e1961459aa7f", 296 issuerName: 297 "MIGBMQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTElMCMGA1UECxMcUHJpbWFyeSBPYmplY3QgUHVibGlzaGluZyBDQTEwMC4GA1UEAxMnR2xvYmFsU2lnbiBQcmltYXJ5IE9iamVjdCBQdWJsaXNoaW5nIENB", 298 last_modified: 4000, 299 serialNumber: "BAAAAAABI54PryQ=", 300 }; 301 302 const RECORD1_DELETION = { 303 deleted: true, 304 enabled: true, 305 id: "97fbf7c4-3ef2-f54f-0029-1ba6540c63ea", 306 last_modified: 3500, 307 }; 308 309 // Check that a signature on an empty collection is OK 310 // We need to set up paths on the HTTP server to return specific data from 311 // specific paths for each test. Here we prepare data for each response. 312 313 // A cert chain response (this the cert chain that contains the signing 314 // cert, the root and any intermediates in between). This is used in each 315 // sync. 316 const RESPONSE_CERT_CHAIN = { 317 comment: "RESPONSE_CERT_CHAIN", 318 sampleHeaders: ["Content-Type: text/plain; charset=UTF-8"], 319 status: { status: 200, statusText: "OK" }, 320 responseBody: getCertChain(), 321 }; 322 323 // A server settings response. This is used in each sync. 324 const RESPONSE_SERVER_SETTINGS = { 325 comment: "RESPONSE_SERVER_SETTINGS", 326 sampleHeaders: [ 327 "Access-Control-Allow-Origin: *", 328 "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff", 329 "Content-Type: application/json; charset=UTF-8", 330 "Server: waitress", 331 ], 332 status: { status: 200, statusText: "OK" }, 333 responseBody: JSON.stringify({ 334 settings: { 335 batch_max_requests: 25, 336 }, 337 url: `http://localhost:${port}/v1/`, 338 documentation: "https://kinto.readthedocs.org/", 339 version: "1.5.1", 340 commit: "cbc6f58", 341 hello: "kinto", 342 }), 343 }; 344 345 // This is the initial, empty state of the collection. This is only used 346 // for the first sync. 347 const RESPONSE_EMPTY_INITIAL = { 348 comment: "RESPONSE_EMPTY_INITIAL", 349 sampleHeaders: [ 350 "Content-Type: application/json; charset=UTF-8", 351 'ETag: "1000"', 352 ], 353 status: { status: 200, statusText: "OK" }, 354 responseBody: JSON.stringify({ 355 timestamp: 1000, 356 metadata: { 357 signatures: [ 358 { 359 x5u, 360 signature: 361 "vxuAg5rDCB-1pul4a91vqSBQRXJG_j7WOYUTswxRSMltdYmbhLRH8R8brQ9YKuNDF56F-w6pn4HWxb076qgKPwgcEBtUeZAO_RtaHXRkRUUgVzAr86yQL4-aJTbv3D6u", 362 }, 363 ], 364 }, 365 changes: [], 366 }), 367 }; 368 369 // Here, we map request method and path to the available responses 370 const emptyCollectionResponses = { 371 "GET:/test_remote_settings_signatures/test_cert_chain.pem?": [ 372 RESPONSE_CERT_CHAIN, 373 ], 374 "GET:/v1/?": [RESPONSE_SERVER_SETTINGS], 375 "GET:/v1/buckets/main/collections/signed/changeset?_expected=1000": [ 376 RESPONSE_EMPTY_INITIAL, 377 ], 378 }; 379 380 // 381 // 1. 382 // - collection: undefined -> [] 383 // - timestamp: undefined -> 1000 384 // 385 386 // .. and use this map to register handlers for each path 387 registerHandlers(emptyCollectionResponses); 388 389 let startSnapshot = getUptakeTelemetrySnapshot( 390 TELEMETRY_COMPONENT, 391 TELEMETRY_SOURCE 392 ); 393 394 // With all of this set up, we attempt a sync. This will resolve if all is 395 // well and throw if something goes wrong. 396 await client.maybeSync(1000); 397 398 equal((await client.get()).length, 0); 399 400 let endSnapshot = getUptakeTelemetrySnapshot( 401 TELEMETRY_COMPONENT, 402 TELEMETRY_SOURCE 403 ); 404 405 // ensure that a success histogram is tracked when a succesful sync occurs. 406 let expectedIncrements = { 407 [UptakeTelemetry.STATUS.SYNC_START]: 1, 408 [UptakeTelemetry.STATUS.SUCCESS]: 1, 409 }; 410 checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements); 411 412 // 413 // 2. 414 // - collection: [] -> [RECORD2, RECORD1] 415 // - timestamp: 1000 -> 3000 416 // 417 // Check that some additions (2 records) to the collection have a valid 418 // signature. 419 420 // This response adds two entries (RECORD1 and RECORD2) to the collection 421 const RESPONSE_TWO_ADDED = { 422 comment: "RESPONSE_TWO_ADDED", 423 sampleHeaders: [ 424 "Content-Type: application/json; charset=UTF-8", 425 'ETag: "3000"', 426 ], 427 status: { status: 200, statusText: "OK" }, 428 responseBody: JSON.stringify({ 429 timestamp: 3000, 430 metadata: { 431 signatures: [ 432 { 433 x5u, 434 signature: 435 "dwhJeypadNIyzGj3QdI0KMRTPnHhFPF_j73mNrsPAHKMW46S2Ftf4BzsPMvPMB8h0TjDus13wo_R4l432DHe7tYyMIWXY0PBeMcoe5BREhFIxMxTsh9eGVXBD1e3UwRy", 436 }, 437 ], 438 }, 439 changes: [RECORD2, RECORD1], 440 }), 441 }; 442 443 const twoItemsResponses = { 444 "GET:/v1/buckets/main/collections/signed/changeset?_expected=3000&_since=%221000%22": 445 [RESPONSE_TWO_ADDED], 446 }; 447 registerHandlers(twoItemsResponses); 448 await client.maybeSync(3000); 449 450 equal((await client.get()).length, 2); 451 452 // 453 // 3. 454 // - collection: [RECORD2, RECORD1] -> [RECORD2, RECORD3] 455 // - timestamp: 3000 -> 4000 456 // 457 // Check the collection with one addition and one removal has a valid 458 // signature 459 const THREE_ITEMS_SIG = 460 "MIEmNghKnkz12UodAAIc3q_Y4a3IJJ7GhHF4JYNYmm8avAGyPM9fYU7NzVo94pzjotG7vmtiYuHyIX2rTHTbT587w0LdRWxipgFd_PC1mHiwUyjFYNqBBG-kifYk7kEw"; 461 462 // Remove RECORD1, add RECORD3 463 const RESPONSE_ONE_ADDED_ONE_REMOVED = { 464 comment: "RESPONSE_ONE_ADDED_ONE_REMOVED ", 465 sampleHeaders: [ 466 "Content-Type: application/json; charset=UTF-8", 467 'ETag: "4000"', 468 ], 469 status: { status: 200, statusText: "OK" }, 470 responseBody: JSON.stringify({ 471 timestamp: 4000, 472 metadata: { 473 signatures: [ 474 { 475 x5u, 476 signature: THREE_ITEMS_SIG, 477 }, 478 ], 479 }, 480 changes: [RECORD3, RECORD1_DELETION], 481 }), 482 }; 483 484 const oneAddedOneRemovedResponses = { 485 "GET:/v1/buckets/main/collections/signed/changeset?_expected=4000&_since=%223000%22": 486 [RESPONSE_ONE_ADDED_ONE_REMOVED], 487 }; 488 registerHandlers(oneAddedOneRemovedResponses); 489 await client.maybeSync(4000); 490 491 equal((await client.get()).length, 2); 492 493 // 494 // 4. 495 // - collection: [RECORD2, RECORD3] -> [RECORD2, RECORD3] 496 // - timestamp: 4000 -> 4100 497 // 498 // Check the signature is still valid with no operation (no changes) 499 500 // Leave the collection unchanged 501 const RESPONSE_EMPTY_NO_UPDATE = { 502 comment: "RESPONSE_EMPTY_NO_UPDATE ", 503 sampleHeaders: [ 504 "Content-Type: application/json; charset=UTF-8", 505 'ETag: "4000"', 506 ], 507 status: { status: 200, statusText: "OK" }, 508 responseBody: JSON.stringify({ 509 timestamp: 4000, 510 metadata: { 511 signatures: [ 512 { 513 x5u, 514 signature: THREE_ITEMS_SIG, 515 }, 516 ], 517 }, 518 changes: [], 519 }), 520 }; 521 522 const noOpResponses = { 523 "GET:/v1/buckets/main/collections/signed/changeset?_expected=4100&_since=%224000%22": 524 [RESPONSE_EMPTY_NO_UPDATE], 525 }; 526 registerHandlers(noOpResponses); 527 await client.maybeSync(4100); 528 529 equal((await client.get()).length, 2); 530 531 // 532 // 5. 533 // - collection: [RECORD2, RECORD3] -> [RECORD2, RECORD3] 534 // - timestamp: 4000 -> 5000 535 // 536 // Check the collection is reset when the signature is invalid. 537 // Client will: 538 // - Fetch metadata (with bad signature) 539 // - Perform the sync (fetch empty changes) 540 // - Refetch the metadata and the whole collection 541 // - Validate signature successfully, but with no changes to emit. 542 543 const RESPONSE_COMPLETE_INITIAL = { 544 comment: "RESPONSE_COMPLETE_INITIAL ", 545 sampleHeaders: [ 546 "Content-Type: application/json; charset=UTF-8", 547 'ETag: "4000"', 548 ], 549 status: { status: 200, statusText: "OK" }, 550 responseBody: JSON.stringify({ 551 timestamp: 4000, 552 metadata: { 553 signatures: [ 554 { 555 x5u, 556 signature: THREE_ITEMS_SIG, 557 }, 558 ], 559 }, 560 changes: [RECORD2, RECORD3], 561 }), 562 }; 563 564 const RESPONSE_EMPTY_NO_UPDATE_BAD_SIG = { 565 ...RESPONSE_EMPTY_NO_UPDATE, 566 responseBody: JSON.stringify({ 567 timestamp: 4000, 568 metadata: { 569 signatures: [ 570 { 571 x5u, 572 signature: "aW52YWxpZCBzaWduYXR1cmUK", 573 }, 574 ], 575 }, 576 changes: [], 577 }), 578 }; 579 580 const badSigGoodSigResponses = { 581 // The first collection state is the three item collection (since 582 // there was sync with no updates before) - but, since the signature is wrong, 583 // another request will be made... 584 "GET:/v1/buckets/main/collections/signed/changeset?_expected=5000&_since=%224000%22": 585 [RESPONSE_EMPTY_NO_UPDATE_BAD_SIG], 586 // Subsequent signature returned is a valid one for the three item 587 // collection. 588 "GET:/v1/buckets/main/collections/signed/changeset?_expected=5000": [ 589 RESPONSE_COMPLETE_INITIAL, 590 ], 591 }; 592 593 registerHandlers(badSigGoodSigResponses); 594 595 startSnapshot = getUptakeTelemetrySnapshot( 596 TELEMETRY_COMPONENT, 597 TELEMETRY_SOURCE 598 ); 599 600 let syncEventSent = false; 601 client.on("sync", () => { 602 syncEventSent = true; 603 }); 604 605 await client.maybeSync(5000); 606 607 equal((await client.get()).length, 2); 608 609 endSnapshot = getUptakeTelemetrySnapshot( 610 TELEMETRY_COMPONENT, 611 TELEMETRY_SOURCE 612 ); 613 614 // since we only fixed the signature, and no data was changed, the sync event 615 // was not sent. 616 equal(syncEventSent, false); 617 618 // ensure that the failure count is incremented for a succesful sync with an 619 // (initial) bad signature - only SERVICES_SETTINGS_SYNC_SIG_FAIL should 620 // increment. 621 expectedIncrements = { 622 [UptakeTelemetry.STATUS.SYNC_START]: -2, 623 [UptakeTelemetry.STATUS.SIGNATURE_ERROR]: 1, 624 }; 625 checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements); 626 627 // 628 // 6. 629 // - collection: [RECORD2, RECORD3] -> [RECORD2, RECORD3] 630 // - timestamp: 4000 -> 5000 631 // 632 // Check the collection is reset when the signature is invalid. 633 // Client will: 634 // - Fetch metadata (with bad signature) 635 // - Perform the sync (fetch empty changes) 636 // - Refetch the whole collection and metadata 637 // - Sync will be no-op since local is equal to server, no changes to emit. 638 639 const badSigGoodOldResponses = { 640 // The first collection state is the current state (since there's no update 641 // - but, since the signature is wrong, another request will be made) 642 "GET:/v1/buckets/main/collections/signed/changeset?_expected=5000&_since=%224000%22": 643 [RESPONSE_EMPTY_NO_UPDATE_BAD_SIG], 644 // The next request is for the full collection. This will be 645 // checked against the valid signature and last_modified times will be 646 // compared. Sync should be a no-op, even though the signature is good, 647 // because the local collection is newer. 648 "GET:/v1/buckets/main/collections/signed/changeset?_expected=5000": [ 649 RESPONSE_EMPTY_INITIAL, 650 ], 651 }; 652 653 // ensure our collection hasn't been replaced with an older, empty one 654 equal((await client.get()).length, 2, "collection was restored"); 655 656 registerHandlers(badSigGoodOldResponses); 657 658 syncEventSent = false; 659 client.on("sync", () => { 660 syncEventSent = true; 661 }); 662 663 await client.maybeSync(5000); 664 665 // Local data was unchanged, since it was never than the one returned by the server, 666 // thus the sync event is not sent. 667 equal(syncEventSent, false, "event was not sent"); 668 669 // 670 // 7. 671 // - collection: [RECORD2, RECORD3] -> [RECORD2, RECORD3] 672 // - timestamp: 4000 -> 5000 673 // 674 // Check that a tampered local DB will be overwritten and 675 // sync event contain the appropriate data. 676 677 const RESPONSE_COMPLETE_BAD_SIG = { 678 ...RESPONSE_EMPTY_NO_UPDATE, 679 responseBody: JSON.stringify({ 680 timestamp: 5000, 681 metadata: { 682 signatures: [ 683 { 684 x5u, 685 signature: "aW52YWxpZCBzaWduYXR1cmUK", 686 }, 687 ], 688 }, 689 changes: [RECORD2, RECORD3], 690 }), 691 }; 692 693 const badLocalContentGoodSigResponses = { 694 "GET:/v1/buckets/main/collections/signed/changeset?_expected=5000&_since=%223900%22": 695 [RESPONSE_COMPLETE_BAD_SIG], 696 "GET:/v1/buckets/main/collections/signed/changeset?_expected=5000": [ 697 RESPONSE_COMPLETE_INITIAL, 698 ], 699 }; 700 701 registerHandlers(badLocalContentGoodSigResponses); 702 703 // we create a local state manually here, in order to test that the sync event data 704 // properly contains created, updated, and deleted records. 705 // the local DB contains same id as RECORD2 and a fake record. 706 // the final server collection contains RECORD2 and RECORD3 707 const localId = "0602b1b2-12ab-4d3a-b6fb-593244e7b035"; 708 await client.db.importChanges( 709 { signatures: [{ x5u, signature: "abc" }] }, 710 3900, 711 [ 712 { ...RECORD2, last_modified: 1234567890, serialNumber: "abc" }, 713 { id: localId }, 714 ], 715 { 716 clear: true, 717 } 718 ); 719 720 let syncData = null; 721 client.on("sync", ({ data }) => { 722 syncData = data; 723 }); 724 725 // Clear events snapshot. 726 TelemetryTestUtils.assertEvents([], {}, { process: "dummy" }); 727 728 const TELEMETRY_EVENTS_FILTERS = { 729 category: "uptake.remotecontent.result", 730 method: "uptake", 731 }; 732 733 // Events telemetry is sampled on released, use fake channel. 734 await client.maybeSync(5000); 735 736 // We should report a corruption_error. 737 TelemetryTestUtils.assertEvents( 738 [ 739 [ 740 "uptake.remotecontent.result", 741 "uptake", 742 "remotesettings", 743 UptakeTelemetry.STATUS.SYNC_START, 744 { 745 source: client.identifier, 746 trigger: "manual", 747 }, 748 ], 749 [ 750 "uptake.remotecontent.result", 751 "uptake", 752 "remotesettings", 753 UptakeTelemetry.STATUS.CORRUPTION_ERROR, 754 { 755 source: client.identifier, 756 duration: v => v > 0, 757 trigger: "manual", 758 }, 759 ], 760 ], 761 TELEMETRY_EVENTS_FILTERS 762 ); 763 764 // The local data was corrupted, and the Telemetry status reflects it. 765 // But the sync overwrote the bad data and was eventually a success. 766 // Since local data was replaced, we use records IDs to determine 767 // what was created and deleted. And bad local data will appear 768 // in the sync event as deleted. 769 equal(syncData.current.length, 2); 770 equal(syncData.created.length, 1); 771 equal(syncData.created[0].id, RECORD3.id); 772 equal(syncData.updated.length, 1); 773 equal(syncData.updated[0].old.serialNumber, "abc"); 774 equal(syncData.updated[0].new.serialNumber, RECORD2.serialNumber); 775 equal(syncData.deleted.length, 1); 776 equal(syncData.deleted[0].id, localId); 777 778 // 779 // 8. 780 // - collection: [RECORD2, RECORD3] -> [RECORD2, RECORD3] (unchanged because of error) 781 // - timestamp: 4000 -> 6000 782 // 783 // Check that a failing signature throws after retry, and that sync changes 784 // are not applied. 785 786 const RESPONSE_ONLY_RECORD4_BAD_SIG = { 787 comment: "Create RECORD4", 788 sampleHeaders: [ 789 "Content-Type: application/json; charset=UTF-8", 790 'ETag: "6000"', 791 ], 792 status: { status: 200, statusText: "OK" }, 793 responseBody: JSON.stringify({ 794 timestamp: 6000, 795 metadata: { 796 signatures: [ 797 { 798 x5u, 799 signature: "aaaaaaaaaaaaaaaaaaaaaaaa", // sig verifier wants proper length or will crash. 800 }, 801 ], 802 }, 803 changes: [ 804 { 805 id: "f765df30-b2f1-42f6-9803-7bd5a07b5098", 806 last_modified: 6000, 807 }, 808 ], 809 }), 810 }; 811 const RESPONSE_EMPTY_NO_UPDATE_BAD_SIG_6000 = { 812 ...RESPONSE_EMPTY_NO_UPDATE, 813 responseBody: JSON.stringify({ 814 timestamp: 6000, 815 metadata: { 816 signatures: [ 817 { 818 x5u, 819 signature: "aW52YWxpZCBzaWduYXR1cmUK", 820 }, 821 ], 822 }, 823 changes: [], 824 }), 825 }; 826 const allBadSigResponses = { 827 "GET:/v1/buckets/main/collections/signed/changeset?_expected=6000&_since=%224000%22": 828 [RESPONSE_EMPTY_NO_UPDATE_BAD_SIG_6000], 829 "GET:/v1/buckets/main/collections/signed/changeset?_expected=6000": [ 830 RESPONSE_ONLY_RECORD4_BAD_SIG, 831 ], 832 }; 833 834 startSnapshot = getUptakeTelemetrySnapshot( 835 TELEMETRY_COMPONENT, 836 TELEMETRY_SOURCE 837 ); 838 registerHandlers(allBadSigResponses); 839 await Assert.rejects( 840 client.maybeSync(6000), 841 RemoteSettingsClient.InvalidSignatureError, 842 "Sync failed as expected (bad signature after retry)" 843 ); 844 845 // Ensure that the failure is reflected in the accumulated telemetry: 846 endSnapshot = getUptakeTelemetrySnapshot( 847 TELEMETRY_COMPONENT, 848 TELEMETRY_SOURCE 849 ); 850 expectedIncrements = { 851 [UptakeTelemetry.STATUS.SYNC_START]: 1, 852 [UptakeTelemetry.STATUS.SIGNATURE_RETRY_ERROR]: 1, 853 }; 854 checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements); 855 856 // When signature fails after retry, the local data present before sync 857 // should be maintained (if its signature is valid). 858 ok( 859 arrayEqual( 860 (await client.get()).map(r => r.id), 861 [RECORD3.id, RECORD2.id] 862 ), 863 "Local records were not changed" 864 ); 865 // And local data should still be valid. 866 await client.get({ verifySignature: true }); // Not raising. 867 868 // 869 // 9. 870 // - collection: [RECORD2, RECORD3] -> [] (cleared) 871 // - timestamp: 4000 -> 6000 872 // 873 // Check that local data is cleared during sync if signature is not valid. 874 875 await client.db.create({ 876 id: "c6b19c67-2e0e-4a82-b7f7-1777b05f3e81", 877 last_modified: 42, 878 tampered: true, 879 }); 880 881 await Assert.rejects( 882 client.maybeSync(6000), 883 RemoteSettingsClient.InvalidSignatureError, 884 "Sync failed as expected (bad signature after retry)" 885 ); 886 887 // Since local data was tampered, it was cleared. 888 equal((await client.get()).length, 0, "Local database is now empty."); 889 890 // 891 // 10. 892 // - collection: [RECORD2, RECORD3] -> [] (cleared) 893 // - timestamp: 4000 -> 6000 894 // 895 // Check that local data is cleared during sync if signature is not valid. 896 897 await client.db.create({ 898 id: "c6b19c67-2e0e-4a82-b7f7-1777b05f3e81", 899 last_modified: 42, 900 tampered: true, 901 }); 902 903 await Assert.rejects( 904 client.maybeSync(6000), 905 RemoteSettingsClient.InvalidSignatureError, 906 "Sync failed as expected (bad signature after retry)" 907 ); 908 // Since local data was tampered, it was cleared. 909 equal((await client.get()).length, 0, "Local database is now empty."); 910 911 // 912 // 11. 913 // - collection: [RECORD2, RECORD3] -> [RECORD2, RECORD3] 914 // - timestamp: 4000 -> 6000 915 // 916 // Check that local data is restored if signature was valid before sync. 917 const sigCalls = []; 918 let i = 0; 919 client._verifier = { 920 async asyncVerifyContentSignature(serialized) { 921 sigCalls.push(serialized); 922 console.log(`verify call ${i}`); 923 return [ 924 false, // After importing changes. 925 true, // When checking previous local data. 926 false, // Still fail after retry. 927 true, // When checking previous local data again. 928 ][i++]; 929 }, 930 }; 931 // Create an extra record. It will have a valid signature locally 932 // thanks to the verifier mock. 933 await client.db.importChanges( 934 { 935 signatures: [{ x5u, signature: "aa" }], 936 }, 937 4000, 938 [ 939 { 940 id: "extraId", 941 last_modified: 42, 942 }, 943 ] 944 ); 945 946 equal((await client.get()).length, 1); 947 948 // Now sync, but importing changes will have failing signature, 949 // and so will retry (see `sigResults`). 950 await Assert.rejects( 951 client.maybeSync(6000), 952 RemoteSettingsClient.InvalidSignatureError, 953 "Sync failed as expected (bad signature after retry)" 954 ); 955 equal(i, 4, "sync has retried as expected"); 956 957 // Make sure that we retried on a blank DB. The extra record should 958 // have been deleted when we validated the signature the second time. 959 // Since local data was tampered, it was cleared. 960 ok(/extraId/.test(sigCalls[0]), "extra record when importing changes"); 961 ok(/extraId/.test(sigCalls[1]), "extra record when checking local"); 962 ok(!/extraId/.test(sigCalls[2]), "db was flushed before retry"); 963 ok(/extraId/.test(sigCalls[3]), "when checking local after retry"); 964 });