summaryrefslogtreecommitdiffstats
path: root/devtools/shared/security
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/shared/security')
-rw-r--r--devtools/shared/security/auth.js653
-rw-r--r--devtools/shared/security/cert.js67
-rw-r--r--devtools/shared/security/docs/wifi.md154
-rw-r--r--devtools/shared/security/moz.build15
-rw-r--r--devtools/shared/security/prompt.js179
-rw-r--r--devtools/shared/security/socket.js793
-rw-r--r--devtools/shared/security/tests/chrome/chrome.ini4
-rw-r--r--devtools/shared/security/tests/chrome/test_websocket-transport.html76
-rw-r--r--devtools/shared/security/tests/unit/.eslintrc.js6
-rw-r--r--devtools/shared/security/tests/unit/head_dbg.js96
-rw-r--r--devtools/shared/security/tests/unit/test_encryption.js110
-rw-r--r--devtools/shared/security/tests/unit/test_oob_cert_auth.js261
-rw-r--r--devtools/shared/security/tests/unit/testactors.js131
-rw-r--r--devtools/shared/security/tests/unit/xpcshell.ini12
14 files changed, 2557 insertions, 0 deletions
diff --git a/devtools/shared/security/auth.js b/devtools/shared/security/auth.js
new file mode 100644
index 000000000..9272f602e
--- /dev/null
+++ b/devtools/shared/security/auth.js
@@ -0,0 +1,653 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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/. */
+
+"use strict";
+
+var { Ci, Cc } = require("chrome");
+var Services = require("Services");
+var promise = require("promise");
+var defer = require("devtools/shared/defer");
+var DevToolsUtils = require("devtools/shared/DevToolsUtils");
+var { dumpn, dumpv } = DevToolsUtils;
+loader.lazyRequireGetter(this, "prompt",
+ "devtools/shared/security/prompt");
+loader.lazyRequireGetter(this, "cert",
+ "devtools/shared/security/cert");
+loader.lazyRequireGetter(this, "asyncStorage",
+ "devtools/shared/async-storage");
+const { Task } = require("devtools/shared/task");
+
+/**
+ * A simple enum-like object with keys mirrored to values.
+ * This makes comparison to a specfic value simpler without having to repeat and
+ * mis-type the value.
+ */
+function createEnum(obj) {
+ for (let key in obj) {
+ obj[key] = key;
+ }
+ return obj;
+}
+
+/**
+ * |allowConnection| implementations can return various values as their |result|
+ * field to indicate what action to take. By specifying these, we can
+ * centralize the common actions available, while still allowing embedders to
+ * present their UI in whatever way they choose.
+ */
+var AuthenticationResult = exports.AuthenticationResult = createEnum({
+
+ /**
+ * Close all listening sockets, and disable them from opening again.
+ */
+ DISABLE_ALL: null,
+
+ /**
+ * Deny the current connection.
+ */
+ DENY: null,
+
+ /**
+ * Additional data needs to be exchanged before a result can be determined.
+ */
+ PENDING: null,
+
+ /**
+ * Allow the current connection.
+ */
+ ALLOW: null,
+
+ /**
+ * Allow the current connection, and persist this choice for future
+ * connections from the same client. This requires a trustable mechanism to
+ * identify the client in the future, such as the cert used during OOB_CERT.
+ */
+ ALLOW_PERSIST: null
+
+});
+
+/**
+ * An |Authenticator| implements an authentication mechanism via various hooks
+ * in the client and server debugger socket connection path (see socket.js).
+ *
+ * |Authenticator|s are stateless objects. Each hook method is passed the state
+ * it needs by the client / server code in socket.js.
+ *
+ * Separate instances of the |Authenticator| are created for each use (client
+ * connection, server listener) in case some methods are customized by the
+ * embedder for a given use case.
+ */
+var Authenticators = {};
+
+/**
+ * The Prompt authenticator displays a server-side user prompt that includes
+ * connection details, and asks the user to verify the connection. There are
+ * no cryptographic properties at work here, so it is up to the user to be sure
+ * that the client can be trusted.
+ */
+var Prompt = Authenticators.Prompt = {};
+
+Prompt.mode = "PROMPT";
+
+Prompt.Client = function () {};
+Prompt.Client.prototype = {
+
+ mode: Prompt.mode,
+
+ /**
+ * When client is about to make a new connection, verify that the connection settings
+ * are compatible with this authenticator.
+ * @throws if validation requirements are not met
+ */
+ validateSettings() {},
+
+ /**
+ * When client has just made a new socket connection, validate the connection
+ * to ensure it meets the authenticator's policies.
+ *
+ * @param host string
+ * The host name or IP address of the debugger server.
+ * @param port number
+ * The port number of the debugger server.
+ * @param encryption boolean (optional)
+ * Whether the server requires encryption. Defaults to false.
+ * @param cert object (optional)
+ * The server's cert details.
+ * @param s nsISocketTransport
+ * Underlying socket transport, in case more details are needed.
+ * @return boolean
+ * Whether the connection is valid.
+ */
+ validateConnection() {
+ return true;
+ },
+
+ /**
+ * Work with the server to complete any additional steps required by this
+ * authenticator's policies.
+ *
+ * Debugging commences after this hook completes successfully.
+ *
+ * @param host string
+ * The host name or IP address of the debugger server.
+ * @param port number
+ * The port number of the debugger server.
+ * @param encryption boolean (optional)
+ * Whether the server requires encryption. Defaults to false.
+ * @param transport DebuggerTransport
+ * A transport that can be used to communicate with the server.
+ * @return A promise can be used if there is async behavior.
+ */
+ authenticate() {},
+
+};
+
+Prompt.Server = function () {};
+Prompt.Server.prototype = {
+
+ mode: Prompt.mode,
+
+ /**
+ * Verify that listener settings are appropriate for this authentication mode.
+ *
+ * @param listener SocketListener
+ * The socket listener about to be opened.
+ * @throws if validation requirements are not met
+ */
+ validateOptions() {},
+
+ /**
+ * Augment options on the listening socket about to be opened.
+ *
+ * @param listener SocketListener
+ * The socket listener about to be opened.
+ * @param socket nsIServerSocket
+ * The socket that is about to start listening.
+ */
+ augmentSocketOptions() {},
+
+ /**
+ * Augment the service discovery advertisement with any additional data needed
+ * to support this authentication mode.
+ *
+ * @param listener SocketListener
+ * The socket listener that was just opened.
+ * @param advertisement object
+ * The advertisement being built.
+ */
+ augmentAdvertisement(listener, advertisement) {
+ advertisement.authentication = Prompt.mode;
+ },
+
+ /**
+ * Determine whether a connection the server should be allowed or not based on
+ * this authenticator's policies.
+ *
+ * @param session object
+ * In PROMPT mode, the |session| includes:
+ * {
+ * client: {
+ * host,
+ * port
+ * },
+ * server: {
+ * host,
+ * port
+ * },
+ * transport
+ * }
+ * @return An AuthenticationResult value.
+ * A promise that will be resolved to the above is also allowed.
+ */
+ authenticate({ client, server }) {
+ if (!Services.prefs.getBoolPref("devtools.debugger.prompt-connection")) {
+ return AuthenticationResult.ALLOW;
+ }
+ return this.allowConnection({
+ authentication: this.mode,
+ client,
+ server
+ });
+ },
+
+ /**
+ * Prompt the user to accept or decline the incoming connection. The default
+ * implementation is used unless this is overridden on a particular
+ * authenticator instance.
+ *
+ * It is expected that the implementation of |allowConnection| will show a
+ * prompt to the user so that they can allow or deny the connection.
+ *
+ * @param session object
+ * In PROMPT mode, the |session| includes:
+ * {
+ * authentication: "PROMPT",
+ * client: {
+ * host,
+ * port
+ * },
+ * server: {
+ * host,
+ * port
+ * }
+ * }
+ * @return An AuthenticationResult value.
+ * A promise that will be resolved to the above is also allowed.
+ */
+ allowConnection: prompt.Server.defaultAllowConnection,
+
+};
+
+/**
+ * The out-of-band (OOB) cert authenticator is based on self-signed X.509 certs
+ * at both the client and server end.
+ *
+ * The user is first prompted to verify the connection, similar to the prompt
+ * method above. This prompt may display cert fingerprints if desired.
+ *
+ * Assuming the user approves the connection, further UI is used to assist the
+ * user in tranferring out-of-band (OOB) verification of the client's
+ * certificate. For example, this could take the form of a QR code that the
+ * client displays which is then scanned by a camera on the server.
+ *
+ * Since it is assumed that an attacker can't forge the client's X.509 cert, the
+ * user may also choose to always allow a client, which would permit immediate
+ * connections in the future with no user interaction needed.
+ *
+ * See docs/wifi.md for details of the authentication design.
+ */
+var OOBCert = Authenticators.OOBCert = {};
+
+OOBCert.mode = "OOB_CERT";
+
+OOBCert.Client = function () {};
+OOBCert.Client.prototype = {
+
+ mode: OOBCert.mode,
+
+ /**
+ * When client is about to make a new connection, verify that the connection settings
+ * are compatible with this authenticator.
+ * @throws if validation requirements are not met
+ */
+ validateSettings({ encryption }) {
+ if (!encryption) {
+ throw new Error(`${OOBCert.mode} authentication requires encryption.`);
+ }
+ },
+
+ /**
+ * When client has just made a new socket connection, validate the connection
+ * to ensure it meets the authenticator's policies.
+ *
+ * @param host string
+ * The host name or IP address of the debugger server.
+ * @param port number
+ * The port number of the debugger server.
+ * @param encryption boolean (optional)
+ * Whether the server requires encryption. Defaults to false.
+ * @param cert object (optional)
+ * The server's cert details.
+ * @param socket nsISocketTransport
+ * Underlying socket transport, in case more details are needed.
+ * @return boolean
+ * Whether the connection is valid.
+ */
+ validateConnection({ cert, socket }) {
+ // Step B.7
+ // Client verifies that Server's cert matches hash(ServerCert) from the
+ // advertisement
+ dumpv("Validate server cert hash");
+ let serverCert = socket.securityInfo.QueryInterface(Ci.nsISSLStatusProvider)
+ .SSLStatus.serverCert;
+ let advertisedCert = cert;
+ if (serverCert.sha256Fingerprint != advertisedCert.sha256) {
+ dumpn("Server cert hash doesn't match advertisement");
+ return false;
+ }
+ return true;
+ },
+
+ /**
+ * Work with the server to complete any additional steps required by this
+ * authenticator's policies.
+ *
+ * Debugging commences after this hook completes successfully.
+ *
+ * @param host string
+ * The host name or IP address of the debugger server.
+ * @param port number
+ * The port number of the debugger server.
+ * @param encryption boolean (optional)
+ * Whether the server requires encryption. Defaults to false.
+ * @param cert object (optional)
+ * The server's cert details.
+ * @param transport DebuggerTransport
+ * A transport that can be used to communicate with the server.
+ * @return A promise can be used if there is async behavior.
+ */
+ authenticate({ host, port, cert, transport }) {
+ let deferred = defer();
+ let oobData;
+
+ let activeSendDialog;
+ let closeDialog = () => {
+ // Close any prompts the client may have been showing from previous
+ // authentication steps
+ if (activeSendDialog && activeSendDialog.close) {
+ activeSendDialog.close();
+ activeSendDialog = null;
+ }
+ };
+
+ transport.hooks = {
+ onPacket: Task.async(function* (packet) {
+ closeDialog();
+ let { authResult } = packet;
+ switch (authResult) {
+ case AuthenticationResult.PENDING:
+ // Step B.8
+ // Client creates hash(ClientCert) + K(random 128-bit number)
+ oobData = yield this._createOOB();
+ activeSendDialog = this.sendOOB({
+ host,
+ port,
+ cert,
+ authResult,
+ oob: oobData
+ });
+ break;
+ case AuthenticationResult.ALLOW:
+ // Step B.12
+ // Client verifies received value matches K
+ if (packet.k != oobData.k) {
+ transport.close(new Error("Auth secret mismatch"));
+ return;
+ }
+ // Step B.13
+ // Debugging begins
+ transport.hooks = null;
+ deferred.resolve(transport);
+ break;
+ case AuthenticationResult.ALLOW_PERSIST:
+ // Server previously persisted Client as allowed
+ // Step C.5
+ // Debugging begins
+ transport.hooks = null;
+ deferred.resolve(transport);
+ break;
+ default:
+ transport.close(new Error("Invalid auth result: " + authResult));
+ return;
+ }
+ }.bind(this)),
+ onClosed(reason) {
+ closeDialog();
+ // Transport died before auth completed
+ transport.hooks = null;
+ deferred.reject(reason);
+ }
+ };
+ transport.ready();
+ return deferred.promise;
+ },
+
+ /**
+ * Create the package of data that needs to be transferred across the OOB
+ * channel.
+ */
+ _createOOB: Task.async(function* () {
+ let clientCert = yield cert.local.getOrCreate();
+ return {
+ sha256: clientCert.sha256Fingerprint,
+ k: this._createRandom()
+ };
+ }),
+
+ _createRandom() {
+ const length = 16; // 16 bytes / 128 bits
+ let rng = Cc["@mozilla.org/security/random-generator;1"]
+ .createInstance(Ci.nsIRandomGenerator);
+ let bytes = rng.generateRandomBytes(length);
+ return bytes.map(byte => byte.toString(16)).join("");
+ },
+
+ /**
+ * Send data across the OOB channel to the server to authenticate the devices.
+ *
+ * @param host string
+ * The host name or IP address of the debugger server.
+ * @param port number
+ * The port number of the debugger server.
+ * @param cert object (optional)
+ * The server's cert details.
+ * @param authResult AuthenticationResult
+ * Authentication result sent from the server.
+ * @param oob object (optional)
+ * The token data to be transferred during OOB_CERT step 8:
+ * * sha256: hash(ClientCert)
+ * * k : K(random 128-bit number)
+ * @return object containing:
+ * * close: Function to hide the notification
+ */
+ sendOOB: prompt.Client.defaultSendOOB,
+
+};
+
+OOBCert.Server = function () {};
+OOBCert.Server.prototype = {
+
+ mode: OOBCert.mode,
+
+ /**
+ * Verify that listener settings are appropriate for this authentication mode.
+ *
+ * @param listener SocketListener
+ * The socket listener about to be opened.
+ * @throws if validation requirements are not met
+ */
+ validateOptions(listener) {
+ if (!listener.encryption) {
+ throw new Error(OOBCert.mode + " authentication requires encryption.");
+ }
+ },
+
+ /**
+ * Augment options on the listening socket about to be opened.
+ *
+ * @param listener SocketListener
+ * The socket listener about to be opened.
+ * @param socket nsIServerSocket
+ * The socket that is about to start listening.
+ */
+ augmentSocketOptions(listener, socket) {
+ let requestCert = Ci.nsITLSServerSocket.REQUIRE_ALWAYS;
+ socket.setRequestClientCertificate(requestCert);
+ },
+
+ /**
+ * Augment the service discovery advertisement with any additional data needed
+ * to support this authentication mode.
+ *
+ * @param listener SocketListener
+ * The socket listener that was just opened.
+ * @param advertisement object
+ * The advertisement being built.
+ */
+ augmentAdvertisement(listener, advertisement) {
+ advertisement.authentication = OOBCert.mode;
+ // Step A.4
+ // Server announces itself via service discovery
+ // Announcement contains hash(ServerCert) as additional data
+ advertisement.cert = listener.cert;
+ },
+
+ /**
+ * Determine whether a connection the server should be allowed or not based on
+ * this authenticator's policies.
+ *
+ * @param session object
+ * In OOB_CERT mode, the |session| includes:
+ * {
+ * client: {
+ * host,
+ * port,
+ * cert: {
+ * sha256
+ * },
+ * },
+ * server: {
+ * host,
+ * port,
+ * cert: {
+ * sha256
+ * }
+ * },
+ * transport
+ * }
+ * @return An AuthenticationResult value.
+ * A promise that will be resolved to the above is also allowed.
+ */
+ authenticate: Task.async(function* ({ client, server, transport }) {
+ // Step B.3 / C.3
+ // TLS connection established, authentication begins
+ const storageKey = `devtools.auth.${this.mode}.approved-clients`;
+ let approvedClients = (yield asyncStorage.getItem(storageKey)) || {};
+ // Step C.4
+ // Server sees that ClientCert is from a known client via hash(ClientCert)
+ if (approvedClients[client.cert.sha256]) {
+ let authResult = AuthenticationResult.ALLOW_PERSIST;
+ transport.send({ authResult });
+ // Step C.5
+ // Debugging begins
+ return authResult;
+ }
+
+ // Step B.4
+ // Server sees that ClientCert is from a unknown client
+ // Tell client they are unknown and should display OOB client UX
+ transport.send({
+ authResult: AuthenticationResult.PENDING
+ });
+
+ // Step B.5
+ // User is shown a Allow / Deny / Always Allow prompt on the Server
+ // with Client name and hash(ClientCert)
+ let authResult = yield this.allowConnection({
+ authentication: this.mode,
+ client,
+ server
+ });
+
+ switch (authResult) {
+ case AuthenticationResult.ALLOW_PERSIST:
+ case AuthenticationResult.ALLOW:
+ break; // Further processing
+ default:
+ return authResult; // Abort for any negative results
+ }
+
+ // Examine additional data for authentication
+ let oob = yield this.receiveOOB();
+ if (!oob) {
+ dumpn("Invalid OOB data received");
+ return AuthenticationResult.DENY;
+ }
+
+ let { sha256, k } = oob;
+ // The OOB auth prompt should have transferred:
+ // hash(ClientCert) + K(random 128-bit number)
+ // from the client.
+ if (!sha256 || !k) {
+ dumpn("Invalid OOB data received");
+ return AuthenticationResult.DENY;
+ }
+
+ // Step B.10
+ // Server verifies that Client's cert matches hash(ClientCert) from
+ // out-of-band channel
+ if (client.cert.sha256 != sha256) {
+ dumpn("Client cert hash doesn't match OOB data");
+ return AuthenticationResult.DENY;
+ }
+
+ // Step B.11
+ // Server sends K to Client over TLS connection
+ transport.send({ authResult, k });
+
+ // Persist Client if we want to always allow in the future
+ if (authResult === AuthenticationResult.ALLOW_PERSIST) {
+ approvedClients[client.cert.sha256] = true;
+ yield asyncStorage.setItem(storageKey, approvedClients);
+ }
+
+ // Client may decide to abort if K does not match.
+ // Server's portion of authentication is now complete.
+
+ // Step B.13
+ // Debugging begins
+ return authResult;
+ }),
+
+ /**
+ * Prompt the user to accept or decline the incoming connection. The default
+ * implementation is used unless this is overridden on a particular
+ * authenticator instance.
+ *
+ * It is expected that the implementation of |allowConnection| will show a
+ * prompt to the user so that they can allow or deny the connection.
+ *
+ * @param session object
+ * In OOB_CERT mode, the |session| includes:
+ * {
+ * authentication: "OOB_CERT",
+ * client: {
+ * host,
+ * port,
+ * cert: {
+ * sha256
+ * },
+ * },
+ * server: {
+ * host,
+ * port,
+ * cert: {
+ * sha256
+ * }
+ * }
+ * }
+ * @return An AuthenticationResult value.
+ * A promise that will be resolved to the above is also allowed.
+ */
+ allowConnection: prompt.Server.defaultAllowConnection,
+
+ /**
+ * The user must transfer some data through some out of band mechanism from
+ * the client to the server to authenticate the devices.
+ *
+ * @return An object containing:
+ * * sha256: hash(ClientCert)
+ * * k : K(random 128-bit number)
+ * A promise that will be resolved to the above is also allowed.
+ */
+ receiveOOB: prompt.Server.defaultReceiveOOB,
+
+};
+
+exports.Authenticators = {
+ get(mode) {
+ if (!mode) {
+ mode = Prompt.mode;
+ }
+ for (let key in Authenticators) {
+ let auth = Authenticators[key];
+ if (auth.mode === mode) {
+ return auth;
+ }
+ }
+ throw new Error("Unknown authenticator mode: " + mode);
+ }
+};
diff --git a/devtools/shared/security/cert.js b/devtools/shared/security/cert.js
new file mode 100644
index 000000000..7dbeded63
--- /dev/null
+++ b/devtools/shared/security/cert.js
@@ -0,0 +1,67 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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/. */
+
+"use strict";
+
+var { Ci, Cc } = require("chrome");
+var promise = require("promise");
+var defer = require("devtools/shared/defer");
+var DevToolsUtils = require("devtools/shared/DevToolsUtils");
+DevToolsUtils.defineLazyGetter(this, "localCertService", () => {
+ // Ensure PSM is initialized to support TLS sockets
+ Cc["@mozilla.org/psm;1"].getService(Ci.nsISupports);
+ return Cc["@mozilla.org/security/local-cert-service;1"]
+ .getService(Ci.nsILocalCertService);
+});
+
+const localCertName = "devtools";
+
+exports.local = {
+
+ /**
+ * Get or create a new self-signed X.509 cert to represent this device for
+ * DevTools purposes over a secure transport, like TLS.
+ *
+ * The cert is stored permanently in the profile's key store after first use,
+ * and is valid for 1 year. If an expired or otherwise invalid cert is found,
+ * it is removed and a new one is made.
+ *
+ * @return promise
+ */
+ getOrCreate() {
+ let deferred = defer();
+ localCertService.getOrCreateCert(localCertName, {
+ handleCert: function (cert, rv) {
+ if (rv) {
+ deferred.reject(rv);
+ return;
+ }
+ deferred.resolve(cert);
+ }
+ });
+ return deferred.promise;
+ },
+
+ /**
+ * Remove the DevTools self-signed X.509 cert for this device.
+ *
+ * @return promise
+ */
+ remove() {
+ let deferred = defer();
+ localCertService.removeCert(localCertName, {
+ handleCert: function (rv) {
+ if (rv) {
+ deferred.reject(rv);
+ return;
+ }
+ deferred.resolve();
+ }
+ });
+ return deferred.promise;
+ }
+
+};
diff --git a/devtools/shared/security/docs/wifi.md b/devtools/shared/security/docs/wifi.md
new file mode 100644
index 000000000..9ba94fc82
--- /dev/null
+++ b/devtools/shared/security/docs/wifi.md
@@ -0,0 +1,154 @@
+Overview
+--------
+
+### Remote Debugging Today
+
+Connecting to the Dev Tools debugging server on a remote device (like
+B2G) via USB (which requires ADB) is too complex to setup and use.
+Dealing with ADB is confusing, especially on Windows and Linux where
+there are driver issues / udev rules to set up first. We have made
+various attempts to simplify this and probably will continue to try our
+best, but it doesn't seem like the UX will ever be great with ADB
+involved.
+
+### Wi-Fi
+
+We're interested in making the debugging server available over Wi-Fi,
+mainly in an attempt to simplify the UX. This of course presents new
+security challenges to address, but we must also keep in mind that **if
+our plan to address security results in a complex UX, then it may not be
+a net gain over the USB & ADB route**.
+
+To be clear, we are not trying to expose ADB over Wi-Fi at all, only the
+Dev Tools debugging server.
+
+### Security
+
+TLS is used to provide encryption of the data in transit. Both parties
+use self-signed certs to identify themselves. There is a one-time setup
+process to authenticate a new device. This is explained in many more
+details later on in this document.
+
+Definitions
+-----------
+
+- **Device / Server**: Firefox OS phone (or Fennec, remote Firefox,
+ etc.)
+- **Computer / Client**: Machine running desktop Firefox w/ WebIDE
+
+Proposal
+--------
+
+This proposal uses TLS with self-signed certs to allow Clients to
+connect to Servers through an encrypted, authenticated channel. After the
+first connection from a new Client, the Client is saved on the Server
+(if the user wants to always allow) and can connect freely in the future
+(assuming Wi-Fi debugging is still enabled).
+
+### Default State
+
+The device does not listen over Wi-Fi at all by default.
+
+### Part A: Enabling Wi-Fi Debugging
+
+1. User goes to Developer menu on Device
+2. User checks "DevTools over Wi-Fi" to enable the feature
+ - Persistent notification displayed in notification bar reminding
+ user that this is enabled
+
+3. Device begins listening on random TCP socket via Wi-Fi only
+4. Device announces itself via service discovery
+ - Announcements only go to the local LAN / same subnet
+ - The announcement contains hash(DeviceCert) as additional data
+
+The Device remains listening as long as the feature is enabled.
+
+### Part B: Using Wi-Fi Debugging (new computer)
+
+Here are the details of connecting from a new computer to the device:
+
+1. Computer detects Device as available for connection via service
+ discovery
+2. User chooses device to start connection on Computer
+3. TLS connection established, authentication begins
+4. Device sees that ComputerCert is from an unknown client (since it is
+ new)
+5. User is shown an Allow / Deny / Always Allow prompt on the Device
+ with Computer name and hash(ComputerCert)
+ - If Deny is chosen, the connection is terminated and exponential
+ backoff begins (larger with each successive Deny)
+ - If Allow is chosen, the connection proceeds, but nothing is
+ stored for the future
+ - If Always Allow is chosen, the connection proceeds, and
+ hash(ComputerCert) is saved for future attempts
+
+6. Device waits for out-of-band data
+7. Computer verifies that Device's cert matches hash(DeviceCert) from
+ the advertisement
+8. Computer creates hash(ComputerCert) and K(random 128-bit number)
+9. Out-of-band channel is used to move result of step 8 from Computer
+ to Device
+ - For Firefox Desktop -\> Firefox OS, Desktop will make a QR code,
+ and FxOS will scan it
+ - For non-mobile servers, some other approach is likely needed,
+ perhaps a short code form for the user to transfer
+
+10. Device verifies that Computer's cert matches hash(ComputerCert) from
+ out-of-band channel
+11. Device sends K to Computer over the TLS connection
+12. Computer verifies received value matches K
+13. Debugging begins
+
+### Part C: Using Wi-Fi Debugging (known computer)
+
+Here are the details of connecting from a known computer (saved via
+Always Allow) to the device:
+
+1. Computer detects Device as available for connection via service
+ discovery
+2. User choose device to start connection on Computer
+3. TLS connection established, authentication begins
+4. Device sees that ComputerCert is from a known client via
+ hash(ComputerCert)
+5. Debugging begins
+
+### Other Details
+
+- When there is a socket listening for connections, they will only be
+ accepted via Wi-Fi
+ - The socket will only listen on the external, Wi-Fi interface
+ - This is to ensure local apps can't connect to the socket
+- Socket remains listening as long as the feature is enabled
+
+### UX
+
+This design seems convenient and of relatively low burden on the user.
+If they choose to save the Computer for future connections, it becomes a
+one click connection from Computer to Device, as it is over USB today.
+
+### Possible Attacks
+
+Someone could try to DoS the phone via many connection attempts. The
+exponential backoff should mitigate this concern. ([bug
+1022692](https://bugzilla.mozilla.org/show_bug.cgi?id=1022692))
+
+### Comparison to ADB
+
+While it would be nice if we could instead leverage ADB here, that
+doesn’t seem viable because:
+
+- ADB comes with a lot of setup / troubleshooting pain
+ - Users don’t understand it or why it is needed for us
+ - Each OS has several UX issues with ADB that make it annoying to
+ use
+- ADB has a much larger attack surface area, simply because it has
+ many more lower level functions than the Developer Tools protocol we
+ are exposing here
+
+Acknowledgments
+---------------
+
+- J. Ryan Stinnett started this project from the DevTools team
+- Brian Warner created many of the specific details of the authentication
+ protocol
+- Trevor Perrin helped vet the authentication protocol
diff --git a/devtools/shared/security/moz.build b/devtools/shared/security/moz.build
new file mode 100644
index 000000000..f4fd1ca1a
--- /dev/null
+++ b/devtools/shared/security/moz.build
@@ -0,0 +1,15 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+MOCHITEST_CHROME_MANIFESTS += ['tests/chrome/chrome.ini']
+XPCSHELL_TESTS_MANIFESTS += ['tests/unit/xpcshell.ini']
+
+DevToolsModules(
+ 'auth.js',
+ 'cert.js',
+ 'prompt.js',
+ 'socket.js',
+)
diff --git a/devtools/shared/security/prompt.js b/devtools/shared/security/prompt.js
new file mode 100644
index 000000000..aad9b7211
--- /dev/null
+++ b/devtools/shared/security/prompt.js
@@ -0,0 +1,179 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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/. */
+
+"use strict";
+
+var { Ci } = require("chrome");
+var Services = require("Services");
+var DevToolsUtils = require("devtools/shared/DevToolsUtils");
+loader.lazyRequireGetter(this, "DebuggerSocket",
+ "devtools/shared/security/socket", true);
+loader.lazyRequireGetter(this, "AuthenticationResult",
+ "devtools/shared/security/auth", true);
+
+const {LocalizationHelper} = require("devtools/shared/l10n");
+const L10N = new LocalizationHelper("devtools/shared/locales/debugger.properties");
+
+var Client = exports.Client = {};
+var Server = exports.Server = {};
+
+/**
+ * During OOB_CERT authentication, a notification dialog like this is used to
+ * to display a token which the user must transfer through some mechanism to the
+ * server to authenticate the devices.
+ *
+ * This implementation presents the token as text for the user to transfer
+ * manually. For a mobile device, you should override this implementation with
+ * something more convenient, such as displaying a QR code.
+ *
+ * @param host string
+ * The host name or IP address of the debugger server.
+ * @param port number
+ * The port number of the debugger server.
+ * @param cert object (optional)
+ * The server's cert details.
+ * @param authResult AuthenticationResult
+ * Authentication result sent from the server.
+ * @param oob object (optional)
+ * The token data to be transferred during OOB_CERT step 8:
+ * * sha256: hash(ClientCert)
+ * * k : K(random 128-bit number)
+ * @return object containing:
+ * * close: Function to hide the notification
+ */
+Client.defaultSendOOB = ({ authResult, oob }) => {
+ // Only show in the PENDING state
+ if (authResult != AuthenticationResult.PENDING) {
+ throw new Error("Expected PENDING result, got " + authResult);
+ }
+ let title = L10N.getStr("clientSendOOBTitle");
+ let header = L10N.getStr("clientSendOOBHeader");
+ let hashMsg = L10N.getFormatStr("clientSendOOBHash", oob.sha256);
+ let token = oob.sha256.replace(/:/g, "").toLowerCase() + oob.k;
+ let tokenMsg = L10N.getFormatStr("clientSendOOBToken", token);
+ let msg = `${header}\n\n${hashMsg}\n${tokenMsg}`;
+ let prompt = Services.prompt;
+ let flags = prompt.BUTTON_POS_0 * prompt.BUTTON_TITLE_CANCEL;
+
+ // Listen for the window our prompt opens, so we can close it programatically
+ let promptWindow;
+ let windowListener = {
+ onOpenWindow(xulWindow) {
+ let win = xulWindow.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIDOMWindow);
+ win.addEventListener("load", function listener() {
+ win.removeEventListener("load", listener, false);
+ if (win.document.documentElement.getAttribute("id") != "commonDialog") {
+ return;
+ }
+ // Found the window
+ promptWindow = win;
+ Services.wm.removeListener(windowListener);
+ }, false);
+ },
+ onCloseWindow() {},
+ onWindowTitleChange() {}
+ };
+ Services.wm.addListener(windowListener);
+
+ // nsIPrompt is typically a blocking API, so |executeSoon| to get around this
+ DevToolsUtils.executeSoon(() => {
+ prompt.confirmEx(null, title, msg, flags, null, null, null, null,
+ { value: false });
+ });
+
+ return {
+ close() {
+ if (!promptWindow) {
+ return;
+ }
+ promptWindow.document.documentElement.acceptDialog();
+ promptWindow = null;
+ }
+ };
+};
+
+/**
+ * Prompt the user to accept or decline the incoming connection. This is the
+ * default implementation that products embedding the debugger server may
+ * choose to override. This can be overridden via |allowConnection| on the
+ * socket's authenticator instance.
+ *
+ * @param session object
+ * The session object will contain at least the following fields:
+ * {
+ * authentication,
+ * client: {
+ * host,
+ * port
+ * },
+ * server: {
+ * host,
+ * port
+ * }
+ * }
+ * Specific authentication modes may include additional fields. Check
+ * the different |allowConnection| methods in ./auth.js.
+ * @return An AuthenticationResult value.
+ * A promise that will be resolved to the above is also allowed.
+ */
+Server.defaultAllowConnection = ({ client, server }) => {
+ let title = L10N.getStr("remoteIncomingPromptTitle");
+ let header = L10N.getStr("remoteIncomingPromptHeader");
+ let clientEndpoint = `${client.host}:${client.port}`;
+ let clientMsg = L10N.getFormatStr("remoteIncomingPromptClientEndpoint", clientEndpoint);
+ let serverEndpoint = `${server.host}:${server.port}`;
+ let serverMsg = L10N.getFormatStr("remoteIncomingPromptServerEndpoint", serverEndpoint);
+ let footer = L10N.getStr("remoteIncomingPromptFooter");
+ let msg = `${header}\n\n${clientMsg}\n${serverMsg}\n\n${footer}`;
+ let disableButton = L10N.getStr("remoteIncomingPromptDisable");
+ let prompt = Services.prompt;
+ let flags = prompt.BUTTON_POS_0 * prompt.BUTTON_TITLE_OK +
+ prompt.BUTTON_POS_1 * prompt.BUTTON_TITLE_CANCEL +
+ prompt.BUTTON_POS_2 * prompt.BUTTON_TITLE_IS_STRING +
+ prompt.BUTTON_POS_1_DEFAULT;
+ let result = prompt.confirmEx(null, title, msg, flags, null, null,
+ disableButton, null, { value: false });
+ if (result === 0) {
+ return AuthenticationResult.ALLOW;
+ }
+ if (result === 2) {
+ return AuthenticationResult.DISABLE_ALL;
+ }
+ return AuthenticationResult.DENY;
+};
+
+/**
+ * During OOB_CERT authentication, the user must transfer some data through some
+ * out of band mechanism from the client to the server to authenticate the
+ * devices.
+ *
+ * This implementation prompts the user for a token as constructed by
+ * |Client.defaultSendOOB| that the user needs to transfer manually. For a
+ * mobile device, you should override this implementation with something more
+ * convenient, such as reading a QR code.
+ *
+ * @return An object containing:
+ * * sha256: hash(ClientCert)
+ * * k : K(random 128-bit number)
+ * A promise that will be resolved to the above is also allowed.
+ */
+Server.defaultReceiveOOB = () => {
+ let title = L10N.getStr("serverReceiveOOBTitle");
+ let msg = L10N.getStr("serverReceiveOOBBody");
+ let input = { value: null };
+ let prompt = Services.prompt;
+ let result = prompt.prompt(null, title, msg, input, null, { value: false });
+ if (!result) {
+ return null;
+ }
+ // Re-create original object from token
+ input = input.value.trim();
+ let sha256 = input.substring(0, 64);
+ sha256 = sha256.replace(/\w{2}/g, "$&:").slice(0, -1).toUpperCase();
+ let k = input.substring(64);
+ return { sha256, k };
+};
diff --git a/devtools/shared/security/socket.js b/devtools/shared/security/socket.js
new file mode 100644
index 000000000..068a8ea81
--- /dev/null
+++ b/devtools/shared/security/socket.js
@@ -0,0 +1,793 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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/. */
+
+"use strict";
+
+var { Ci, Cc, CC, Cr, Cu } = require("chrome");
+
+// Ensure PSM is initialized to support TLS sockets
+Cc["@mozilla.org/psm;1"].getService(Ci.nsISupports);
+
+var Services = require("Services");
+var promise = require("promise");
+var defer = require("devtools/shared/defer");
+var DevToolsUtils = require("devtools/shared/DevToolsUtils");
+var { dumpn, dumpv } = DevToolsUtils;
+loader.lazyRequireGetter(this, "WebSocketServer",
+ "devtools/server/websocket-server");
+loader.lazyRequireGetter(this, "DebuggerTransport",
+ "devtools/shared/transport/transport", true);
+loader.lazyRequireGetter(this, "WebSocketDebuggerTransport",
+ "devtools/shared/transport/websocket-transport");
+loader.lazyRequireGetter(this, "DebuggerServer",
+ "devtools/server/main", true);
+loader.lazyRequireGetter(this, "discovery",
+ "devtools/shared/discovery/discovery");
+loader.lazyRequireGetter(this, "cert",
+ "devtools/shared/security/cert");
+loader.lazyRequireGetter(this, "Authenticators",
+ "devtools/shared/security/auth", true);
+loader.lazyRequireGetter(this, "AuthenticationResult",
+ "devtools/shared/security/auth", true);
+
+DevToolsUtils.defineLazyGetter(this, "nsFile", () => {
+ return CC("@mozilla.org/file/local;1", "nsIFile", "initWithPath");
+});
+
+DevToolsUtils.defineLazyGetter(this, "socketTransportService", () => {
+ return Cc["@mozilla.org/network/socket-transport-service;1"]
+ .getService(Ci.nsISocketTransportService);
+});
+
+DevToolsUtils.defineLazyGetter(this, "certOverrideService", () => {
+ return Cc["@mozilla.org/security/certoverride;1"]
+ .getService(Ci.nsICertOverrideService);
+});
+
+DevToolsUtils.defineLazyGetter(this, "nssErrorsService", () => {
+ return Cc["@mozilla.org/nss_errors_service;1"]
+ .getService(Ci.nsINSSErrorsService);
+});
+
+const { Task } = require("devtools/shared/task");
+
+var DebuggerSocket = {};
+
+/**
+ * Connects to a debugger server socket.
+ *
+ * @param host string
+ * The host name or IP address of the debugger server.
+ * @param port number
+ * The port number of the debugger server.
+ * @param encryption boolean (optional)
+ * Whether the server requires encryption. Defaults to false.
+ * @param webSocket boolean (optional)
+ * Whether to use WebSocket protocol to connect. Defaults to false.
+ * @param authenticator Authenticator (optional)
+ * |Authenticator| instance matching the mode in use by the server.
+ * Defaults to a PROMPT instance if not supplied.
+ * @param cert object (optional)
+ * The server's cert details. Used with OOB_CERT authentication.
+ * @return promise
+ * Resolved to a DebuggerTransport instance.
+ */
+DebuggerSocket.connect = Task.async(function* (settings) {
+ // Default to PROMPT |Authenticator| instance if not supplied
+ if (!settings.authenticator) {
+ settings.authenticator = new (Authenticators.get().Client)();
+ }
+ _validateSettings(settings);
+ let { host, port, encryption, authenticator, cert } = settings;
+ let transport = yield _getTransport(settings);
+ yield authenticator.authenticate({
+ host,
+ port,
+ encryption,
+ cert,
+ transport
+ });
+ transport.connectionSettings = settings;
+ return transport;
+});
+
+/**
+ * Validate that the connection settings have been set to a supported configuration.
+ */
+function _validateSettings(settings) {
+ let { encryption, webSocket, authenticator } = settings;
+
+ if (webSocket && encryption) {
+ throw new Error("Encryption not supported on WebSocket transport");
+ }
+ authenticator.validateSettings(settings);
+}
+
+/**
+ * Try very hard to create a DevTools transport, potentially making several
+ * connect attempts in the process.
+ *
+ * @param host string
+ * The host name or IP address of the debugger server.
+ * @param port number
+ * The port number of the debugger server.
+ * @param encryption boolean (optional)
+ * Whether the server requires encryption. Defaults to false.
+ * @param webSocket boolean (optional)
+ * Whether to use WebSocket protocol to connect to the server. Defaults to false.
+ * @param authenticator Authenticator
+ * |Authenticator| instance matching the mode in use by the server.
+ * Defaults to a PROMPT instance if not supplied.
+ * @param cert object (optional)
+ * The server's cert details. Used with OOB_CERT authentication.
+ * @return transport DebuggerTransport
+ * A possible DevTools transport (if connection succeeded and streams
+ * are actually alive and working)
+ */
+var _getTransport = Task.async(function* (settings) {
+ let { host, port, encryption, webSocket } = settings;
+
+ if (webSocket) {
+ // Establish a connection and wait until the WebSocket is ready to send and receive
+ let socket = yield new Promise((resolve, reject) => {
+ let s = new WebSocket(`ws://${host}:${port}`);
+ s.onopen = () => resolve(s);
+ s.onerror = err => reject(err);
+ });
+
+ return new WebSocketDebuggerTransport(socket);
+ }
+
+ let attempt = yield _attemptTransport(settings);
+ if (attempt.transport) {
+ return attempt.transport; // Success
+ }
+
+ // If the server cert failed validation, store a temporary override and make
+ // a second attempt.
+ if (encryption && attempt.certError) {
+ _storeCertOverride(attempt.s, host, port);
+ } else {
+ throw new Error("Connection failed");
+ }
+
+ attempt = yield _attemptTransport(settings);
+ if (attempt.transport) {
+ return attempt.transport; // Success
+ }
+
+ throw new Error("Connection failed even after cert override");
+});
+
+/**
+ * Make a single attempt to connect and create a DevTools transport. This could
+ * fail if the remote host is unreachable, for example. If there is security
+ * error due to the use of self-signed certs, you should make another attempt
+ * after storing a cert override.
+ *
+ * @param host string
+ * The host name or IP address of the debugger server.
+ * @param port number
+ * The port number of the debugger server.
+ * @param encryption boolean (optional)
+ * Whether the server requires encryption. Defaults to false.
+ * @param authenticator Authenticator
+ * |Authenticator| instance matching the mode in use by the server.
+ * Defaults to a PROMPT instance if not supplied.
+ * @param cert object (optional)
+ * The server's cert details. Used with OOB_CERT authentication.
+ * @return transport DebuggerTransport
+ * A possible DevTools transport (if connection succeeded and streams
+ * are actually alive and working)
+ * @return certError boolean
+ * Flag noting if cert trouble caused the streams to fail
+ * @return s nsISocketTransport
+ * Underlying socket transport, in case more details are needed.
+ */
+var _attemptTransport = Task.async(function* (settings) {
+ let { authenticator } = settings;
+ // _attemptConnect only opens the streams. Any failures at that stage
+ // aborts the connection process immedidately.
+ let { s, input, output } = yield _attemptConnect(settings);
+
+ // Check if the input stream is alive. If encryption is enabled, we need to
+ // watch out for cert errors by testing the input stream.
+ let alive, certError;
+ try {
+ let results = yield _isInputAlive(input);
+ alive = results.alive;
+ certError = results.certError;
+ } catch (e) {
+ // For other unexpected errors, like NS_ERROR_CONNECTION_REFUSED, we reach
+ // this block.
+ input.close();
+ output.close();
+ throw e;
+ }
+ dumpv("Server cert accepted? " + !certError);
+
+ // The |Authenticator| examines the connection as well and may determine it
+ // should be dropped.
+ alive = alive && authenticator.validateConnection({
+ host: settings.host,
+ port: settings.port,
+ encryption: settings.encryption,
+ cert: settings.cert,
+ socket: s
+ });
+
+ let transport;
+ if (alive) {
+ transport = new DebuggerTransport(input, output);
+ } else {
+ // Something went wrong, close the streams.
+ input.close();
+ output.close();
+ }
+
+ return { transport, certError, s };
+});
+
+/**
+ * Try to connect to a remote server socket.
+ *
+ * If successsful, the socket transport and its opened streams are returned.
+ * Typically, this will only fail if the host / port is unreachable. Other
+ * problems, such as security errors, will allow this stage to succeed, but then
+ * fail later when the streams are actually used.
+ * @return s nsISocketTransport
+ * Underlying socket transport, in case more details are needed.
+ * @return input nsIAsyncInputStream
+ * The socket's input stream.
+ * @return output nsIAsyncOutputStream
+ * The socket's output stream.
+ */
+var _attemptConnect = Task.async(function* ({ host, port, encryption }) {
+ let s;
+ if (encryption) {
+ s = socketTransportService.createTransport(["ssl"], 1, host, port, null);
+ } else {
+ s = socketTransportService.createTransport(null, 0, host, port, null);
+ }
+ // By default the CONNECT socket timeout is very long, 65535 seconds,
+ // so that if we race to be in CONNECT state while the server socket is still
+ // initializing, the connection is stuck in connecting state for 18.20 hours!
+ s.setTimeout(Ci.nsISocketTransport.TIMEOUT_CONNECT, 2);
+
+ // If encrypting, load the client cert now, so we can deliver it at just the
+ // right time.
+ let clientCert;
+ if (encryption) {
+ clientCert = yield cert.local.getOrCreate();
+ }
+
+ let deferred = defer();
+ let input;
+ let output;
+ // Delay opening the input stream until the transport has fully connected.
+ // The goal is to avoid showing the user a client cert UI prompt when
+ // encryption is used. This prompt is shown when the client opens the input
+ // stream and does not know which client cert to present to the server. To
+ // specify a client cert programmatically, we need to access the transport's
+ // nsISSLSocketControl interface, which is not accessible until the transport
+ // has connected.
+ s.setEventSink({
+ onTransportStatus(transport, status) {
+ if (status != Ci.nsISocketTransport.STATUS_CONNECTING_TO) {
+ return;
+ }
+ if (encryption) {
+ let sslSocketControl =
+ transport.securityInfo.QueryInterface(Ci.nsISSLSocketControl);
+ sslSocketControl.clientCert = clientCert;
+ }
+ try {
+ input = s.openInputStream(0, 0, 0);
+ } catch (e) {
+ deferred.reject(e);
+ }
+ deferred.resolve({ s, input, output });
+ }
+ }, Services.tm.currentThread);
+
+ // openOutputStream may throw NS_ERROR_NOT_INITIALIZED if we hit some race
+ // where the nsISocketTransport gets shutdown in between its instantiation and
+ // the call to this method.
+ try {
+ output = s.openOutputStream(0, 0, 0);
+ } catch (e) {
+ deferred.reject(e);
+ }
+
+ deferred.promise.catch(e => {
+ if (input) {
+ input.close();
+ }
+ if (output) {
+ output.close();
+ }
+ DevToolsUtils.reportException("_attemptConnect", e);
+ });
+
+ return deferred.promise;
+});
+
+/**
+ * Check if the input stream is alive. For an encrypted connection, it may not
+ * be if the client refuses the server's cert. A cert error is expected on
+ * first connection to a new host because the cert is self-signed.
+ */
+function _isInputAlive(input) {
+ let deferred = defer();
+ input.asyncWait({
+ onInputStreamReady(stream) {
+ try {
+ stream.available();
+ deferred.resolve({ alive: true });
+ } catch (e) {
+ try {
+ // getErrorClass may throw if you pass a non-NSS error
+ let errorClass = nssErrorsService.getErrorClass(e.result);
+ if (errorClass === Ci.nsINSSErrorsService.ERROR_CLASS_BAD_CERT) {
+ deferred.resolve({ certError: true });
+ } else {
+ deferred.reject(e);
+ }
+ } catch (nssErr) {
+ deferred.reject(e);
+ }
+ }
+ }
+ }, 0, 0, Services.tm.currentThread);
+ return deferred.promise;
+}
+
+/**
+ * To allow the connection to proceed with self-signed cert, we store a cert
+ * override. This implies that we take on the burden of authentication for
+ * these connections.
+ */
+function _storeCertOverride(s, host, port) {
+ let cert = s.securityInfo.QueryInterface(Ci.nsISSLStatusProvider)
+ .SSLStatus.serverCert;
+ let overrideBits = Ci.nsICertOverrideService.ERROR_UNTRUSTED |
+ Ci.nsICertOverrideService.ERROR_MISMATCH;
+ certOverrideService.rememberValidityOverride(host, port, cert, overrideBits,
+ true /* temporary */);
+}
+
+/**
+ * Creates a new socket listener for remote connections to the DebuggerServer.
+ * This helps contain and organize the parts of the server that may differ or
+ * are particular to one given listener mechanism vs. another.
+ */
+function SocketListener() {}
+
+SocketListener.prototype = {
+
+ /* Socket Options */
+
+ /**
+ * The port or path to listen on.
+ *
+ * If given an integer, the port to listen on. Use -1 to choose any available
+ * port. Otherwise, the path to the unix socket domain file to listen on.
+ */
+ portOrPath: null,
+
+ /**
+ * Controls whether this listener is announced via the service discovery
+ * mechanism.
+ */
+ discoverable: false,
+
+ /**
+ * Controls whether this listener's transport uses encryption.
+ */
+ encryption: false,
+
+ /**
+ * Controls the |Authenticator| used, which hooks various socket steps to
+ * implement an authentication policy. It is expected that different use
+ * cases may override pieces of the |Authenticator|. See auth.js.
+ *
+ * Here we set the default |Authenticator|, which is |Prompt|.
+ */
+ authenticator: new (Authenticators.get().Server)(),
+
+ /**
+ * Validate that all options have been set to a supported configuration.
+ */
+ _validateOptions: function () {
+ if (this.portOrPath === null) {
+ throw new Error("Must set a port / path to listen on.");
+ }
+ if (this.discoverable && !Number(this.portOrPath)) {
+ throw new Error("Discovery only supported for TCP sockets.");
+ }
+ if (this.encryption && this.webSocket) {
+ throw new Error("Encryption not supported on WebSocket transport");
+ }
+ this.authenticator.validateOptions(this);
+ },
+
+ /**
+ * Listens on the given port or socket file for remote debugger connections.
+ */
+ open: function () {
+ this._validateOptions();
+ DebuggerServer._addListener(this);
+
+ let flags = Ci.nsIServerSocket.KeepWhenOffline;
+ // A preference setting can force binding on the loopback interface.
+ if (Services.prefs.getBoolPref("devtools.debugger.force-local")) {
+ flags |= Ci.nsIServerSocket.LoopbackOnly;
+ }
+
+ let self = this;
+ return Task.spawn(function* () {
+ let backlog = 4;
+ self._socket = self._createSocketInstance();
+ if (self.isPortBased) {
+ let port = Number(self.portOrPath);
+ self._socket.initSpecialConnection(port, flags, backlog);
+ } else {
+ let file = nsFile(self.portOrPath);
+ if (file.exists()) {
+ file.remove(false);
+ }
+ self._socket.initWithFilename(file, parseInt("666", 8), backlog);
+ }
+ yield self._setAdditionalSocketOptions();
+ self._socket.asyncListen(self);
+ dumpn("Socket listening on: " + (self.port || self.portOrPath));
+ }).then(() => {
+ this._advertise();
+ }).catch(e => {
+ dumpn("Could not start debugging listener on '" + this.portOrPath +
+ "': " + e);
+ this.close();
+ });
+ },
+
+ _advertise: function () {
+ if (!this.discoverable || !this.port) {
+ return;
+ }
+
+ let advertisement = {
+ port: this.port,
+ encryption: this.encryption,
+ };
+
+ this.authenticator.augmentAdvertisement(this, advertisement);
+
+ discovery.addService("devtools", advertisement);
+ },
+
+ _createSocketInstance: function () {
+ if (this.encryption) {
+ return Cc["@mozilla.org/network/tls-server-socket;1"]
+ .createInstance(Ci.nsITLSServerSocket);
+ }
+ return Cc["@mozilla.org/network/server-socket;1"]
+ .createInstance(Ci.nsIServerSocket);
+ },
+
+ _setAdditionalSocketOptions: Task.async(function* () {
+ if (this.encryption) {
+ this._socket.serverCert = yield cert.local.getOrCreate();
+ this._socket.setSessionCache(false);
+ this._socket.setSessionTickets(false);
+ let requestCert = Ci.nsITLSServerSocket.REQUEST_NEVER;
+ this._socket.setRequestClientCertificate(requestCert);
+ }
+ this.authenticator.augmentSocketOptions(this, this._socket);
+ }),
+
+ /**
+ * Closes the SocketListener. Notifies the server to remove the listener from
+ * the set of active SocketListeners.
+ */
+ close: function () {
+ if (this.discoverable && this.port) {
+ discovery.removeService("devtools");
+ }
+ if (this._socket) {
+ this._socket.close();
+ this._socket = null;
+ }
+ DebuggerServer._removeListener(this);
+ },
+
+ get host() {
+ if (!this._socket) {
+ return null;
+ }
+ if (Services.prefs.getBoolPref("devtools.debugger.force-local")) {
+ return "127.0.0.1";
+ }
+ return "0.0.0.0";
+ },
+
+ /**
+ * Gets whether this listener uses a port number vs. a path.
+ */
+ get isPortBased() {
+ return !!Number(this.portOrPath);
+ },
+
+ /**
+ * Gets the port that a TCP socket listener is listening on, or null if this
+ * is not a TCP socket (so there is no port).
+ */
+ get port() {
+ if (!this.isPortBased || !this._socket) {
+ return null;
+ }
+ return this._socket.port;
+ },
+
+ get cert() {
+ if (!this._socket || !this._socket.serverCert) {
+ return null;
+ }
+ return {
+ sha256: this._socket.serverCert.sha256Fingerprint
+ };
+ },
+
+ // nsIServerSocketListener implementation
+
+ onSocketAccepted:
+ DevToolsUtils.makeInfallible(function (socket, socketTransport) {
+ new ServerSocketConnection(this, socketTransport);
+ }, "SocketListener.onSocketAccepted"),
+
+ onStopListening: function (socket, status) {
+ dumpn("onStopListening, status: " + status);
+ }
+
+};
+
+// Client must complete TLS handshake within this window (ms)
+loader.lazyGetter(this, "HANDSHAKE_TIMEOUT", () => {
+ return Services.prefs.getIntPref("devtools.remote.tls-handshake-timeout");
+});
+
+/**
+ * A |ServerSocketConnection| is created by a |SocketListener| for each accepted
+ * incoming socket. This is a short-lived object used to implement
+ * authentication and verify encryption prior to handing off the connection to
+ * the |DebuggerServer|.
+ */
+function ServerSocketConnection(listener, socketTransport) {
+ this._listener = listener;
+ this._socketTransport = socketTransport;
+ this._handle();
+}
+
+ServerSocketConnection.prototype = {
+
+ get authentication() {
+ return this._listener.authenticator.mode;
+ },
+
+ get host() {
+ return this._socketTransport.host;
+ },
+
+ get port() {
+ return this._socketTransport.port;
+ },
+
+ get cert() {
+ if (!this._clientCert) {
+ return null;
+ }
+ return {
+ sha256: this._clientCert.sha256Fingerprint
+ };
+ },
+
+ get address() {
+ return this.host + ":" + this.port;
+ },
+
+ get client() {
+ let client = {
+ host: this.host,
+ port: this.port
+ };
+ if (this.cert) {
+ client.cert = this.cert;
+ }
+ return client;
+ },
+
+ get server() {
+ let server = {
+ host: this._listener.host,
+ port: this._listener.port
+ };
+ if (this._listener.cert) {
+ server.cert = this._listener.cert;
+ }
+ return server;
+ },
+
+ /**
+ * This is the main authentication workflow. If any pieces reject a promise,
+ * the connection is denied. If the entire process resolves successfully,
+ * the connection is finally handed off to the |DebuggerServer|.
+ */
+ _handle() {
+ dumpn("Debugging connection starting authentication on " + this.address);
+ let self = this;
+ Task.spawn(function* () {
+ self._listenForTLSHandshake();
+ yield self._createTransport();
+ yield self._awaitTLSHandshake();
+ yield self._authenticate();
+ }).then(() => this.allow()).catch(e => this.deny(e));
+ },
+
+ /**
+ * We need to open the streams early on, as that is required in the case of
+ * TLS sockets to keep the handshake moving.
+ */
+ _createTransport: Task.async(function* () {
+ let input = this._socketTransport.openInputStream(0, 0, 0);
+ let output = this._socketTransport.openOutputStream(0, 0, 0);
+
+ if (this._listener.webSocket) {
+ let socket = yield WebSocketServer.accept(this._socketTransport, input, output);
+ this._transport = new WebSocketDebuggerTransport(socket);
+ } else {
+ this._transport = new DebuggerTransport(input, output);
+ }
+
+ // Start up the transport to observe the streams in case they are closed
+ // early. This allows us to clean up our state as well.
+ this._transport.hooks = {
+ onClosed: reason => {
+ this.deny(reason);
+ }
+ };
+ this._transport.ready();
+ }),
+
+ /**
+ * Set the socket's security observer, which receives an event via the
+ * |onHandshakeDone| callback when the TLS handshake completes.
+ */
+ _setSecurityObserver(observer) {
+ if (!this._socketTransport || !this._socketTransport.securityInfo) {
+ return;
+ }
+ let connectionInfo = this._socketTransport.securityInfo
+ .QueryInterface(Ci.nsITLSServerConnectionInfo);
+ connectionInfo.setSecurityObserver(observer);
+ },
+
+ /**
+ * When encryption is used, we wait for the client to complete the TLS
+ * handshake before proceeding. The handshake details are validated in
+ * |onHandshakeDone|.
+ */
+ _listenForTLSHandshake() {
+ this._handshakeDeferred = defer();
+ if (!this._listener.encryption) {
+ this._handshakeDeferred.resolve();
+ return;
+ }
+ this._setSecurityObserver(this);
+ this._handshakeTimeout = setTimeout(this._onHandshakeTimeout.bind(this),
+ HANDSHAKE_TIMEOUT);
+ },
+
+ _awaitTLSHandshake() {
+ return this._handshakeDeferred.promise;
+ },
+
+ _onHandshakeTimeout() {
+ dumpv("Client failed to complete TLS handshake");
+ this._handshakeDeferred.reject(Cr.NS_ERROR_NET_TIMEOUT);
+ },
+
+ // nsITLSServerSecurityObserver implementation
+ onHandshakeDone(socket, clientStatus) {
+ clearTimeout(this._handshakeTimeout);
+ this._setSecurityObserver(null);
+ dumpv("TLS version: " + clientStatus.tlsVersionUsed.toString(16));
+ dumpv("TLS cipher: " + clientStatus.cipherName);
+ dumpv("TLS key length: " + clientStatus.keyLength);
+ dumpv("TLS MAC length: " + clientStatus.macLength);
+ this._clientCert = clientStatus.peerCert;
+ /*
+ * TODO: These rules should be really be set on the TLS socket directly, but
+ * this would need more platform work to expose it via XPCOM.
+ *
+ * Enforcing cipher suites here would be a bad idea, as we want TLS
+ * cipher negotiation to work correctly. The server already allows only
+ * Gecko's normal set of cipher suites.
+ */
+ if (clientStatus.tlsVersionUsed < Ci.nsITLSClientStatus.TLS_VERSION_1_2) {
+ this._handshakeDeferred.reject(Cr.NS_ERROR_CONNECTION_REFUSED);
+ return;
+ }
+
+ this._handshakeDeferred.resolve();
+ },
+
+ _authenticate: Task.async(function* () {
+ let result = yield this._listener.authenticator.authenticate({
+ client: this.client,
+ server: this.server,
+ transport: this._transport
+ });
+ switch (result) {
+ case AuthenticationResult.DISABLE_ALL:
+ DebuggerServer.closeAllListeners();
+ Services.prefs.setBoolPref("devtools.debugger.remote-enabled", false);
+ return promise.reject(Cr.NS_ERROR_CONNECTION_REFUSED);
+ case AuthenticationResult.DENY:
+ return promise.reject(Cr.NS_ERROR_CONNECTION_REFUSED);
+ case AuthenticationResult.ALLOW:
+ case AuthenticationResult.ALLOW_PERSIST:
+ return promise.resolve();
+ default:
+ return promise.reject(Cr.NS_ERROR_CONNECTION_REFUSED);
+ }
+ }),
+
+ deny(result) {
+ if (this._destroyed) {
+ return;
+ }
+ let errorName = result;
+ for (let name in Cr) {
+ if (Cr[name] === result) {
+ errorName = name;
+ break;
+ }
+ }
+ dumpn("Debugging connection denied on " + this.address +
+ " (" + errorName + ")");
+ if (this._transport) {
+ this._transport.hooks = null;
+ this._transport.close(result);
+ }
+ this._socketTransport.close(result);
+ this.destroy();
+ },
+
+ allow() {
+ if (this._destroyed) {
+ return;
+ }
+ dumpn("Debugging connection allowed on " + this.address);
+ DebuggerServer._onConnection(this._transport);
+ this.destroy();
+ },
+
+ destroy() {
+ this._destroyed = true;
+ clearTimeout(this._handshakeTimeout);
+ this._setSecurityObserver(null);
+ this._listener = null;
+ this._socketTransport = null;
+ this._transport = null;
+ this._clientCert = null;
+ }
+
+};
+
+DebuggerSocket.createListener = function () {
+ return new SocketListener();
+};
+
+exports.DebuggerSocket = DebuggerSocket;
diff --git a/devtools/shared/security/tests/chrome/chrome.ini b/devtools/shared/security/tests/chrome/chrome.ini
new file mode 100644
index 000000000..581251103
--- /dev/null
+++ b/devtools/shared/security/tests/chrome/chrome.ini
@@ -0,0 +1,4 @@
+[DEFAULT]
+tags = devtools
+
+[test_websocket-transport.html]
diff --git a/devtools/shared/security/tests/chrome/test_websocket-transport.html b/devtools/shared/security/tests/chrome/test_websocket-transport.html
new file mode 100644
index 000000000..542206ecf
--- /dev/null
+++ b/devtools/shared/security/tests/chrome/test_websocket-transport.html
@@ -0,0 +1,76 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test the WebSocket debugger transport</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SpawnTask.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+</head>
+<body>
+<script>
+window.onload = function() {
+ const {require} = Components.utils.import("resource://devtools/shared/Loader.jsm", {});
+ const Services = require("Services");
+ const {DebuggerClient} = require("devtools/shared/client/main");
+ const {DebuggerServer} = require("devtools/server/main");
+
+ Services.prefs.setBoolPref("devtools.debugger.remote-enabled", true);
+ Services.prefs.setBoolPref("devtools.debugger.prompt-connection", false);
+
+ SimpleTest.registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("devtools.debugger.remote-enabled");
+ Services.prefs.clearUserPref("devtools.debugger.prompt-connection");
+ });
+
+ add_task(function* () {
+ if (!DebuggerServer.initialized) {
+ DebuggerServer.init();
+ DebuggerServer.addBrowserActors();
+ }
+
+ is(DebuggerServer.listeningSockets, 0, "0 listening sockets");
+
+ let listener = DebuggerServer.createListener();
+ ok(listener, "Socket listener created");
+ listener.portOrPath = -1;
+ listener.webSocket = true;
+ yield listener.open();
+ is(DebuggerServer.listeningSockets, 1, "1 listening socket");
+
+ let transport = yield DebuggerClient.socketConnect({
+ host: "127.0.0.1",
+ port: listener.port,
+ webSocket: true
+ });
+ ok(transport, "Client transport created");
+
+ let client = new DebuggerClient(transport);
+ let onUnexpectedClose = () => {
+ do_throw("Closed unexpectedly");
+ };
+ client.addListener("closed", onUnexpectedClose);
+
+ yield client.connect();
+ yield client.listTabs();
+
+ // Send a message the server that will echo back
+ let message = "message";
+ let reply = yield client.request({
+ to: "root",
+ type: "echo",
+ message
+ });
+ is(reply.message, message, "Echo message matches");
+
+ client.removeListener("closed", onUnexpectedClose);
+ transport.close();
+ listener.close();
+ is(DebuggerServer.listeningSockets, 0, "0 listening sockets");
+
+ DebuggerServer.destroy();
+ });
+}
+</script>
+</body>
+</html>
diff --git a/devtools/shared/security/tests/unit/.eslintrc.js b/devtools/shared/security/tests/unit/.eslintrc.js
new file mode 100644
index 000000000..59adf410a
--- /dev/null
+++ b/devtools/shared/security/tests/unit/.eslintrc.js
@@ -0,0 +1,6 @@
+"use strict";
+
+module.exports = {
+ // Extend from the common devtools xpcshell eslintrc config.
+ "extends": "../../../../.eslintrc.xpcshell.js"
+};
diff --git a/devtools/shared/security/tests/unit/head_dbg.js b/devtools/shared/security/tests/unit/head_dbg.js
new file mode 100644
index 000000000..f3b2a9a97
--- /dev/null
+++ b/devtools/shared/security/tests/unit/head_dbg.js
@@ -0,0 +1,96 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+var Cc = Components.classes;
+var Ci = Components.interfaces;
+var Cu = Components.utils;
+var Cr = Components.results;
+var CC = Components.Constructor;
+
+const { require } =
+ Cu.import("resource://devtools/shared/Loader.jsm", {});
+const promise = require("promise");
+const defer = require("devtools/shared/defer");
+const { Task } = require("devtools/shared/task");
+
+const Services = require("Services");
+const DevToolsUtils = require("devtools/shared/DevToolsUtils");
+const xpcInspector = require("xpcInspector");
+const { DebuggerServer } = require("devtools/server/main");
+const { DebuggerClient } = require("devtools/shared/client/main");
+
+// We do not want to log packets by default, because in some tests,
+// we can be sending large amounts of data. The test harness has
+// trouble dealing with logging all the data, and we end up with
+// intermittent time outs (e.g. bug 775924).
+// Services.prefs.setBoolPref("devtools.debugger.log", true);
+// Services.prefs.setBoolPref("devtools.debugger.log.verbose", true);
+// Enable remote debugging for the relevant tests.
+Services.prefs.setBoolPref("devtools.debugger.remote-enabled", true);
+// Fast timeout for TLS tests
+Services.prefs.setIntPref("devtools.remote.tls-handshake-timeout", 1000);
+
+// Convert an nsIScriptError 'aFlags' value into an appropriate string.
+function scriptErrorFlagsToKind(aFlags) {
+ var kind;
+ if (aFlags & Ci.nsIScriptError.warningFlag)
+ kind = "warning";
+ if (aFlags & Ci.nsIScriptError.exceptionFlag)
+ kind = "exception";
+ else
+ kind = "error";
+
+ if (aFlags & Ci.nsIScriptError.strictFlag)
+ kind = "strict " + kind;
+
+ return kind;
+}
+
+// Register a console listener, so console messages don't just disappear
+// into the ether.
+var errorCount = 0;
+var listener = {
+ observe: function (aMessage) {
+ errorCount++;
+ try {
+ // If we've been given an nsIScriptError, then we can print out
+ // something nicely formatted, for tools like Emacs to pick up.
+ var scriptError = aMessage.QueryInterface(Ci.nsIScriptError);
+ dump(aMessage.sourceName + ":" + aMessage.lineNumber + ": " +
+ scriptErrorFlagsToKind(aMessage.flags) + ": " +
+ aMessage.errorMessage + "\n");
+ var string = aMessage.errorMessage;
+ } catch (x) {
+ // Be a little paranoid with message, as the whole goal here is to lose
+ // no information.
+ try {
+ var string = "" + aMessage.message;
+ } catch (x) {
+ var string = "<error converting error message to string>";
+ }
+ }
+
+ // Make sure we exit all nested event loops so that the test can finish.
+ while (xpcInspector.eventLoopNestLevel > 0) {
+ xpcInspector.exitNestedEventLoop();
+ }
+
+ // Print in most cases, but ignore the "strict" messages
+ if (!(aMessage.flags & Ci.nsIScriptError.strictFlag)) {
+ do_print("head_dbg.js got console message: " + string + "\n");
+ }
+ }
+};
+
+var consoleService = Cc["@mozilla.org/consoleservice;1"]
+ .getService(Ci.nsIConsoleService);
+consoleService.registerListener(listener);
+
+/**
+ * Initialize the testing debugger server.
+ */
+function initTestDebuggerServer() {
+ DebuggerServer.registerModule("xpcshell-test/testactors");
+ DebuggerServer.init();
+}
diff --git a/devtools/shared/security/tests/unit/test_encryption.js b/devtools/shared/security/tests/unit/test_encryption.js
new file mode 100644
index 000000000..e7fc80f5d
--- /dev/null
+++ b/devtools/shared/security/tests/unit/test_encryption.js
@@ -0,0 +1,110 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test basic functionality of DevTools client and server TLS encryption mode
+function run_test() {
+ // Need profile dir to store the key / cert
+ do_get_profile();
+ // Ensure PSM is initialized
+ Cc["@mozilla.org/psm;1"].getService(Ci.nsISupports);
+ run_next_test();
+}
+
+function connectClient(client) {
+ let deferred = defer();
+ client.connect(() => {
+ client.listTabs(deferred.resolve);
+ });
+ return deferred.promise;
+}
+
+add_task(function* () {
+ initTestDebuggerServer();
+});
+
+// Client w/ encryption connects successfully to server w/ encryption
+add_task(function* () {
+ equal(DebuggerServer.listeningSockets, 0, "0 listening sockets");
+
+ let AuthenticatorType = DebuggerServer.Authenticators.get("PROMPT");
+ let authenticator = new AuthenticatorType.Server();
+ authenticator.allowConnection = () => {
+ return DebuggerServer.AuthenticationResult.ALLOW;
+ };
+
+ let listener = DebuggerServer.createListener();
+ ok(listener, "Socket listener created");
+ listener.portOrPath = -1;
+ listener.authenticator = authenticator;
+ listener.encryption = true;
+ yield listener.open();
+ equal(DebuggerServer.listeningSockets, 1, "1 listening socket");
+
+ let transport = yield DebuggerClient.socketConnect({
+ host: "127.0.0.1",
+ port: listener.port,
+ encryption: true
+ });
+ ok(transport, "Client transport created");
+
+ let client = new DebuggerClient(transport);
+ let onUnexpectedClose = () => {
+ do_throw("Closed unexpectedly");
+ };
+ client.addListener("closed", onUnexpectedClose);
+ yield connectClient(client);
+
+ // Send a message the server will echo back
+ let message = "secrets";
+ let reply = yield client.request({
+ to: "root",
+ type: "echo",
+ message
+ });
+ equal(reply.message, message, "Encrypted echo matches");
+
+ client.removeListener("closed", onUnexpectedClose);
+ transport.close();
+ listener.close();
+ equal(DebuggerServer.listeningSockets, 0, "0 listening sockets");
+});
+
+// Client w/o encryption fails to connect to server w/ encryption
+add_task(function* () {
+ equal(DebuggerServer.listeningSockets, 0, "0 listening sockets");
+
+ let AuthenticatorType = DebuggerServer.Authenticators.get("PROMPT");
+ let authenticator = new AuthenticatorType.Server();
+ authenticator.allowConnection = () => {
+ return DebuggerServer.AuthenticationResult.ALLOW;
+ };
+
+ let listener = DebuggerServer.createListener();
+ ok(listener, "Socket listener created");
+ listener.portOrPath = -1;
+ listener.authenticator = authenticator;
+ listener.encryption = true;
+ yield listener.open();
+ equal(DebuggerServer.listeningSockets, 1, "1 listening socket");
+
+ try {
+ yield DebuggerClient.socketConnect({
+ host: "127.0.0.1",
+ port: listener.port
+ // encryption: false is the default
+ });
+ } catch (e) {
+ ok(true, "Client failed to connect as expected");
+ listener.close();
+ equal(DebuggerServer.listeningSockets, 0, "0 listening sockets");
+ return;
+ }
+
+ do_throw("Connection unexpectedly succeeded");
+});
+
+add_task(function* () {
+ DebuggerServer.destroy();
+});
diff --git a/devtools/shared/security/tests/unit/test_oob_cert_auth.js b/devtools/shared/security/tests/unit/test_oob_cert_auth.js
new file mode 100644
index 000000000..1e820af52
--- /dev/null
+++ b/devtools/shared/security/tests/unit/test_oob_cert_auth.js
@@ -0,0 +1,261 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var cert = require("devtools/shared/security/cert");
+
+// Test basic functionality of DevTools client and server OOB_CERT auth (used
+// with WiFi debugging)
+function run_test() {
+ // Need profile dir to store the key / cert
+ do_get_profile();
+ // Ensure PSM is initialized
+ Cc["@mozilla.org/psm;1"].getService(Ci.nsISupports);
+ run_next_test();
+}
+
+function connectClient(client) {
+ return client.connect(() => {
+ return client.listTabs();
+ });
+}
+
+add_task(function* () {
+ initTestDebuggerServer();
+});
+
+// Client w/ OOB_CERT auth connects successfully to server w/ OOB_CERT auth
+add_task(function* () {
+ equal(DebuggerServer.listeningSockets, 0, "0 listening sockets");
+
+ // Grab our cert, instead of relying on a discovery advertisement
+ let serverCert = yield cert.local.getOrCreate();
+
+ let oobData = defer();
+ let AuthenticatorType = DebuggerServer.Authenticators.get("OOB_CERT");
+ let serverAuth = new AuthenticatorType.Server();
+ serverAuth.allowConnection = () => {
+ return DebuggerServer.AuthenticationResult.ALLOW;
+ };
+ serverAuth.receiveOOB = () => oobData.promise; // Skip prompt for tests
+
+ let listener = DebuggerServer.createListener();
+ ok(listener, "Socket listener created");
+ listener.portOrPath = -1;
+ listener.encryption = true;
+ listener.authenticator = serverAuth;
+ yield listener.open();
+ equal(DebuggerServer.listeningSockets, 1, "1 listening socket");
+
+ let clientAuth = new AuthenticatorType.Client();
+ clientAuth.sendOOB = ({ oob }) => {
+ do_print(oob);
+ // Pass to server, skipping prompt for tests
+ oobData.resolve(oob);
+ };
+
+ let transport = yield DebuggerClient.socketConnect({
+ host: "127.0.0.1",
+ port: listener.port,
+ encryption: true,
+ authenticator: clientAuth,
+ cert: {
+ sha256: serverCert.sha256Fingerprint
+ }
+ });
+ ok(transport, "Client transport created");
+
+ let client = new DebuggerClient(transport);
+ let onUnexpectedClose = () => {
+ do_throw("Closed unexpectedly");
+ };
+ client.addListener("closed", onUnexpectedClose);
+ yield connectClient(client);
+
+ // Send a message the server will echo back
+ let message = "secrets";
+ let reply = yield client.request({
+ to: "root",
+ type: "echo",
+ message
+ });
+ equal(reply.message, message, "Encrypted echo matches");
+
+ client.removeListener("closed", onUnexpectedClose);
+ transport.close();
+ listener.close();
+ equal(DebuggerServer.listeningSockets, 0, "0 listening sockets");
+});
+
+// Client w/o OOB_CERT auth fails to connect to server w/ OOB_CERT auth
+add_task(function* () {
+ equal(DebuggerServer.listeningSockets, 0, "0 listening sockets");
+
+ let oobData = defer();
+ let AuthenticatorType = DebuggerServer.Authenticators.get("OOB_CERT");
+ let serverAuth = new AuthenticatorType.Server();
+ serverAuth.allowConnection = () => {
+ return DebuggerServer.AuthenticationResult.ALLOW;
+ };
+ serverAuth.receiveOOB = () => oobData.promise; // Skip prompt for tests
+
+ let listener = DebuggerServer.createListener();
+ ok(listener, "Socket listener created");
+ listener.portOrPath = -1;
+ listener.encryption = true;
+ listener.authenticator = serverAuth;
+ yield listener.open();
+ equal(DebuggerServer.listeningSockets, 1, "1 listening socket");
+
+ // This will succeed, but leaves the client in confused state, and no data is
+ // actually accessible
+ let transport = yield DebuggerClient.socketConnect({
+ host: "127.0.0.1",
+ port: listener.port,
+ encryption: true
+ // authenticator: PROMPT is the default
+ });
+
+ // Attempt to use the transport
+ let deferred = defer();
+ let client = new DebuggerClient(transport);
+ client.onPacket = packet => {
+ // Client did not authenticate, so it ends up seeing the server's auth data
+ // which is effectively malformed data from the client's perspective
+ ok(!packet.from && packet.authResult, "Got auth packet instead of data");
+ deferred.resolve();
+ };
+ client.connect();
+ yield deferred.promise;
+
+ // Try to send a message the server will echo back
+ let message = "secrets";
+ try {
+ yield client.request({
+ to: "root",
+ type: "echo",
+ message
+ });
+ } catch (e) {
+ ok(true, "Sending a message failed");
+ transport.close();
+ listener.close();
+ equal(DebuggerServer.listeningSockets, 0, "0 listening sockets");
+ return;
+ }
+
+ do_throw("Connection unexpectedly succeeded");
+});
+
+// Client w/ invalid K value fails to connect
+add_task(function* () {
+ equal(DebuggerServer.listeningSockets, 0, "0 listening sockets");
+
+ // Grab our cert, instead of relying on a discovery advertisement
+ let serverCert = yield cert.local.getOrCreate();
+
+ let oobData = defer();
+ let AuthenticatorType = DebuggerServer.Authenticators.get("OOB_CERT");
+ let serverAuth = new AuthenticatorType.Server();
+ serverAuth.allowConnection = () => {
+ return DebuggerServer.AuthenticationResult.ALLOW;
+ };
+ serverAuth.receiveOOB = () => oobData.promise; // Skip prompt for tests
+
+ let clientAuth = new AuthenticatorType.Client();
+ clientAuth.sendOOB = ({ oob }) => {
+ do_print(oob);
+ do_print("Modifying K value, should fail");
+ // Pass to server, skipping prompt for tests
+ oobData.resolve({
+ k: oob.k + 1,
+ sha256: oob.sha256
+ });
+ };
+
+ let listener = DebuggerServer.createListener();
+ ok(listener, "Socket listener created");
+ listener.portOrPath = -1;
+ listener.encryption = true;
+ listener.authenticator = serverAuth;
+ yield listener.open();
+ equal(DebuggerServer.listeningSockets, 1, "1 listening socket");
+
+ try {
+ yield DebuggerClient.socketConnect({
+ host: "127.0.0.1",
+ port: listener.port,
+ encryption: true,
+ authenticator: clientAuth,
+ cert: {
+ sha256: serverCert.sha256Fingerprint
+ }
+ });
+ } catch (e) {
+ ok(true, "Client failed to connect as expected");
+ listener.close();
+ equal(DebuggerServer.listeningSockets, 0, "0 listening sockets");
+ return;
+ }
+
+ do_throw("Connection unexpectedly succeeded");
+});
+
+// Client w/ invalid cert hash fails to connect
+add_task(function* () {
+ equal(DebuggerServer.listeningSockets, 0, "0 listening sockets");
+
+ // Grab our cert, instead of relying on a discovery advertisement
+ let serverCert = yield cert.local.getOrCreate();
+
+ let oobData = defer();
+ let AuthenticatorType = DebuggerServer.Authenticators.get("OOB_CERT");
+ let serverAuth = new AuthenticatorType.Server();
+ serverAuth.allowConnection = () => {
+ return DebuggerServer.AuthenticationResult.ALLOW;
+ };
+ serverAuth.receiveOOB = () => oobData.promise; // Skip prompt for tests
+
+ let clientAuth = new AuthenticatorType.Client();
+ clientAuth.sendOOB = ({ oob }) => {
+ do_print(oob);
+ do_print("Modifying cert hash, should fail");
+ // Pass to server, skipping prompt for tests
+ oobData.resolve({
+ k: oob.k,
+ sha256: oob.sha256 + 1
+ });
+ };
+
+ let listener = DebuggerServer.createListener();
+ ok(listener, "Socket listener created");
+ listener.portOrPath = -1;
+ listener.encryption = true;
+ listener.authenticator = serverAuth;
+ yield listener.open();
+ equal(DebuggerServer.listeningSockets, 1, "1 listening socket");
+
+ try {
+ yield DebuggerClient.socketConnect({
+ host: "127.0.0.1",
+ port: listener.port,
+ encryption: true,
+ authenticator: clientAuth,
+ cert: {
+ sha256: serverCert.sha256Fingerprint
+ }
+ });
+ } catch (e) {
+ ok(true, "Client failed to connect as expected");
+ listener.close();
+ equal(DebuggerServer.listeningSockets, 0, "0 listening sockets");
+ return;
+ }
+
+ do_throw("Connection unexpectedly succeeded");
+});
+
+add_task(function* () {
+ DebuggerServer.destroy();
+});
diff --git a/devtools/shared/security/tests/unit/testactors.js b/devtools/shared/security/tests/unit/testactors.js
new file mode 100644
index 000000000..80d5d4e18
--- /dev/null
+++ b/devtools/shared/security/tests/unit/testactors.js
@@ -0,0 +1,131 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { ActorPool, appendExtraActors, createExtraActors } =
+ require("devtools/server/actors/common");
+const { RootActor } = require("devtools/server/actors/root");
+const { ThreadActor } = require("devtools/server/actors/script");
+const { DebuggerServer } = require("devtools/server/main");
+const promise = require("promise");
+
+var gTestGlobals = [];
+DebuggerServer.addTestGlobal = function (aGlobal) {
+ gTestGlobals.push(aGlobal);
+};
+
+// A mock tab list, for use by tests. This simply presents each global in
+// gTestGlobals as a tab, and the list is fixed: it never calls its
+// onListChanged handler.
+//
+// As implemented now, we consult gTestGlobals when we're constructed, not
+// when we're iterated over, so tests have to add their globals before the
+// root actor is created.
+function TestTabList(aConnection) {
+ this.conn = aConnection;
+
+ // An array of actors for each global added with
+ // DebuggerServer.addTestGlobal.
+ this._tabActors = [];
+
+ // A pool mapping those actors' names to the actors.
+ this._tabActorPool = new ActorPool(aConnection);
+
+ for (let global of gTestGlobals) {
+ let actor = new TestTabActor(aConnection, global);
+ actor.selected = false;
+ this._tabActors.push(actor);
+ this._tabActorPool.addActor(actor);
+ }
+ if (this._tabActors.length > 0) {
+ this._tabActors[0].selected = true;
+ }
+
+ aConnection.addActorPool(this._tabActorPool);
+}
+
+TestTabList.prototype = {
+ constructor: TestTabList,
+ getList: function () {
+ return promise.resolve([...this._tabActors]);
+ }
+};
+
+function createRootActor(aConnection) {
+ let root = new RootActor(aConnection, {
+ tabList: new TestTabList(aConnection),
+ globalActorFactories: DebuggerServer.globalActorFactories
+ });
+ root.applicationType = "xpcshell-tests";
+ return root;
+}
+
+function TestTabActor(aConnection, aGlobal) {
+ this.conn = aConnection;
+ this._global = aGlobal;
+ this._threadActor = new ThreadActor(this, this._global);
+ this.conn.addActor(this._threadActor);
+ this._attached = false;
+ this._extraActors = {};
+}
+
+TestTabActor.prototype = {
+ constructor: TestTabActor,
+ actorPrefix: "TestTabActor",
+
+ get window() {
+ return { wrappedJSObject: this._global };
+ },
+
+ get url() {
+ return this._global.__name;
+ },
+
+ form: function () {
+ let response = { actor: this.actorID, title: this._global.__name };
+
+ // Walk over tab actors added by extensions and add them to a new ActorPool.
+ let actorPool = new ActorPool(this.conn);
+ this._createExtraActors(DebuggerServer.tabActorFactories, actorPool);
+ if (!actorPool.isEmpty()) {
+ this._tabActorPool = actorPool;
+ this.conn.addActorPool(this._tabActorPool);
+ }
+
+ this._appendExtraActors(response);
+
+ return response;
+ },
+
+ onAttach: function (aRequest) {
+ this._attached = true;
+
+ let response = { type: "tabAttached", threadActor: this._threadActor.actorID };
+ this._appendExtraActors(response);
+
+ return response;
+ },
+
+ onDetach: function (aRequest) {
+ if (!this._attached) {
+ return { "error":"wrongState" };
+ }
+ return { type: "detached" };
+ },
+
+ /* Support for DebuggerServer.addTabActor. */
+ _createExtraActors: createExtraActors,
+ _appendExtraActors: appendExtraActors
+};
+
+TestTabActor.prototype.requestTypes = {
+ "attach": TestTabActor.prototype.onAttach,
+ "detach": TestTabActor.prototype.onDetach
+};
+
+exports.register = function (handle) {
+ handle.setRootActor(createRootActor);
+};
+
+exports.unregister = function (handle) {
+ handle.setRootActor(null);
+};
diff --git a/devtools/shared/security/tests/unit/xpcshell.ini b/devtools/shared/security/tests/unit/xpcshell.ini
new file mode 100644
index 000000000..f2b3e7151
--- /dev/null
+++ b/devtools/shared/security/tests/unit/xpcshell.ini
@@ -0,0 +1,12 @@
+[DEFAULT]
+tags = devtools
+head = head_dbg.js
+tail =
+firefox-appdir = browser
+
+support-files=
+ testactors.js
+
+[test_encryption.js]
+[test_oob_cert_auth.js]
+skip-if = (toolkit == 'android' && !debug) # Bug 1141544: Re-enable when buildbot tests are gone