/* -*- 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 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"); DevToolsUtils.defineLazyModuleGetter(this, "Task", "resource://gre/modules/Task.jsm"); /** * 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 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 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 = promise.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); } };