--- a/comm/mailnews/base/src/MailAuthenticator.sys.mjs +++ b/comm/mailnews/base/src/MailAuthenticator.sys.mjs @@ -229,7 +229,7 @@ this._server.forgetPassword(); } - getPassword() { + async getPassword() { if (this._server.password) { return this._server.password; } @@ -261,8 +261,8 @@ * * @returns {string} */ - getByteStringPassword() { - return MailStringUtils.stringToByteString(this.getPassword()); + async getByteStringPassword() { + return MailStringUtils.stringToByteString(await this.getPassword()); } /** @@ -270,10 +270,12 @@ * * @returns {string} */ - getPlainToken() { + async getPlainToken() { // According to rfc4616#section-2, password should be UTF-8 BinaryString // before base64 encoded. - return btoa("\0" + this.username + "\0" + this.getByteStringPassword()); + return btoa( + "\0" + this.username + "\0" + (await this.getByteStringPassword()) + ); } async getOAuthToken() { --- a/comm/mailnews/compose/src/SmtpServer.sys.mjs +++ b/comm/mailnews/compose/src/SmtpServer.sys.mjs @@ -7,9 +7,22 @@ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { + FileUtils: "resource:///modules/FileUtils.sys.mjs", SmtpClient: "resource:///modules/SmtpClient.sys.mjs", }); +const UnixServerSocket = Components.Constructor( + "@mozilla.org/network/server-socket;1", + "nsIServerSocket", + "initWithFilename" +); +const currentThread = Cc["@mozilla.org/thread-manager;1"].getService().currentThread; +const BinaryInputStream = Components.Constructor( + "@mozilla.org/binaryinputstream;1", + "nsIBinaryInputStream", + "setInputStream" +); + /** * This class represents a single SMTP server. * @@ -318,7 +331,161 @@ this._password = password; } - getPasswordWithUI(promptMessage, promptTitle) { + getPasswordCommand() { + let serializedCommand = this._getCharPrefWithDefault("passwordCommand"); + if (serializedCommand === "") + return null; + + // Serialization is achieved by joining the arguments with a comma. + // Comma are themselves allowed to be backslash-espaced. + + let currentArgument = ""; + let nextShouldBeEscaped = false; + let commandArguments = []; + + for (let i = 0; i < serializedCommand.length; i++) { + let c = serializedCommand[i]; + + switch (c) { + case ',': + if (nextShouldBeEscaped) { + currentArgument += ','; + nextShouldBeEscaped = false; + } else { + commandArguments.push(currentArgument); + currentArgument = ''; + } + break; + + case '\\': + if (nextShouldBeEscaped) + currentArgument += '\\'; + + nextShouldBeEscaped = !nextShouldBeEscaped; + break; + + default: + currentArgument += c; + break; + } + } + + if (nextShouldBeEscaped) + currentArgument += '\\'; + commandArguments.push(currentArgument); + + return commandArguments; + } + + async runPasswordCommand(passwordCommand) { + let executable = passwordCommand[0]; + let args = passwordCommand.splice(1); + + let executableFile = new lazy.FileUtils.File(executable); + + function genSocketFile() { + let tmpDir = lazy.FileUtils.getDir("TmpD", [], false); + let random = Math.round(Math.random() * 36 ** 3).toString(36); + return new lazy.FileUtils.File( + PathUtils.join(tmpDir.path, `tmp-${random}.sock`) + ); + } + let socketFile = genSocketFile(); + + let socket = new UnixServerSocket( + socketFile, + parseInt("600", 8), + -1 + ); + + var process = Cc['@mozilla.org/process/util;1'].createInstance(Ci.nsIProcess); + process.init(executableFile); + let processPromise = new Promise((resolve, reject) => { + process.runAsync( + args.concat(socketFile.path), + args.length + 1, + { + observe(subject, topic, data) { + switch (topic) { + case "process-finished": + resolve(); + break; + + case "process-failed": + reject(); + break; + } + } + }, + false + ); + }); + + let passwordPromise = new Promise((resolve, reject) => { + socket.asyncListen({ + "onSocketAccepted": (server, transport) => { + // The socket can be closed. This does not close the existing connection. + socket.close(); + + var stream = transport.openInputStream(0, 0, 0); + + let received = []; + let observer = { + onInputStreamReady(stream) { + let len = 0; + try { + len = stream.available(); + } catch (e) { + // note: stream.available() can also return -1. + len = -1; + } + + if (len == -1) { + let decoder = new TextDecoder(); + let result = decoder.decode(new Uint8Array(received)); + + resolve(result); + } else { + if (len > 0) { + let bin = new BinaryInputStream(stream); + let data = Array.from(bin.readByteArray(len)); + received = received.concat(data); + } + + stream.asyncWait(observer, 0, 0, currentThread); + } + } + }; + stream.asyncWait(observer, 0, 0, currentThread); + } + }); + }); + + let [_, result] = await Promise.allSettled([ + processPromise, + passwordPromise, + ]); + + socketFile.remove(false); + + if (result.status == "fulfilled") + return result.value; + else + return Promise.reject(result.reason); + } + + async getPasswordWithUI(promptMessage, promptTitle) { + let passwordCommand = this.getPasswordCommand(); + if (passwordCommand !== null) { + let password = await this.runPasswordCommand(passwordCommand); + + if (password === null) + throw Components.Exception("Password command failure", Cr.NS_ERROR_ABORT); + + this.password = password; + return this.password; + } + // This prompt has a checkbox for saving password. const authPrompt = Cc["@mozilla.org/messenger/msgAuthPrompt;1"].getService( Ci.nsIAuthPrompt --- a/comm/mailnews/compose/src/SmtpClient.sys.mjs +++ b/comm/mailnews/compose/src/SmtpClient.sys.mjs @@ -362,7 +362,7 @@ * * @param {string} chunk Chunk of data received from the server */ - _parse(chunk) { + async _parse(chunk) { // Lines should always end with but you never know, might be only as well var lines = (this._parseRemainder + (chunk || "")).split(/\r?\n/); this._parseRemainder = lines.pop(); // not sure if the line has completely arrived yet @@ -399,14 +399,14 @@ success: statusCode >= 200 && statusCode < 300, }; - this._onCommand(response); + await this._onCommand(response); this._parseBlock = { data: [], statusCode: null, }; } } else { - this._onCommand({ + await this._onCommand({ success: false, statusCode: this._parseBlock.statusCode || null, data: [lines[i]].join("\n"), @@ -456,7 +456,7 @@ // rejects AUTH PLAIN then closes the connection, the client then sends AUTH // LOGIN. This line guarantees onclose is called before sending AUTH LOGIN. await new Promise(resolve => setTimeout(resolve)); - this._parse(stringPayload); + await this._parse(stringPayload); }; /** @@ -732,7 +732,7 @@ this.logger.debug("Authentication via AUTH PLAIN"); this._currentAction = this._actionAUTHComplete; this._sendCommand( - "AUTH PLAIN " + this._authenticator.getPlainToken(), + "AUTH PLAIN " + (await this._authenticator.getPlainToken()), true ); return; @@ -1065,7 +1065,7 @@ * @param {{statusCode: number, data: string}} command - Parsed command from * the server. */ - _actionAUTH_LOGIN_PASS(command) { + async _actionAUTH_LOGIN_PASS(command) { if ( command.statusCode !== 334 || (command.data !== btoa("Password:") && command.data !== btoa("password:")) @@ -1075,7 +1075,7 @@ } this.logger.debug("AUTH LOGIN PASS"); this._currentAction = this._actionAUTHComplete; - let password = this._getPassword(); + let password = await this._getPassword(); if ( !Services.prefs.getBoolPref( "mail.smtp_login_pop3_user_pass_auth_is_latin1", @@ -1104,7 +1104,7 @@ } this._currentAction = this._actionAUTHComplete; this._sendCommand( - this._authenticator.getCramMd5Token(this._getPassword(), command.data), + this._authenticator.getCramMd5Token(await this._getPassword(), command.data), true ); }