Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bug 1689547 - First attempt at providing a ping encryption plugin #96

Merged
merged 15 commits into from
Mar 12, 2021
Merged
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

[Full changelog](https://github.com/mozilla/glean.js/compare/v0.4.0...main)

* [#96](https://github.com/mozilla/glean.js/pull/96): Provide a ping encryption plugin.
* This plugin listens to the `afterPingCollection` event. It receives the collected payload of a ping and returns an encrypted version of it using a JWK provided upon instantiation.
* [#95](https://github.com/mozilla/glean.js/pull/95): Add a `plugins` property to the configuration options and create an event abstraction for triggering internal Glean events.
* The only internal event triggered at this point is the `afterPingCollection` event, which is triggered after ping collection and logging, and before ping storing.
* Plugins are built to listen to a specific Glean event. Each plugin must define an `action`, which is executed everytime the event they are listening to is triggered.
* [#101](https://github.com/mozilla/glean.js/pull/101): BUGFIX: Only validate Debug View Tag and Source Tags when they are present.
* [#101](https://github.com/mozilla/glean.js/pull/101): BUGFIX: Only validate Debug View Tag and Source Tags when they are present.
* [#102](https://github.com/mozilla/glean.js/pull/102): BUGFIX: Include a Glean User-Agent header in all pings.

Expand Down
3 changes: 2 additions & 1 deletion glean/.eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@
],
"rules": {
"mocha/no-skipped-tests": "off",
"mocha/no-pending-tests": "off"
"mocha/no-pending-tests": "off",
"mocha/no-setup-in-describe": "off"
}
},
{
Expand Down
14 changes: 14 additions & 0 deletions glean/package-lock.json

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

12 changes: 11 additions & 1 deletion glean/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
"browser": "./dist/webext/browser/core/pings/index.js",
"import": "./dist/webext/esm/core/pings/index.js",
"require": "./dist/webext/cjs/core/pings/index.js"
},
"./webext/plugins/*": {
"browser": "./dist/webext/browser/plugins/*.js",
"import": "./dist/webext/esm/plugins/*.js",
"require": "./dist/webext/cjs/plugins/*.js"
}
},
"typesVersions": {
Expand All @@ -30,6 +35,9 @@
],
"webext/private/metrics/*": [
"./dist/webext/types/core/metrics/types/*"
],
"webext/plugins/*": [
"./dist/webext/types/plugins/*"
]
}
},
Expand All @@ -39,8 +47,9 @@
"dist/**/*"
],
"scripts": {
"test": "npm run test:core && npm run test:platform",
"test": "npm run test:core && npm run test:platform && npm run test:plugins",
"test:core": "ts-mocha \"tests/core/**/*.spec.ts\" --recursive",
"test:plugins": "ts-mocha \"tests/plugins/**/*.spec.ts\" --recursive",
brizental marked this conversation as resolved.
Show resolved Hide resolved
"test:platform": "npm run build:test-webext && ts-mocha \"tests/platform/**/*.spec.ts\" --recursive --timeout 0",
"build:test-webext": "cd tests/platform/utils/webext/sample/ && npm install && npm run build:xpi",
"lint": "eslint . --ext .ts,.js,.json --max-warnings=0",
Expand Down Expand Up @@ -96,6 +105,7 @@
"webpack-cli": "^4.5.0"
},
"dependencies": {
"jose": "^3.7.0",
"uuid": "^8.3.2"
}
}
92 changes: 48 additions & 44 deletions glean/src/core/events/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,53 +12,57 @@ export class CoreEvent<
Context extends unknown[] = unknown[],
// The expected type of the action result. To be returned by the plugin.
Result extends unknown = unknown
> {
// The plugin to be triggered eveytime this even occurs.
private plugin?: Plugin<CoreEvent<Context, Result>>;
> {
// The plugin to be triggered eveytime this even occurs.
brizental marked this conversation as resolved.
Show resolved Hide resolved
private plugin?: Plugin<CoreEvent<Context, Result>>;

constructor(readonly name: string) {}
constructor(readonly name: string) {}

/**
* Registers a plugin that listens to this event.
*
* @param plugin The plugin to register.
*/
registerPlugin(plugin: Plugin<CoreEvent<Context, Result>>): void {
if (this.plugin) {
console.error(
`Attempted to register plugin '${plugin.name}', which listens to the event '${plugin.event}'.`,
`That event is already watched by plugin '${this.plugin.name}'`,
`Plugin '${plugin.name}' will be ignored.`
);
return;
}

this.plugin = plugin;
}

/**
* Deregisters the currently registered plugin.
*
* If no plugin is currently registered this is a no-op.
*/
deregisterPlugin(): void {
this.plugin = undefined;
}
get registeredPluginIdentifier(): string | undefined {
return this.plugin?.name;
}

/**
* Triggers this event.
*
* Will execute the action of the registered plugin, if there is any.
*
* @param args The arguments to be passed as context to the registered plugin.
*
* @returns The result from the plugin execution.
*/
trigger(...args: Context): Result | void {
if (this.plugin) {
return this.plugin.action(...args);
}
}
/**
* Registers a plugin that listens to this event.
*
* @param plugin The plugin to register.
*/
registerPlugin(plugin: Plugin<CoreEvent<Context, Result>>): void {
if (this.plugin) {
console.error(
`Attempted to register plugin '${plugin.name}', which listens to the event '${plugin.event}'.`,
`That event is already watched by plugin '${this.plugin.name}'`,
`Plugin '${plugin.name}' will be ignored.`
);
return;
}

this.plugin = plugin;
}

/**
* Deregisters the currently registered plugin.
*
* If no plugin is currently registered this is a no-op.
*/
deregisterPlugin(): void {
this.plugin = undefined;
}

/**
* Triggers this event.
*
* Will execute the action of the registered plugin, if there is any.
*
* @param args The arguments to be passed as context to the registered plugin.
*
* @returns The result from the plugin execution.
*/
trigger(...args: Context): Result | void {
if (this.plugin) {
return this.plugin.action(...args);
}
}
}

/**
Expand Down
22 changes: 16 additions & 6 deletions glean/src/core/pings/maker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ export async function collectPing(ping: PingType, reason?: string): Promise<Ping
*
* @returns The final submission path.
*/
function makePath(identifier: string, ping: PingType): string {
export function makePath(identifier: string, ping: PingType): string {
// We are sure that the applicationId is not `undefined` at this point,
// this function is only called when submitting a ping
// and that function return early when Glean is not initialized.
Expand Down Expand Up @@ -237,15 +237,25 @@ export async function collectAndStorePing(identifier: string, ping: PingType, re
return;
}

let modifiedPayload;
try {
brizental marked this conversation as resolved.
Show resolved Hide resolved
modifiedPayload = await CoreEvents.afterPingCollection.trigger(collectedPayload);
} catch(e) {
console.error(
`Error while attempting to modify ping payload for the "${ping.name}" ping using`,
`the ${JSON.stringify(CoreEvents.afterPingCollection.registeredPluginIdentifier)} plugin.`,
"Ping will not be submitted. See more logs below.\n\n",
brizental marked this conversation as resolved.
Show resolved Hide resolved
e
);

return;
}

if (Glean.logPings) {
console.info(JSON.stringify(collectedPayload, null, 2));
}

const headers = getPingHeaders();

const modifiedPayload = await CoreEvents.afterPingCollection.trigger(collectedPayload);
const finalPayload = modifiedPayload ? modifiedPayload : collectedPayload;

const headers = getPingHeaders();
return Glean.pingsDatabase.recordPing(
makePath(identifier, ping),
identifier,
Expand Down
60 changes: 60 additions & 0 deletions glean/src/plugins/encryption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/* 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/. */

import CompactEncrypt from "jose/jwe/compact/encrypt";
import parseJwk from "jose/jwk/parse";
import calculateThumbprint from "jose/jwk/thumbprint";
import { JWK } from "jose/types";

import Plugin from "./index";
import { PingPayload } from "../core/pings/database";
import { JSONObject } from "../core/utils";
import CoreEvents from "../core/events";

// These are the chosen defaults, because they are the ones expected by Glean's data pipeline.
//
// That is the case because they are the only algorithm and content encoding pair supported
// by Firefox's hand-rolled JWE implementation.
// See: https://searchfox.org/mozilla-central/rev/eeb8cf278192d68b3977d0adb4d43f1463439269/services/crypto/modules/jwcrypto.jsm#58-74
const JWE_ALGORITHM = "ECDH-ES";
const JWE_CONTENT_ENCODING = "A256GCM";

/**
* A plugin that listens for the `afterPingCollection` event and encrypts **all** outgoing pings
* with the JWK provided upon initialization.
brizental marked this conversation as resolved.
Show resolved Hide resolved
*
* This plugin will modify the schema of outgoing pings to:
*
* ```json
* {
* payload: "<encrypted-payload>"
* }
* ```
*/
class PingEncryptionPlugin extends Plugin<typeof CoreEvents["afterPingCollection"]> {
/**
* Creates a new PingEncryptionPlugin instance.
*
* @param jwk The JWK that will be used to encode outgoing ping payloads.
*/
constructor(private jwk: JWK) {
super(CoreEvents["afterPingCollection"].name, "pingEncryptionPlugin");
}

async action(payload: PingPayload): Promise<JSONObject> {
const key = await parseJwk(this.jwk, JWE_ALGORITHM);
brizental marked this conversation as resolved.
Show resolved Hide resolved
const encoder = new TextEncoder();
const encodedPayload = await new CompactEncrypt(encoder.encode(JSON.stringify(payload)))
.setProtectedHeader({
kid: await calculateThumbprint(this.jwk),
alg: JWE_ALGORITHM,
enc: JWE_CONTENT_ENCODING,
typ: "JWE",
})
.encrypt(key);
return { payload: encodedPayload };
brizental marked this conversation as resolved.
Show resolved Hide resolved
}
}

export default PingEncryptionPlugin;
Loading