diff options
author | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
---|---|---|
committer | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
commit | 5f8de423f190bbb79a62f804151bc24824fa32d8 (patch) | |
tree | 10027f336435511475e392454359edea8e25895d /devtools/shared/security | |
parent | 49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff) | |
download | UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip |
Add m-esr52 at 52.6.0
Diffstat (limited to 'devtools/shared/security')
-rw-r--r-- | devtools/shared/security/auth.js | 653 | ||||
-rw-r--r-- | devtools/shared/security/cert.js | 67 | ||||
-rw-r--r-- | devtools/shared/security/docs/wifi.md | 154 | ||||
-rw-r--r-- | devtools/shared/security/moz.build | 15 | ||||
-rw-r--r-- | devtools/shared/security/prompt.js | 179 | ||||
-rw-r--r-- | devtools/shared/security/socket.js | 793 | ||||
-rw-r--r-- | devtools/shared/security/tests/chrome/chrome.ini | 4 | ||||
-rw-r--r-- | devtools/shared/security/tests/chrome/test_websocket-transport.html | 76 | ||||
-rw-r--r-- | devtools/shared/security/tests/unit/.eslintrc.js | 6 | ||||
-rw-r--r-- | devtools/shared/security/tests/unit/head_dbg.js | 96 | ||||
-rw-r--r-- | devtools/shared/security/tests/unit/test_encryption.js | 110 | ||||
-rw-r--r-- | devtools/shared/security/tests/unit/test_oob_cert_auth.js | 261 | ||||
-rw-r--r-- | devtools/shared/security/tests/unit/testactors.js | 131 | ||||
-rw-r--r-- | devtools/shared/security/tests/unit/xpcshell.ini | 12 |
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 |