js: Error handling for browser errors
[gpgme.git] / lang / js / src / Connection.js
1 /* gpgme.js - Javascript integration for gpgme
2  * Copyright (C) 2018 Bundesamt für Sicherheit in der Informationstechnik
3  *
4  * This file is part of GPGME.
5  *
6  * GPGME is free software; you can redistribute it and/or modify it
7  * under the terms of the GNU Lesser General Public License as
8  * published by the Free Software Foundation; either version 2.1 of
9  * the License, or (at your option) any later version.
10  *
11  * GPGME is distributed in the hope that it will be useful, but
12  * WITHOUT ANY WARRANTY; without even the implied warranty of
13  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
14  * Lesser General Public License for more details.
15  *
16  * You should have received a copy of the GNU Lesser General Public
17  * License along with this program; if not, see <https://www.gnu.org/licenses/>.
18  * SPDX-License-Identifier: LGPL-2.1+
19  *
20  * Author(s):
21  *     Maximilian Krambach <mkrambach@intevation.de>
22  */
23
24 /* global chrome */
25
26 import { permittedOperations } from './permittedOperations';
27 import { gpgme_error } from './Errors';
28 import { GPGME_Message, createMessage } from './Message';
29 import { decode, atobArray, Utf8ArrayToStr } from './Helpers';
30
31 /**
32  * A Connection handles the nativeMessaging interaction via a port. As the
33  * protocol only allows up to 1MB of message sent from the nativeApp to the
34  * browser, the connection will stay open until all parts of a communication
35  * are finished. For a new request, a new port will open, to avoid mixing
36  * contexts.
37  * @class
38  * @private
39  */
40 export class Connection{
41
42     constructor (){
43         this._connectionError = null;
44         this._connection = chrome.runtime.connectNative('gpgmejson');
45         this._connection.onDisconnect.addListener(() => {
46             if (chrome.runtime.lastError) {
47                 this._connectionError = chrome.runtime.lastError.message;
48             } else {
49                 this._connectionError = 'Disconnected without error message';
50             }
51         });
52     }
53
54     /**
55      * Immediately closes an open port.
56      */
57     disconnect () {
58         if (this._connection){
59             this._connection.disconnect();
60             this._connection = null;
61             this._connectionError = 'Disconnect requested by gpgmejs';
62         }
63     }
64
65     /**
66      * Checks if the connection terminated with an error state
67      */
68     get isDisconnected (){
69         return this._connectionError !== null;
70     }
71
72     /**
73     * @typedef {Object} backEndDetails
74     * @property {String} gpgme Version number of gpgme
75     * @property {Array<Object>} info Further information about the backend
76     * and the used applications (Example:
77     * <pre>
78     * {
79     *          "protocol":     "OpenPGP",
80     *          "fname":        "/usr/bin/gpg",
81     *          "version":      "2.2.6",
82     *          "req_version":  "1.4.0",
83     *          "homedir":      "default"
84     * }
85     * </pre>
86     */
87
88     /**
89      * Retrieves the information about the backend.
90      * @param {Boolean} details (optional) If set to false, the promise will
91      *  just return if a connection was successful.
92      * @param {Number} timeout (optional)
93      * @returns {Promise<backEndDetails>|Promise<Boolean>} Details from the
94      * backend
95      * @async
96      */
97     checkConnection (details = true, timeout = 1000){
98         if (typeof timeout !== 'number' && timeout <= 0) {
99             timeout = 1000;
100         }
101         const msg = createMessage('version');
102         if (details === true) {
103             return this.post(msg);
104         } else {
105             let me = this;
106             return new Promise(function (resolve) {
107                 Promise.race([
108                     me.post(msg),
109                     new Promise(function (resolve, reject){
110                         setTimeout(function (){
111                             reject(gpgme_error('CONN_TIMEOUT'));
112                         }, timeout);
113                     })
114                 ]).then(function (){ // success
115                     resolve(true);
116                 }, function (){ // failure
117                     resolve(false);
118                 });
119             });
120         }
121     }
122
123     /**
124      * Sends a {@link GPGME_Message} via the nativeMessaging port. It
125      * resolves with the completed answer after all parts have been
126      * received and reassembled, or rejects with an {@link GPGME_Error}.
127      *
128      * @param {GPGME_Message} message
129      * @returns {Promise<*>} The collected answer, depending on the messages'
130      * operation
131      * @private
132      * @async
133      */
134     post (message){
135         if (!message || !(message instanceof GPGME_Message)){
136             this.disconnect();
137             return Promise.reject(gpgme_error(
138                 'PARAM_WRONG', 'Connection.post'));
139         }
140         if (message.isComplete() !== true){
141             this.disconnect();
142             return Promise.reject(gpgme_error('MSG_INCOMPLETE'));
143         }
144         if (this.isDisconnected) {
145             if ( this.isNativeHostUnknown === true) {
146                 return Promise.reject(gpgme_error('CONN_NO_CONFIG'));
147             } else {
148                 return Promise.reject(gpgme_error(
149                     'CONN_NO_CONNECT', this._connectionError));
150             }
151         }
152         let chunksize = message.chunksize;
153         const me = this;
154         const nativeCommunication = new Promise(function (resolve, reject){
155             let answer = new Answer(message);
156             let listener = function (msg) {
157                 if (!msg){
158                     me._connection.onMessage.removeListener(listener);
159                     me._connection.disconnect();
160                     reject(gpgme_error('CONN_EMPTY_GPG_ANSWER'));
161                 } else {
162                     let answer_result = answer.collect(msg);
163                     if (answer_result !== true){
164                         me._connection.onMessage.removeListener(listener);
165                         me._connection.disconnect();
166                         reject(answer_result);
167                     } else {
168                         if (msg.more === true){
169                             me._connection.postMessage({
170                                 'op': 'getmore',
171                                 'chunksize': chunksize
172                             });
173                         } else {
174                             me._connection.onMessage.removeListener(listener);
175                             me._connection.disconnect();
176                             const message = answer.getMessage();
177                             if (message instanceof Error){
178                                 reject(message);
179                             } else {
180                                 resolve(message);
181                             }
182                         }
183                     }
184                 }
185             };
186             me._connection.onMessage.addListener(listener);
187             me._connection.postMessage(message.message);
188         });
189         if (permittedOperations[message.operation].pinentry === true) {
190             return nativeCommunication;
191         } else {
192             return Promise.race([
193                 nativeCommunication,
194                 new Promise(function (resolve, reject){
195                     setTimeout(function (){
196                         me._connection.disconnect();
197                         reject(gpgme_error('CONN_TIMEOUT'));
198                     }, 5000);
199                 })
200             ]);
201         }
202     }
203 }
204
205
206 /**
207  * A class for answer objects, checking and processing the return messages of
208  * the nativeMessaging communication.
209  * @private
210  */
211 class Answer{
212
213     /**
214      * @param {GPGME_Message} message
215      */
216     constructor (message){
217         this._operation = message.operation;
218         this._expected = message.expected;
219         this._response_b64 = null;
220     }
221
222     get operation (){
223         return this._operation;
224     }
225
226     get expected (){
227         return this._expected;
228     }
229
230     /**
231      * Checks if an error matching browsers 'host not known' messages occurred
232      */
233     get isNativeHostUnknown () {
234         return this._connectionError === 'Specified native messaging host not found.';
235     }
236
237     /**
238      * Adds incoming base64 encoded data to the existing response
239      * @param {*} msg base64 encoded data.
240      * @returns {Boolean}
241      *
242      * @private
243      */
244     collect (msg){
245         if (typeof (msg) !== 'object' || !msg.hasOwnProperty('response')) {
246             return gpgme_error('CONN_UNEXPECTED_ANSWER');
247         }
248         if (!this._response_b64){
249             this._response_b64 = msg.response;
250             return true;
251         } else {
252             this._response_b64 += msg.response;
253             return true;
254         }
255     }
256     /**
257      * Decodes and verifies the base64 encoded answer data. Verified against
258      * {@link permittedOperations}.
259      * @returns {Object} The readable gpnupg answer
260      */
261     getMessage (){
262         if (this._response_b64 === null){
263             return gpgme_error('CONN_UNEXPECTED_ANSWER');
264         }
265         let _decodedResponse = JSON.parse(atob(this._response_b64));
266         let _response = {
267             format: 'ascii'
268         };
269         let messageKeys = Object.keys(_decodedResponse);
270         let poa = permittedOperations[this.operation].answer;
271         if (messageKeys.length === 0){
272             return gpgme_error('CONN_UNEXPECTED_ANSWER');
273         }
274         for (let i= 0; i < messageKeys.length; i++){
275             let key = messageKeys[i];
276             switch (key) {
277             case 'type': {
278                 if (_decodedResponse.type === 'error'){
279                     return (gpgme_error('GNUPG_ERROR',
280                         decode(_decodedResponse.msg)));
281                 } else if (poa.type.indexOf(_decodedResponse.type) < 0){
282                     return gpgme_error('CONN_UNEXPECTED_ANSWER');
283                 }
284                 break;
285             }
286             case 'base64': {
287                 break;
288             }
289             case 'msg': {
290                 if (_decodedResponse.type === 'error'){
291                     return (gpgme_error('GNUPG_ERROR', _decodedResponse.msg));
292                 }
293                 break;
294             }
295             default: {
296                 let answerType = null;
297                 if (poa.payload && poa.payload.hasOwnProperty(key)){
298                     answerType = 'p';
299                 } else if (poa.info && poa.info.hasOwnProperty(key)){
300                     answerType = 'i';
301                 }
302                 if (answerType !== 'p' && answerType !== 'i'){
303                     return gpgme_error('CONN_UNEXPECTED_ANSWER');
304                 }
305
306                 if (answerType === 'i') {
307                     if ( typeof (_decodedResponse[key]) !== poa.info[key] ){
308                         return gpgme_error('CONN_UNEXPECTED_ANSWER');
309                     }
310                     _response[key] = decode(_decodedResponse[key]);
311
312                 } else if (answerType === 'p') {
313                     if (_decodedResponse.base64 === true
314                         && poa.payload[key] === 'string'
315                     ) {
316                         if (this.expected === 'uint8'){
317                             _response[key] = atobArray(_decodedResponse[key]);
318                             _response.format = 'uint8';
319
320                         } else if (this.expected === 'base64'){
321                             _response[key] = _decodedResponse[key];
322                             _response.format = 'base64';
323
324                         } else { // no 'expected'
325                             _response[key] = Utf8ArrayToStr(
326                                 atobArray(_decodedResponse[key]));
327                             _response.format = 'string';
328                         }
329                     } else if (poa.payload[key] === 'string') {
330                         _response[key] = _decodedResponse[key];
331                     } else {
332                         // fallthrough, should not be reached
333                         // (payload is always string)
334                         return gpgme_error('CONN_UNEXPECTED_ANSWER');
335                     }
336                 }
337                 break;
338             } }
339         }
340         return _response;
341     }
342 }