├── .babelrc ├── .gitignore ├── .vscode └── settings.json ├── INSTALL ├── LICENSE.md ├── README.md ├── encrypt-composer-button@2x.png ├── icon.png ├── key-present@2x.png ├── package-lock.json ├── package.json ├── spec ├── decrypt-buttons-spec.jsx ├── encrypt-button-spec.jsx ├── keybase-profile-spec.jsx ├── keybase-search-spec.jsx ├── keybase-spec.js ├── main-spec.js ├── pgp-key-store-spec.jsx └── recipient-key-chip-spec.jsx ├── src ├── decrypt-button.jsx ├── decryption-preprocess.js ├── email-popover.jsx ├── encrypt-button.jsx ├── identity.js ├── key-adder.jsx ├── key-manager.jsx ├── keybase-search.jsx ├── keybase-user.jsx ├── keybase.js ├── main.es6 ├── modal-key-recommender.jsx ├── passphrase-popover.jsx ├── pgp-key-store.jsx ├── preferences-keybase.jsx ├── private-key-popover.jsx └── recipient-key-chip.jsx └── stylesheets └── main.less /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react", 5 | ], 6 | "plugins": [ 7 | "@babel/plugin-proposal-class-properties" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | lib 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "git.ignoreLimitWarning": true 3 | } -------------------------------------------------------------------------------- /INSTALL: -------------------------------------------------------------------------------- 1 | The following installation instruction reference to David's issue, "Add install instructions in Readme.md." 2 | 3 | 1. Clone repository into any location 4 | 2. Run the command `cd /path/to/mailspring-keybase` 5 | 3. Install babel and other dependencies: `npm install` 6 | 4. Compile source codes to vanilla js with babel: `./node_modules/.bin/babel src --out-dir lib` 7 | 3. Start Mailspring and add plugin - you should see a success notification 8 | 4. Close Mailspring and edit config.json and remove keybase from disabled plugins list (config.json can be found in different places depending on your OS. Search for the Mailspring folder in your filesystem) 9 | 5. Start Mailspring, key management can be found in preferences 10 | 11 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Nylas, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Keybase Plugin 2 | 3 | TODO: 4 | ----- 5 | * final refactor 6 | * tests 7 | 8 | WISHLIST: 9 | ----- 10 | * message signing 11 | * encrypted file handling 12 | * integrate MIT PGP Keyserver search into Keybase searchbar 13 | * make the decrypt interface a message body overlay instead of a button in the header 14 | * improve search result deduping with keys on file 15 | 16 | How to install: 17 | ----- 18 | 1. Clone repository into any location 19 | 2. Run the command `cd /path/to/mailspring-keybase` 20 | 3. Install babel and other dependencies: `npm install` 21 | 4. Compile source codes to vanilla js with babel: `chmod +x ./node_modules/.bin/babel` and `./node_modules/.bin/babel src --out-dir lib` 22 | 3. Start Mailspring and add plugin - you should see a success notification 23 | 4. Close Mailspring and edit config.json and remove keybase from disabled plugins list (config.json can be found in different places depending on your OS. Search for the Mailspring folder in your filesystem). For linux you will find it in: `~/.config/Mailspring/config.json` 24 | 5. Start Mailspring, key management can be found in preferences 25 | -------------------------------------------------------------------------------- /encrypt-composer-button@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NgoHuy/mailspring-keybase/e316396b216c9750ed5cac799dfb413b9c8a1b8b/encrypt-composer-button@2x.png -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NgoHuy/mailspring-keybase/e316396b216c9750ed5cac799dfb413b9c8a1b8b/icon.png -------------------------------------------------------------------------------- /key-present@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NgoHuy/mailspring-keybase/e316396b216c9750ed5cac799dfb413b9c8a1b8b/key-present@2x.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "keybase", 3 | "main": "./lib/main", 4 | "version": "0.1.0", 5 | "engines": { 6 | "mailspring": ">=0.3.0" 7 | }, 8 | "isOptional": true, 9 | "isHiddenOnPluginsPage": true, 10 | "title": "Encryption", 11 | "description": "Send and receive encrypted messages using Keybase for public key exchange.", 12 | "icon": "./icon.png", 13 | "license": "GPL-3.0", 14 | "windowTypes": { 15 | "default": true, 16 | "composer": true, 17 | "thread-popout": true 18 | }, 19 | "dependencies": { 20 | "@babel/cli": "^7.10.4", 21 | "@babel/core": "^7.10.4", 22 | "@babel/preset-react": "^7.10.4", 23 | "babel-plugin-transform-react-createelement-to-jsx": "^1.0.1", 24 | "coffee-react": "^5.0.1", 25 | "kbpgp": "^2.0.79", 26 | "lebab": "^3.0.2", 27 | "prop-types": "^15.6.2", 28 | "react": "^16.5.2", 29 | "request": "^2.88.0", 30 | "underscore": "^1.12.1" 31 | }, 32 | "devDependencies": { 33 | "@babel/preset-env": "^7.10.4" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /spec/decrypt-buttons-spec.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * decaffeinate suggestions: 3 | * DS102: Remove unnecessary code created because of implicit returns 4 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 5 | */ 6 | const {React, ReactTestUtils, DraftStore, Message} = require('mailspring-exports'); 7 | const pgp = require('kbpgp'); 8 | 9 | const DecryptMessageButton = require('../lib/decrypt-button'); 10 | const PGPKeyStore = require('../lib/pgp-key-store'); 11 | 12 | describe("DecryptMessageButton", function() { 13 | beforeEach(function() { 14 | this.unencryptedMsg = new Message({clientId: 'test', subject: 'Subject', body: '
Body
'}); 15 | const body = `-----BEGIN PGP MESSAGE----- 16 | Version: Keybase OpenPGP v2.0.52 Comment: keybase.io/crypto 17 | 18 | wcBMA5nwa6GWVDOUAQf+MjiVRIBWJyM6The6/h2MgSJTDyrN9teFFJTizOvgHNnD W4EpEmmhShNyERI67qXhC03lFczu2Zp2Qofgs8YePIEv7wwb27/cviODsE42YJvX 1zGir+jBp81s9ZiF4dex6Ir9XfiZJlypI2QV2dHjO+5pstW+XhKIc1R5vKvoFTGI 1XmZtL3EgtKfj/HkPUkq2N0G5kAoB2MTTQuurfXm+3TRkftqesyTKlek652sFjCv nSF+LQ1GYq5hI4YaUBiHnZd7wKUgDrIh2rzbuGq+AHjrHdVLMfRTbN0Xsy3OWRcC 9uWU8Nln00Ly6KbTqPXKcBDcMrOJuoxYcpmLlhRds9JoAY7MyIsj87M2mkTtAtMK hqK0PPvJKfepV+eljDhQ7y0TQ0IvNtO5/pcY2CozbFJncm/ToxxZPNJueKRcz+EH M9uBvrWNTwfHj26g405gpRDN1T8CsY5ZeiaDHduIKnBWd4za0ak0Xfw= 19 | =1aPN 20 | -----END PGP MESSAGE-----`; 21 | this.encryptedMsg = new Message({clientId: 'test2', subject: 'Subject', body}); 22 | 23 | this.msg = new Message({subject: 'Subject', body: 'Body
'}); 24 | return this.component = ReactTestUtils.renderIntoDocument( 25 | React.createElement(DecryptMessageButton, {"message": (this.msg)}) 26 | ); 27 | }); 28 | 29 | xit("should try to decrypt the message whenever a new key is unlocked", function() { 30 | spyOn(PGPKeyStore, "decrypt"); 31 | spyOn(PGPKeyStore, "isDecrypted").andCallFake(message => { 32 | return false; 33 | }); 34 | spyOn(PGPKeyStore, "hasEncryptedComponent").andCallFake(message => { 35 | return true; 36 | }); 37 | 38 | PGPKeyStore.trigger(PGPKeyStore); 39 | return expect(PGPKeyStore.decrypt).toHaveBeenCalled(); 40 | }); 41 | 42 | xit(`should not try to decrypt the message whenever a new key is unlocked \ 43 | if the message is already decrypted`, function() { 44 | spyOn(PGPKeyStore, "decrypt"); 45 | spyOn(PGPKeyStore, "isDecrypted").andCallFake(message => { 46 | return true; 47 | }); 48 | spyOn(PGPKeyStore, "hasEncryptedComponent").andCallFake(message => { 49 | return true; 50 | }); 51 | 52 | // TODO for some reason the above spyOn calls aren't working and false is 53 | // being returned from isDecrypted, causing this test to fail 54 | PGPKeyStore.trigger(PGPKeyStore); 55 | 56 | return expect(PGPKeyStore.decrypt).not.toHaveBeenCalled(); 57 | }); 58 | 59 | it("should have a button to decrypt a message", function() { 60 | this.component = ReactTestUtils.renderIntoDocument( 61 | React.createElement(DecryptMessageButton, {"message": this.encryptedMsg}) 62 | ); 63 | 64 | return expect(this.component.refs.button).toBeDefined(); 65 | }); 66 | 67 | it("should not allow for the unlocking of a message with no encrypted component", function() { 68 | this.component = ReactTestUtils.renderIntoDocument( 69 | React.createElement(DecryptMessageButton, {"message": this.unencryptedMsg}) 70 | ); 71 | 72 | return expect(this.component.refs.button).not.toBeDefined(); 73 | }); 74 | 75 | it("should indicate when a message has been decrypted", function() { 76 | spyOn(PGPKeyStore, "isDecrypted").andCallFake(message => { 77 | return true; 78 | }); 79 | 80 | this.component = ReactTestUtils.renderIntoDocument( 81 | React.createElement(DecryptMessageButton, {"message": this.encryptedMsg}) 82 | ); 83 | 84 | return expect(this.component.refs.button).not.toBeDefined(); 85 | }); 86 | 87 | return it("should open a popover when clicked", function() { 88 | spyOn(DecryptMessageButton.prototype, "_onClickDecrypt"); 89 | 90 | const msg = this.encryptedMsg; 91 | msg.to = [{email: "test@example.com"}]; 92 | this.component = ReactTestUtils.renderIntoDocument( 93 | React.createElement(DecryptMessageButton, {"message": msg}) 94 | ); 95 | expect(this.component.refs.button).toBeDefined(); 96 | ReactTestUtils.Simulate.click(this.component.refs.button); 97 | return expect(DecryptMessageButton.prototype._onClickDecrypt).toHaveBeenCalled(); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /spec/encrypt-button-spec.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * decaffeinate suggestions: 3 | * DS102: Remove unnecessary code created because of implicit returns 4 | * DS207: Consider shorter variations of null checks 5 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 6 | */ 7 | const {React, ReactDOM, ReactTestUtils, DraftStore, Message} = require('mailspring-exports'); 8 | const pgp = require('kbpgp'); 9 | 10 | const EncryptMessageButton = require('../lib/encrypt-button'); 11 | const PGPKeyStore = require('../lib/pgp-key-store'); 12 | 13 | describe("EncryptMessageButton", function() { 14 | beforeEach(function() { 15 | const key = `-----BEGIN PGP PRIVATE KEY BLOCK----- 16 | Version: GnuPG v1 17 | 18 | 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 70 | 217s2OKjpJqtpHPf2vY= 71 | =UY7Y 72 | -----END PGP PRIVATE KEY BLOCK-----`; 73 | 74 | pgp.KeyManager.import_from_armored_pgp({ 75 | armored: key 76 | }, (err, km) => { 77 | return this.km = km; 78 | }); 79 | 80 | waitsFor((() => (this.km != null)), "getting a key took too long", 1000); 81 | 82 | this.msg = new Message({subject: 'Subject', body: 'Body
', draft: true}); 83 | this.session = { 84 | draft: () => { 85 | return this.msg; 86 | }, 87 | changes: { 88 | add: changes => { 89 | return this.output = changes; 90 | } 91 | } 92 | }; 93 | 94 | this.output = null; 95 | 96 | const add = jasmine.createSpy('add'); 97 | spyOn(DraftStore, 'sessionForClientId').andCallFake(draftClientId => { 98 | return Promise.resolve(this.session); 99 | }); 100 | 101 | return this.component = ReactTestUtils.renderIntoDocument( 102 | React.createElement(EncryptMessageButton, {"draft": (this.msg), "session": (this.session)}) 103 | ); 104 | }); 105 | 106 | it("should render into the page", function() { 107 | return expect(this.component).toBeDefined(); 108 | }); 109 | 110 | it("should have a displayName", () => expect(EncryptMessageButton.displayName).toBe('EncryptMessageButton')); 111 | 112 | it("should have an onClick behavior which encrypts the message", function() { 113 | spyOn(this.component, '_onClick'); 114 | const buttonNode = ReactDOM.findDOMNode(this.component.refs.button); 115 | ReactTestUtils.Simulate.click(buttonNode); 116 | return expect(this.component._onClick).toHaveBeenCalled(); 117 | }); 118 | 119 | it("should store the message body's plaintext on encryption", function() { 120 | spyOn(this.component, '_onClick'); 121 | const buttonNode = ReactDOM.findDOMNode(this.component.refs.button); 122 | ReactTestUtils.Simulate.click(buttonNode); 123 | return expect(this.component.plaintext === this.msg.body); 124 | }); 125 | 126 | it("should mark itself as encrypted", function() { 127 | spyOn(this.component, '_onClick'); 128 | const buttonNode = ReactDOM.findDOMNode(this.component.refs.button); 129 | ReactTestUtils.Simulate.click(buttonNode); 130 | return expect(this.component.currentlyEncrypted === true); 131 | }); 132 | 133 | return xit("should be able to encrypt messages", function() { 134 | // NOTE: this doesn't work. 135 | // As best I can tell, something is wrong with the pgp.box function - 136 | // nothing seems to get it to complete. Weird. 137 | 138 | runs( () => { 139 | console.log(this.km); 140 | this.component._encrypt("test text", [this.km]); 141 | 142 | this.flag = false; 143 | return pgp.box({encrypt_for: [this.km], msg: "test text"}, (err, result_string) => { 144 | expect((err == null)); 145 | this.err = err; 146 | this.result_string = result_string; 147 | return this.flag = true; 148 | }); 149 | }); 150 | 151 | waitsFor((() => { console.log(this.flag); return this.flag; }), "encryption took too long", 5000); 152 | 153 | return runs( () => { 154 | console.log(this.err); 155 | console.log(this.result_string); 156 | console.log(this.output); 157 | 158 | return expect(this.output === this.result_string); 159 | }); 160 | }); 161 | }); 162 | -------------------------------------------------------------------------------- /spec/keybase-profile-spec.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * decaffeinate suggestions: 3 | * DS102: Remove unnecessary code created because of implicit returns 4 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 5 | */ 6 | const {React, ReactTestUtils, Message} = require('mailspring-exports'); 7 | 8 | const KeybaseUser = require('../lib/keybase-user'); 9 | 10 | describe("KeybaseUserProfile", () => 11 | it("should have a displayName", () => expect(KeybaseUser.displayName).toBe('KeybaseUserProfile')) 12 | ); 13 | 14 | // behold, the most comprehensive test suite of all time 15 | -------------------------------------------------------------------------------- /spec/keybase-search-spec.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * decaffeinate suggestions: 3 | * DS102: Remove unnecessary code created because of implicit returns 4 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 5 | */ 6 | const {React, ReactTestUtils, Message} = require('mailspring-exports'); 7 | 8 | const KeybaseSearch = require('../lib/keybase-search'); 9 | 10 | describe("KeybaseSearch", function() { 11 | it("should have a displayName", () => expect(KeybaseSearch.displayName).toBe('KeybaseSearch')); 12 | 13 | return it("should have no results when rendered", function() { 14 | this.component = ReactTestUtils.renderIntoDocument( 15 | React.createElement(KeybaseSearch, null) 16 | ); 17 | 18 | return expect(this.component.state.results).toEqual([]); 19 | }); 20 | }); 21 | 22 | // behold, the most comprehensive test suite of all time 23 | -------------------------------------------------------------------------------- /spec/keybase-spec.js: -------------------------------------------------------------------------------- 1 | /* 2 | * decaffeinate suggestions: 3 | * DS102: Remove unnecessary code created because of implicit returns 4 | * DS207: Consider shorter variations of null checks 5 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 6 | */ 7 | const kb = require("../lib/keybase"); 8 | 9 | xdescribe("keybase lib", function() { 10 | // TODO stub keybase calls? 11 | it("should be able to fetch an account by username", function() { 12 | this.them = null; 13 | runs(() => { 14 | return kb.getUser("dakota", "usernames", (err, them) => { 15 | return (this.them = them); 16 | }); 17 | }); 18 | waitsFor(() => this.them !== null, 2000); 19 | return runs(() => { 20 | return expect( 21 | this.them != null ? this.them[0].components.username.val : undefined 22 | ).toEqual("dakota"); 23 | }); 24 | }); 25 | 26 | it("should be able to fetch an account by key fingerprint", function() { 27 | this.them = null; 28 | runs(() => { 29 | return kb.getUser( 30 | "7FA5A43BBF2BAD1845C8D0E8145FCCD989968E3B", 31 | "key_fingerprint", 32 | (err, them) => { 33 | return (this.them = them); 34 | } 35 | ); 36 | }); 37 | waitsFor(() => this.them !== null, 2000); 38 | return runs(() => { 39 | return expect( 40 | this.them != null ? this.them[0].components.username.val : undefined 41 | ).toEqual("dakota"); 42 | }); 43 | }); 44 | 45 | it("should be able to fetch a user's key", function() { 46 | this.key = null; 47 | runs(() => { 48 | return kb.getKey("dakota", (error, key) => { 49 | return (this.key = key); 50 | }); 51 | }); 52 | waitsFor(() => this.key !== null, 2000); 53 | return runs(() => { 54 | return expect( 55 | this.key != null 56 | ? this.key.startsWith("-----BEGIN PGP PUBLIC KEY BLOCK-----") 57 | : undefined 58 | ); 59 | }); 60 | }); 61 | 62 | return it("should be able to return an autocomplete query", function() { 63 | this.completions = null; 64 | runs(() => { 65 | return kb.autocomplete("dakota", (error, completions) => { 66 | return (this.completions = completions); 67 | }); 68 | }); 69 | waitsFor(() => this.completions !== null, 2000); 70 | return runs(() => { 71 | return expect(this.completions[0].components.username.val).toEqual( 72 | "dakota" 73 | ); 74 | }); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /spec/main-spec.js: -------------------------------------------------------------------------------- 1 | /* 2 | * decaffeinate suggestions: 3 | * DS102: Remove unnecessary code created because of implicit returns 4 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 5 | */ 6 | const { ComponentRegistry, ExtensionRegistry } = require("mailspring-exports"); 7 | const { activate, deactivate } = require("../lib/main"); 8 | 9 | const EncryptMessageButton = require("../lib/encrypt-button"); 10 | const DecryptMessageButton = require("../lib/decrypt-button"); 11 | const DecryptPGPExtension = require("../lib/decryption-preprocess"); 12 | 13 | describe("activate", function() { 14 | it("should register the encryption button", function() { 15 | spyOn(ComponentRegistry, "register"); 16 | activate(); 17 | return expect(ComponentRegistry.register).toHaveBeenCalledWith( 18 | EncryptMessageButton, 19 | { role: "Composer:ActionButton" } 20 | ); 21 | }); 22 | 23 | it("should register the decryption button", function() { 24 | spyOn(ComponentRegistry, "register"); 25 | activate(); 26 | return expect(ComponentRegistry.register).toHaveBeenCalledWith( 27 | DecryptMessageButton, 28 | { role: "message:BodyHeader" } 29 | ); 30 | }); 31 | 32 | return it("should register the decryption processor", function() { 33 | spyOn(ExtensionRegistry.MessageView, "register"); 34 | activate(); 35 | return expect(ExtensionRegistry.MessageView.register).toHaveBeenCalledWith( 36 | DecryptPGPExtension 37 | ); 38 | }); 39 | }); 40 | 41 | describe("deactivate", function() { 42 | it("should unregister the encrypt button", function() { 43 | spyOn(ComponentRegistry, "unregister"); 44 | deactivate(); 45 | return expect(ComponentRegistry.unregister).toHaveBeenCalledWith( 46 | EncryptMessageButton 47 | ); 48 | }); 49 | 50 | it("should unregister the decryption button", function() { 51 | spyOn(ComponentRegistry, "unregister"); 52 | deactivate(); 53 | return expect(ComponentRegistry.unregister).toHaveBeenCalledWith( 54 | DecryptMessageButton 55 | ); 56 | }); 57 | 58 | return it("should unregister the decryption processor", function() { 59 | spyOn(ExtensionRegistry.MessageView, "unregister"); 60 | deactivate(); 61 | return expect( 62 | ExtensionRegistry.MessageView.unregister 63 | ).toHaveBeenCalledWith(DecryptPGPExtension); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /spec/pgp-key-store-spec.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * decaffeinate suggestions: 3 | * DS102: Remove unnecessary code created because of implicit returns 4 | * DS207: Consider shorter variations of null checks 5 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 6 | */ 7 | const {React, ReactTestUtils, DraftStore, Message} = require('mailspring-exports'); 8 | const pgp = require('kbpgp'); 9 | const _ = require('underscore'); 10 | const fs = require('fs'); 11 | 12 | const Identity = require('../lib/identity'); 13 | const PGPKeyStore = require('../lib/pgp-key-store'); 14 | 15 | describe("PGPKeyStore", function() { 16 | beforeEach(function() { 17 | this.TEST_KEY = `-----BEGIN PGP PRIVATE KEY BLOCK----- 18 | Version: GnuPG v1 19 | 20 | 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 72 | 217s2OKjpJqtpHPf2vY= 73 | =UY7Y 74 | -----END PGP PRIVATE KEY BLOCK-----`; 75 | 76 | // mock getKeyContents to get rid of all the fs.readFiles 77 | spyOn(PGPKeyStore, "getKeyContents").andCallFake( ({key, passphrase, callback}) => { 78 | const data = this.TEST_KEY; 79 | return pgp.KeyManager.import_from_armored_pgp({ 80 | armored: data 81 | }, (err, km) => { 82 | expect(err).toEqual(null); 83 | if (km.is_pgp_locked()) { 84 | expect(passphrase).toBeDefined(); 85 | km.unlock_pgp({ passphrase }, err => { 86 | return expect(err).toEqual(null); 87 | }); 88 | } 89 | key.key = km; 90 | key.setTimeout(); 91 | if (callback != null) { 92 | return callback(); 93 | } 94 | }); 95 | }); 96 | 97 | // define an encrypted and an unencrypted message 98 | this.unencryptedMsg = new Message({clientId: 'test', subject: 'Subject', body: 'Body
'}); 99 | const body = `-----BEGIN PGP MESSAGE----- 100 | Version: Keybase OpenPGP v2.0.52 Comment: keybase.io/crypto 101 | 102 | wcBMA5nwa6GWVDOUAQf+MjiVRIBWJyM6The6/h2MgSJTDyrN9teFFJTizOvgHNnD W4EpEmmhShNyERI67qXhC03lFczu2Zp2Qofgs8YePIEv7wwb27/cviODsE42YJvX 1zGir+jBp81s9ZiF4dex6Ir9XfiZJlypI2QV2dHjO+5pstW+XhKIc1R5vKvoFTGI 1XmZtL3EgtKfj/HkPUkq2N0G5kAoB2MTTQuurfXm+3TRkftqesyTKlek652sFjCv nSF+LQ1GYq5hI4YaUBiHnZd7wKUgDrIh2rzbuGq+AHjrHdVLMfRTbN0Xsy3OWRcC 9uWU8Nln00Ly6KbTqPXKcBDcMrOJuoxYcpmLlhRds9JoAY7MyIsj87M2mkTtAtMK hqK0PPvJKfepV+eljDhQ7y0TQ0IvNtO5/pcY2CozbFJncm/ToxxZPNJueKRcz+EH M9uBvrWNTwfHj26g405gpRDN1T8CsY5ZeiaDHduIKnBWd4za0ak0Xfw= 103 | =1aPN 104 | -----END PGP MESSAGE-----`; 105 | this.encryptedMsg = new Message({clientId: 'test2', subject: 'Subject', body}); 106 | 107 | // blow away the saved identities and set up a test pub/priv keypair 108 | PGPKeyStore._identities = {}; 109 | const pubIdent = new Identity({ 110 | addresses: ["benbitdiddle@icloud.com"], 111 | isPriv: false 112 | }); 113 | PGPKeyStore._identities[pubIdent.clientId] = pubIdent; 114 | const privIdent = new Identity({ 115 | addresses: ["benbitdiddle@icloud.com"], 116 | isPriv: true 117 | }); 118 | return PGPKeyStore._identities[privIdent.clientId] = privIdent; 119 | }); 120 | 121 | describe("when handling private keys", function() { 122 | it('should be able to retrieve and unlock a private key', function() { 123 | expect(PGPKeyStore.privKeys().some((cv, index, array) => { 124 | return cv.hasOwnProperty("key"); 125 | })).toBeFalsey; 126 | const key = PGPKeyStore.privKeys({address: "benbitdiddle@icloud.com", timed: false})[0]; 127 | return PGPKeyStore.getKeyContents({key, passphrase: "", callback: () => { 128 | return expect(PGPKeyStore.privKeys({timed: false}).some((cv, index, array) => { 129 | return cv.hasOwnProperty("key"); 130 | })).toBeTruthy; 131 | } 132 | }); 133 | }); 134 | 135 | it('should not return a private key after its timeout has passed', function() { 136 | expect(PGPKeyStore.privKeys({address: "benbitdiddle@icloud.com", timed: false}).length).toEqual(1); 137 | PGPKeyStore.privKeys({address: "benbitdiddle@icloud.com", timed: false})[0].timeout = Date.now() - 5; 138 | expect(PGPKeyStore.privKeys({address: "benbitdiddle@icloud.com", timed: true}).length).toEqual(0); 139 | return PGPKeyStore.privKeys({address: "benbitdiddle@icloud.com", timed: false})[0].setTimeout(); 140 | }); 141 | 142 | it('should only return the key(s) corresponding to a supplied email address', () => expect(PGPKeyStore.privKeys({address: "wrong@example.com", timed: true}).length).toEqual(0)); 143 | 144 | it('should return all private keys when an address is not supplied', () => expect(PGPKeyStore.privKeys({timed: false}).length).toEqual(1)); 145 | 146 | return it('should update an existing key when it is unlocked, not add a new one', function() { 147 | const { timeout } = PGPKeyStore.privKeys({address: "benbitdiddle@icloud.com", timed: false})[0]; 148 | return PGPKeyStore.getKeyContents({key: PGPKeyStore.privKeys({timed: false})[0], passphrase: "", callback: () => { 149 | // expect no new keys to have been added 150 | expect(PGPKeyStore.privKeys({timed: false}).length).toEqual(1); 151 | // make sure the timeout is updated 152 | return expect(timeout < PGPKeyStore.privKeys({address: "benbitdiddle@icloud.com", timed: false}).timeout); 153 | } 154 | }); 155 | }); 156 | }); 157 | 158 | describe("when decrypting messages", function() { 159 | xit('should be able to decrypt a message', function() { 160 | // TODO for some reason, the pgp.unbox has a problem with the message body 161 | runs( () => { 162 | spyOn(PGPKeyStore, 'trigger'); 163 | return PGPKeyStore.getKeyContents({key: PGPKeyStore.privKeys({timed: false})[0], passphrase: "", callback: () => { 164 | return PGPKeyStore.decrypt(this.encryptedMsg); 165 | } 166 | }); 167 | }); 168 | waitsFor((() => PGPKeyStore.trigger.callCount > 0), 'message to decrypt'); 169 | return runs( () => { 170 | return expect(_.findWhere(PGPKeyStore._msgCache, 171 | {clientId: this.encryptedMsg.clientId})).toExist(); 172 | }); 173 | }); 174 | 175 | it('should be able to handle an unencrypted message', function() { 176 | PGPKeyStore.decrypt(this.unencryptedMsg); 177 | return expect(_.findWhere(PGPKeyStore._msgCache, 178 | {clientId: this.unencryptedMsg.clientId})).not.toBeDefined(); 179 | }); 180 | 181 | it('should be able to tell when a message has no encrypted component', function() { 182 | expect(PGPKeyStore.hasEncryptedComponent(this.unencryptedMsg)).not; 183 | return expect(PGPKeyStore.hasEncryptedComponent(this.encryptedMsg)); 184 | }); 185 | 186 | it('should be able to handle a message with no BEGIN PGP MESSAGE block', function() { 187 | const body = `Version: Keybase OpenPGP v2.0.52 Comment: keybase.io/crypto 188 | 189 | wcBMA5nwa6GWVDOUAQf+MjiVRIBWJyM6The6/h2MgSJTDyrN9teFFJTizOvgHNnD W4EpEmmhShNyERI67qXhC03lFczu2Zp2Qofgs8YePIEv7wwb27/cviODsE42YJvX 1zGir+jBp81s9ZiF4dex6Ir9XfiZJlypI2QV2dHjO+5pstW+XhKIc1R5vKvoFTGI 1XmZtL3EgtKfj/HkPUkq2N0G5kAoB2MTTQuurfXm+3TRkftqesyTKlek652sFjCv nSF+LQ1GYq5hI4YaUBiHnZd7wKUgDrIh2rzbuGq+AHjrHdVLMfRTbN0Xsy3OWRcC 9uWU8Nln00Ly6KbTqPXKcBDcMrOJuoxYcpmLlhRds9JoAY7MyIsj87M2mkTtAtMK hqK0PPvJKfepV+eljDhQ7y0TQ0IvNtO5/pcY2CozbFJncm/ToxxZPNJueKRcz+EH M9uBvrWNTwfHj26g405gpRDN1T8CsY5ZeiaDHduIKnBWd4za0ak0Xfw= 190 | =1aPN 191 | -----END PGP MESSAGE-----`; 192 | const badMsg = new Message({clientId: 'test2', subject: 'Subject', body}); 193 | 194 | return PGPKeyStore.getKeyContents({key: PGPKeyStore.privKeys({timed: false})[0], passphrase: "", callback: () => { 195 | PGPKeyStore.decrypt(badMsg); 196 | return expect(_.findWhere(PGPKeyStore._msgCache, 197 | {clientId: badMsg.clientId})).not.toBeDefined(); 198 | } 199 | }); 200 | }); 201 | 202 | it('should be able to handle a message with no END PGP MESSAGE block', function() { 203 | const body = `-----BEGIN PGP MESSAGE----- 204 | Version: Keybase OpenPGP v2.0.52 Comment: keybase.io/crypto 205 | 206 | wcBMA5nwa6GWVDOUAQf+MjiVRIBWJyM6The6/h2MgSJTDyrN9teFFJTizOvgHNnD W4EpEmmhShNyERI67qXhC03lFczu2Zp2Qofgs8YePIEv7wwb27/cviODsE42YJvX 1zGir+jBp81s9ZiF4dex6Ir9XfiZJlypI2QV2dHjO+5pstW+XhKIc1R5vKvoFTGI 1XmZtL3EgtKfj/HkPUkq2N0G5kAoB2MTTQuurfXm+3TRkftqesyTKlek652sFjCv nSF+LQ1GYq5hI4YaUBiHnZd7wKUgDrIh2rzbuGq+AHjrHdVLMfRTbN0Xsy3OWRcC 9uWU8Nln00Ly6KbTqPXKcBDcMrOJuoxYcpmLlhRds9JoAY7MyIsj87M2mkTtAtMK hqK0PPvJKfepV+eljDhQ7y0TQ0IvNtO5/pcY2CozbFJncm/ToxxZPNJueKRcz+EH M9uBvrWNTwfHj26g405gpRDN1T8CsY5ZeiaDHduIKnBWd4za0ak0Xfw= 207 | =1aPN`; 208 | const badMsg = new Message({clientId: 'test2', subject: 'Subject', body}); 209 | 210 | return PGPKeyStore.getKeyContents({key: PGPKeyStore.privKeys({timed: false})[0], passphrase: "", callback: () => { 211 | PGPKeyStore.decrypt(badMsg); 212 | return expect(_.findWhere(PGPKeyStore._msgCache, 213 | {clientId: badMsg.clientId})).not.toBeDefined(); 214 | } 215 | }); 216 | }); 217 | 218 | it('should not return a decrypted message which has timed out', function() { 219 | PGPKeyStore._msgCache.push({clientId: "testID", body: "example body", timeout: Date.now()}); 220 | 221 | const msg = new Message({clientId: "testID"}); 222 | return expect(PGPKeyStore.getDecrypted(msg)).toEqual(null); 223 | }); 224 | 225 | return it('should return a decrypted message', function() { 226 | const timeout = Date.now() + (1000*60*60); 227 | PGPKeyStore._msgCache.push({clientId: "testID2", body: "example body", timeout}); 228 | 229 | const msg = new Message({clientId: "testID2", body: "example body"}); 230 | return expect(PGPKeyStore.getDecrypted(msg)).toEqual(msg.body); 231 | }); 232 | }); 233 | 234 | return describe("when handling public keys", () => 235 | 236 | it("should immediately return a pre-cached key", () => expect(PGPKeyStore.pubKeys('benbitdiddle@icloud.com').length).toEqual(1)) 237 | ); 238 | }); 239 | -------------------------------------------------------------------------------- /spec/recipient-key-chip-spec.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * decaffeinate suggestions: 3 | * DS102: Remove unnecessary code created because of implicit returns 4 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 5 | */ 6 | const {React, ReactTestUtils, DraftStore, Contact} = require('mailspring-exports'); 7 | const pgp = require('kbpgp'); 8 | 9 | const RecipientKeyChip = require('../lib/recipient-key-chip'); 10 | const PGPKeyStore = require('../lib/pgp-key-store'); 11 | 12 | describe("DecryptMessageButton", function() { 13 | beforeEach(function() { 14 | this.contact = new Contact({email: "test@example.com"}); 15 | return this.component = ReactTestUtils.renderIntoDocument( 16 | React.createElement(RecipientKeyChip, {"contact": this.contact}) 17 | ); 18 | }); 19 | 20 | it("should render into the page", function() { 21 | return expect(this.component).toBeDefined(); 22 | }); 23 | 24 | it("should have a displayName", () => expect(RecipientKeyChip.displayName).toBe('RecipientKeyChip')); 25 | 26 | xit("should indicate when a recipient has a PGP key available", function() { 27 | spyOn(PGPKeyStore, "pubKeys").andCallFake(address => { 28 | return [{'key':0}]; 29 | }); 30 | const key = PGPKeyStore.pubKeys(this.contact.email); 31 | expect(key).toBeDefined(); 32 | 33 | // TODO these calls crash the tester because they require a call to getKeyContents 34 | expect(this.component.refs.keyIcon).toBeDefined(); 35 | return expect(this.component.refs.noKeyIcon).not.toBeDefined(); 36 | }); 37 | 38 | return xit("should indicate when a recipient does not have a PGP key available", function() { 39 | const component = ReactTestUtils.renderIntoDocument( 40 | React.createElement(RecipientKeyChip, {"contact": this.contact}) 41 | ); 42 | 43 | const key = PGPKeyStore.pubKeys(this.contact.email); 44 | expect(key).toEqual([]); 45 | 46 | // TODO these calls crash the tester because they require a call to getKeyContents 47 | expect(component.refs.keyIcon).not.toBeDefined(); 48 | return expect(component.refs.noKeyIcon).toBeDefined(); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/decrypt-button.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * decaffeinate suggestions: 3 | * DS001: Remove Babel/TypeScript constructor workaround 4 | * DS101: Remove unnecessary use of Array.from 5 | * DS102: Remove unnecessary code created because of implicit returns 6 | * DS205: Consider reworking code to avoid use of IIFEs 7 | * DS206: Consider reworking classes to avoid initClass 8 | * DS207: Consider shorter variations of null checks 9 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 10 | */ 11 | const { 12 | MessageStore, 13 | React, 14 | ReactDOM, 15 | AttachmentStore, 16 | MessageBodyProcessor, 17 | Actions 18 | } = require("mailspring-exports"); 19 | const PGPKeyStore = require("./pgp-key-store"); 20 | const { remote } = require("electron"); 21 | const PassphrasePopover = require("./passphrase-popover"); 22 | const PrivateKeyPopover = require("./private-key-popover"); 23 | const pgp = require("kbpgp"); 24 | const _ = require("underscore"); 25 | import PropTypes from 'prop-types'; 26 | 27 | class DecryptMessageButton extends React.Component { 28 | static displayName = "DecryptMessageButton"; 29 | static initClass() { 30 | this.propTypes = { message: PropTypes.object.isRequired }; 31 | } 32 | 33 | constructor(props) { 34 | super(props); 35 | this._onClickDecrypt = this._onClickDecrypt.bind(this); 36 | this._onClickDecryptAttachments = this._onClickDecryptAttachments.bind( 37 | this 38 | ); 39 | this.decryptPopoverDone = this.decryptPopoverDone.bind(this); 40 | this.decryptAttachmentsPopoverDone = this.decryptAttachmentsPopoverDone.bind( 41 | this 42 | ); 43 | this._openPassphrasePopover = this._openPassphrasePopover.bind(this); 44 | this._noPrivateKeys = this._noPrivateKeys.bind(this); 45 | this.render = this.render.bind(this); 46 | this.state = this._getStateFromStores(); 47 | } 48 | 49 | _getStateFromStores() { 50 | return { 51 | isDecrypted: PGPKeyStore.isDecrypted(this.props.message), 52 | wasEncrypted: PGPKeyStore.hasEncryptedComponent(this.props.message), 53 | encryptedAttachments: PGPKeyStore.fetchEncryptedAttachments( 54 | this.props.message 55 | ), 56 | status: PGPKeyStore.msgStatus(this.props.message) 57 | }; 58 | } 59 | 60 | componentDidMount() { 61 | return (this.unlistenKeystore = PGPKeyStore.listen( 62 | this._onKeystoreChange, 63 | this 64 | )); 65 | } 66 | 67 | componentWillUnmount() { 68 | return this.unlistenKeystore(); 69 | } 70 | 71 | _onKeystoreChange() { 72 | // every time a new key gets unlocked/fetched, try to decrypt this message 73 | if (!this.state.isDecrypted) { 74 | PGPKeyStore.decrypt(this.props.message); 75 | } 76 | return this.setState(this._getStateFromStores()); 77 | } 78 | 79 | _onClickDecrypt(event) { 80 | const popoverTarget = event.target.getBoundingClientRect(); 81 | if (this._noPrivateKeys()) { 82 | return Actions.openPopover( 83 |tag prevents gross HTML formatting in-flight 177 | cryptotext = `${cryptotext}`; 178 | this.setState({ 179 | currentlyEncrypted: true, 180 | plaintext, 181 | cryptotext 182 | }); 183 | return this.props.session.changes.add({ body: cryptotext }); 184 | } 185 | } 186 | ); 187 | } 188 | } 189 | 190 | _encrypt(text, identities, cb) { 191 | // get the actual key objects 192 | const keys = _.pluck(identities, "key"); 193 | // remove the nulls 194 | const kms = _.compact(keys); 195 | if (kms.length === 0) { 196 | AppEnv.showErrorDialog(`There are no PGP public keys loaded, so the message cannot be \ 197 | encrypted. Compose a message, add recipients in the To: field, and try again.`); 198 | return; 199 | } 200 | const params = { 201 | encrypt_for: kms, 202 | msg: text 203 | }; 204 | return pgp.box(params, cb); 205 | } 206 | 207 | _checkKeysAndEncrypt(text, identities, cb) { 208 | const emails = _.chain(identities) 209 | .pluck("addresses") 210 | .flatten() 211 | .uniq() 212 | .value(); 213 | 214 | if (_.every(identities, identity => identity.key != null)) { 215 | // every key is present and valid 216 | return this._encrypt(text, identities, cb); 217 | } else { 218 | // open a popover to correct null keys 219 | return DatabaseStore.findAll(Contact, { email: emails }).then( 220 | contacts => { 221 | const component = ( 222 |this._encrypt(text, newIdentities, cb)} 226 | /> 227 | ); 228 | return Actions.openPopover(component, { 229 | originRect: ReactDOM.findDOMNode(this).getBoundingClientRect(), 230 | direction: "up", 231 | closeOnAppBlur: false 232 | }); 233 | } 234 | ); 235 | } 236 | } 237 | } 238 | EncryptMessageButton.initClass(); 239 | 240 | module.exports = EncryptMessageButton; 241 | -------------------------------------------------------------------------------- /src/identity.js: -------------------------------------------------------------------------------- 1 | /* 2 | * decaffeinate suggestions: 3 | * DS102: Remove unnecessary code created because of implicit returns 4 | * DS207: Consider shorter variations of null checks 5 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 6 | */ 7 | // A single user identity: a key, a way to find that key, one or more email 8 | // addresses, and a keybase profile 9 | 10 | let Identity; 11 | const { Utils } = require("mailspring-exports"); 12 | const path = require("path"); 13 | 14 | module.exports = Identity = class Identity { 15 | constructor({ key, addresses, isPriv, keybase_profile }) { 16 | this.clientId = Utils.generateTempId(); 17 | this.key = key != null ? key : null; // keybase keymanager object 18 | this.isPriv = isPriv != null ? isPriv : false; // is this a private key? 19 | this.timeout = null; // the time after which this key (if private) needs to be unlocked again 20 | this.addresses = addresses != null ? addresses : []; // email addresses associated with this identity 21 | this.keybase_profile = keybase_profile != null ? keybase_profile : null; // a kb profile object associated with this identity 22 | 23 | Object.defineProperty(this, "keyPath", { 24 | get() { 25 | let keyPath; 26 | if (this.addresses.length > 0) { 27 | const keyDir = path.join(AppEnv.getConfigDirPath(), "keys"); 28 | const thisDir = this.isPriv 29 | ? path.join(keyDir, "private") 30 | : path.join(keyDir, "public"); 31 | keyPath = path.join(thisDir, this.addresses.join(" ")); 32 | } else { 33 | keyPath = null; 34 | } 35 | return keyPath; 36 | } 37 | }); 38 | 39 | if (this.isPriv) { 40 | this.setTimeout(); 41 | } 42 | } 43 | 44 | fingerprint() { 45 | if (this.key != null) { 46 | return this.key.get_pgp_fingerprint().toString("hex"); 47 | } 48 | return null; 49 | } 50 | 51 | setTimeout() { 52 | const delay = 1000 * 60 * 30; // 30 minutes in ms 53 | return (this.timeout = Date.now() + delay); 54 | } 55 | 56 | isTimedOut() { 57 | return this.timeout < Date.now(); 58 | } 59 | 60 | uid() { 61 | let uid; 62 | if (this.key != null) { 63 | uid = this.key.get_pgp_fingerprint().toString("hex"); 64 | } else if (this.keybase_profile != null) { 65 | uid = this.keybase_profile.components.username.val; 66 | } else if (this.addresses.length > 0) { 67 | uid = this.addresses.join(""); 68 | } else { 69 | uid = this.clientId; 70 | } 71 | 72 | return uid; 73 | } 74 | }; 75 | -------------------------------------------------------------------------------- /src/key-adder.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * decaffeinate suggestions: 3 | * DS001: Remove Babel/TypeScript constructor workaround 4 | * DS102: Remove unnecessary code created because of implicit returns 5 | * DS206: Consider reworking classes to avoid initClass 6 | * DS207: Consider shorter variations of null checks 7 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 8 | */ 9 | let KeyAdder; 10 | const { Utils, React, RegExpUtils } = require("mailspring-exports"); 11 | const { RetinaImg } = require("mailspring-component-kit"); 12 | const PGPKeyStore = require("./pgp-key-store"); 13 | const Identity = require("./identity"); 14 | const kb = require("./keybase"); 15 | const pgp = require("kbpgp"); 16 | const _ = require("underscore"); 17 | const fs = require("fs"); 18 | 19 | module.exports = KeyAdder = (function() { 20 | KeyAdder = class KeyAdder extends React.Component { 21 | static displayName = "KeyAdder"; 22 | 23 | 24 | constructor(props) { 25 | super(); 26 | this._onPasteButtonClick = this._onPasteButtonClick.bind(this); 27 | this._onGenerateButtonClick = this._onGenerateButtonClick.bind(this); 28 | this._onImportButtonClick = this._onImportButtonClick.bind(this); 29 | this._onInnerGenerateButtonClick = this._onInnerGenerateButtonClick.bind( 30 | this 31 | ); 32 | this._generateKeypair = this._generateKeypair.bind(this); 33 | this._saveNewKey = this._saveNewKey.bind(this); 34 | this._onAddressChange = this._onAddressChange.bind(this); 35 | this._onPassphraseChange = this._onPassphraseChange.bind(this); 36 | this._onKeyChange = this._onKeyChange.bind(this); 37 | this.state = { 38 | address: "", 39 | keyContents: "", 40 | passphrase: "", 41 | 42 | generate: false, 43 | paste: false, 44 | import: false, 45 | 46 | isPriv: false, 47 | loading: false, 48 | 49 | validAddress: false, 50 | validKeyBody: false 51 | }; 52 | } 53 | 54 | _onPasteButtonClick(event) { 55 | return this.setState({ 56 | generate: false, 57 | paste: !this.state.paste, 58 | import: false, 59 | address: "", 60 | validAddress: false, 61 | keyContents: "" 62 | }); 63 | } 64 | 65 | _onGenerateButtonClick(event) { 66 | return this.setState({ 67 | generate: !this.state.generate, 68 | paste: false, 69 | import: false, 70 | address: "", 71 | validAddress: false, 72 | keyContents: "", 73 | passphrase: "" 74 | }); 75 | } 76 | 77 | _onImportButtonClick(event) { 78 | return AppEnv.showOpenDialog( 79 | { 80 | title: "Import PGP Key", 81 | buttonLabel: "Import", 82 | properties: ["openFile"] 83 | }, 84 | filepath => { 85 | if (filepath != null) { 86 | this.setState({ 87 | generate: false, 88 | paste: false, 89 | import: true, 90 | address: "", 91 | validAddress: false, 92 | passphrase: "" 93 | }); 94 | return fs.readFile(filepath[0], (err, data) => { 95 | return pgp.KeyManager.import_from_armored_pgp( 96 | { 97 | armored: data 98 | }, 99 | (err, km) => { 100 | if (err) { 101 | PGPKeyStore._displayError("File is not a valid PGP key."); 102 | return; 103 | } else { 104 | const privateStart = 105 | "-----BEGIN PGP PRIVATE KEY BLOCK-----"; 106 | const keyBody = 107 | km.armored_pgp_private != null 108 | ? km.armored_pgp_private 109 | : km.armored_pgp_public; 110 | return this.setState({ 111 | keyContents: keyBody, 112 | isPriv: keyBody.indexOf(privateStart) >= 0, 113 | validKeyBody: true 114 | }); 115 | } 116 | } 117 | ); 118 | }); 119 | } 120 | } 121 | ); 122 | } 123 | 124 | _onInnerGenerateButtonClick(event) { 125 | this.setState({ 126 | loading: true 127 | }); 128 | return this._generateKeypair(); 129 | } 130 | 131 | _generateKeypair() { 132 | return pgp.KeyManager.generate_rsa( 133 | { userid: this.state.address }, 134 | (err, km) => { 135 | return km.sign({}, err => { 136 | if (err) { 137 | console.warn(err); 138 | } 139 | km.export_pgp_private( 140 | { passphrase: this.state.passphrase }, 141 | (err, pgp_private) => { 142 | const ident = new Identity({ 143 | addresses: [this.state.address], 144 | isPriv: true 145 | }); 146 | return PGPKeyStore.saveNewKey(ident, pgp_private); 147 | } 148 | ); 149 | return km.export_pgp_public({}, (err, pgp_public) => { 150 | const ident = new Identity({ 151 | addresses: [this.state.address], 152 | isPriv: false 153 | }); 154 | PGPKeyStore.saveNewKey(ident, pgp_public); 155 | return this.setState({ 156 | keyContents: pgp_public, 157 | loading: false 158 | }); 159 | }); 160 | }); 161 | } 162 | ); 163 | } 164 | 165 | _saveNewKey() { 166 | const ident = new Identity({ 167 | addresses: [this.state.address], 168 | isPriv: this.state.isPriv 169 | }); 170 | return PGPKeyStore.saveNewKey(ident, this.state.keyContents); 171 | } 172 | 173 | _onAddressChange(event) { 174 | const address = event.target.value; 175 | let valid = false; 176 | if ( 177 | address && 178 | address.length > 0 && 179 | RegExpUtils.emailRegex().test(address) 180 | ) { 181 | valid = true; 182 | } 183 | return this.setState({ 184 | address: event.target.value, 185 | validAddress: valid 186 | }); 187 | } 188 | 189 | _onPassphraseChange(event) { 190 | return this.setState({ 191 | passphrase: event.target.value 192 | }); 193 | } 194 | 195 | _onKeyChange(event) { 196 | const privateStart = "-----BEGIN PGP PRIVATE KEY BLOCK-----"; 197 | this.setState({ 198 | keyContents: event.target.value, 199 | isPriv: event.target.value.indexOf(privateStart) >= 0 200 | }); 201 | return pgp.KeyManager.import_from_armored_pgp( 202 | { 203 | armored: event.target.value 204 | }, 205 | (err, km) => { 206 | let valid; 207 | if (err) { 208 | valid = false; 209 | } else { 210 | valid = true; 211 | } 212 | return this.setState({ 213 | validKeyBody: valid 214 | }); 215 | } 216 | ); 217 | } 218 | 219 | _renderAddButtons() { 220 | return ( 221 | 222 | {`\ 223 | Add a PGP Key:\ 224 | `} 225 | 232 | 239 | 246 |247 | ); 248 | } 249 | 250 | _renderManualKey() { 251 | let invalidMsg; 252 | if (!this.state.validAddress && this.state.address.length > 0) { 253 | invalidMsg = Invalid email address; 254 | } else if ( 255 | !this.state.validKeyBody && 256 | this.state.keyContents.length > 0 257 | ) { 258 | invalidMsg = Invalid key body; 259 | } else { 260 | invalidMsg = ; 261 | } 262 | const invalidInputs = !( 263 | this.state.validAddress && this.state.validKeyBody 264 | ); 265 | 266 | const buttonClass = invalidInputs 267 | ? "btn key-add-btn btn-disabled" 268 | : "btn key-add-btn"; 269 | 270 | const passphraseInput = ( 271 | 278 | ); 279 | 280 | return ( 281 |282 |310 | ); 311 | } 312 | 313 | _renderGenerateKey() { 314 | let invalidMsg, keyPlaceholder; 315 | if (!this.state.validAddress && this.state.address.length > 0) { 316 | invalidMsg = Invalid email address; 317 | } else { 318 | invalidMsg = ; 319 | } 320 | 321 | const loading = ( 322 |283 | 289 |290 |291 | 298 | {this.state.isPriv ? passphraseInput : undefined} 299 | {invalidMsg} 300 | 308 |309 |327 | ); 328 | if (this.state.loading) { 329 | keyPlaceholder = "Generating your key now. This could take a while."; 330 | } else { 331 | keyPlaceholder = 332 | "Your generated public key will appear here. Share it with your friends!"; 333 | } 334 | 335 | const buttonClass = !this.state.validAddress 336 | ? "btn key-add-btn btn-disabled" 337 | : "btn key-add-btn"; 338 | 339 | return ( 340 | 341 |378 | ); 379 | } 380 | 381 | render() { 382 | return ( 383 |342 | 349 | 356 | {invalidMsg} 357 | 365 |366 |367 |377 |368 | {this.state.loading ? loading : undefined} 369 |370 | 376 |384 | {this._renderAddButtons()} 385 | {this.state.generate ? this._renderGenerateKey() : undefined} 386 | {this.state.paste || this.state.import 387 | ? this._renderManualKey() 388 | : undefined} 389 |390 | ); 391 | } 392 | }; 393 | return KeyAdder; 394 | })(); 395 | -------------------------------------------------------------------------------- /src/key-manager.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * decaffeinate suggestions: 3 | * DS001: Remove Babel/TypeScript constructor workaround 4 | * DS102: Remove unnecessary code created because of implicit returns 5 | * DS206: Consider reworking classes to avoid initClass 6 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 7 | */ 8 | let KeyManager; 9 | const { Utils, React, Actions } = require("mailspring-exports"); 10 | const PGPKeyStore = require("./pgp-key-store"); 11 | const KeybaseUser = require("./keybase-user"); 12 | const PassphrasePopover = require("./passphrase-popover"); 13 | const kb = require("./keybase"); 14 | const _ = require("underscore"); 15 | const pgp = require("kbpgp"); 16 | const fs = require("fs"); 17 | import PropTypes from 'prop-types'; 18 | 19 | module.exports = KeyManager = (function() { 20 | KeyManager = class KeyManager extends React.Component { 21 | static displayName = "KeyManager"; 22 | static initClass() { 23 | this.propTypes = { 24 | pubKeys: PropTypes.array.isRequired, 25 | privKeys: PropTypes.array.isRequired 26 | }; 27 | } 28 | 29 | constructor(props) { 30 | super(props); 31 | this._exportPopoverDone = this._exportPopoverDone.bind(this); 32 | this._exportPrivateKey = this._exportPrivateKey.bind(this); 33 | } 34 | 35 | _exportPopoverDone(passphrase, identity) { 36 | // check the passphrase before opening the save dialog 37 | return fs.readFile(identity.keyPath, (err, data) => { 38 | return pgp.KeyManager.import_from_armored_pgp( 39 | { 40 | armored: data 41 | }, 42 | (err, km) => { 43 | if (err) { 44 | return console.warn(err); 45 | } else { 46 | return km.unlock_pgp({ passphrase }, err => { 47 | if (err) { 48 | return PGPKeyStore._displayError(err); 49 | } else { 50 | return PGPKeyStore.exportKey({ identity, passphrase }); 51 | } 52 | }); 53 | } 54 | } 55 | ); 56 | }); 57 | } 58 | 59 | _exportPrivateKey(identity, event) { 60 | const popoverTarget = event.target.getBoundingClientRect(); 61 | 62 | return Actions.openPopover( 63 |, 68 | { originRect: popoverTarget, direction: "left" } 69 | ); 70 | } 71 | 72 | render() { 73 | let { pubKeys, privKeys } = this.props; 74 | 75 | pubKeys = pubKeys.map(identity => { 76 | const deleteButton = ( 77 | 85 | ); 86 | const exportButton = ( 87 | 95 | ); 96 | const actionButton = ( 97 | 98 | {exportButton} 99 | {deleteButton} 100 |101 | ); 102 | return ( 103 |108 | ); 109 | }); 110 | 111 | privKeys = privKeys.map(identity => { 112 | const deleteButton = ( 113 | 121 | ); 122 | const exportButton = ( 123 | 131 | ); 132 | const actionButton = ( 133 | 134 | {exportButton} 135 | {deleteButton} 136 |137 | ); 138 | return ( 139 |144 | ); 145 | }); 146 | 147 | return ( 148 | 149 |162 | ); 163 | } 164 | }; 165 | KeyManager.initClass(); 166 | return KeyManager; 167 | })(); 168 | -------------------------------------------------------------------------------- /src/keybase-search.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * decaffeinate suggestions: 3 | * DS001: Remove Babel/TypeScript constructor workaround 4 | * DS102: Remove unnecessary code created because of implicit returns 5 | * DS206: Consider reworking classes to avoid initClass 6 | * DS207: Consider shorter variations of null checks 7 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 8 | */ 9 | let KeybaseSearch; 10 | const { 11 | Utils, 12 | React, 13 | ReactDOM, 14 | Actions, 15 | RegExpUtils, 16 | IdentityStore, 17 | AccountStore 18 | } = require("mailspring-exports"); 19 | const { RetinaImg } = require("mailspring-component-kit"); 20 | const EmailPopover = require("./email-popover"); 21 | const PGPKeyStore = require("./pgp-key-store"); 22 | const KeybaseUser = require("../lib/keybase-user"); 23 | const Identity = require("./identity"); 24 | const kb = require("./keybase"); 25 | const _ = require("underscore"); 26 | import PropTypes from 'prop-types'; 27 | //import {Component} from 'react'; 28 | 29 | module.exports = KeybaseSearch = (function() { 30 | KeybaseSearch = class KeybaseSearch extends React.Component { 31 | static displayName = "KeybaseSearch"; 32 | static initClass() { 33 | this.propTypes = { 34 | initialSearch: PropTypes.string, 35 | // importFunc: a alternate function to execute when the "import" button is 36 | // clicked instead of the "please specify an email" popover 37 | importFunc: PropTypes.func, 38 | // TODO consider just passing in a pre-specified email instead of a func? 39 | inPreferences: PropTypes.bool 40 | }; 41 | 42 | this.defaultProps = { 43 | initialSearch: "", 44 | importFunc: null, 45 | inPreferences: false 46 | }; 47 | } 48 | 49 | constructor(props) { 50 | 51 | super(props); 52 | this._importKey = this._importKey.bind(this); 53 | this._popoverDone = this._popoverDone.bind(this); 54 | this._save = this._save.bind(this); 55 | this._queryChange = this._queryChange.bind(this); 56 | this.state = { 57 | query: props.initialSearch, 58 | results: [], 59 | loading: false, 60 | searchedByEmail: false 61 | }; 62 | 63 | this.debouncedSearch = _.debounce(this._search, 300); 64 | } 65 | 66 | componentDidMount() { 67 | return this._search(); 68 | } 69 | 70 | componentWillReceiveProps(props) { 71 | return this.setState({ query: props.initialSearch }); 72 | } 73 | 74 | _search() { 75 | const oldquery = this.state.query; 76 | if (this.state.query !== "" && this.state.loading === false) { 77 | this.setState({ loading: true }); 78 | return kb.autocomplete(this.state.query, (error, profiles) => { 79 | if (profiles != null) { 80 | profiles = _.map( 81 | profiles, 82 | profile => 83 | new Identity({ keybase_profile: profile, isPriv: false }) 84 | ); 85 | this.setState({ results: profiles, loading: false }); 86 | } else { 87 | this.setState({ results: [], loading: false }); 88 | } 89 | if (this.state.query !== oldquery) { 90 | return this.debouncedSearch(); 91 | } 92 | }); 93 | } else { 94 | // no query - empty out the results 95 | return this.setState({ results: [] }); 96 | } 97 | } 98 | 99 | _importKey(profile, event) { 100 | // opens a popover requesting user to enter 1+ emails to associate with a 101 | // key - a button in the popover then calls _save to actually import the key 102 | const popoverTarget = event.target.getBoundingClientRect(); 103 | 104 | return Actions.openPopover( 105 |150 | 151 |154 |Saved Public Keys152 | 153 |{pubKeys}155 |156 | 157 |160 |Saved Private Keys158 | 159 |{privKeys}161 |, 106 | { originRect: popoverTarget, direction: "left" } 107 | ); 108 | } 109 | 110 | _popoverDone(addresses, identity) { 111 | if (addresses.length < 1) { 112 | // no email addresses added, noop 113 | return; 114 | } else { 115 | identity.addresses = addresses; 116 | // TODO validate the addresses? 117 | return this._save(identity); 118 | } 119 | } 120 | 121 | _save(identity) { 122 | // save/import a key from keybase 123 | const keybaseUsername = identity.keybase_profile.components.username.val; 124 | 125 | return kb.getKey(keybaseUsername, (error, key) => { 126 | if (error) { 127 | return console.error(error); 128 | } else { 129 | return PGPKeyStore.saveNewKey(identity, key); 130 | } 131 | }); 132 | } 133 | 134 | _queryChange(event) { 135 | const emailQuery = RegExpUtils.emailRegex().test(event.target.value); 136 | this.setState({ query: event.target.value, searchedByEmail: emailQuery }); 137 | return this.debouncedSearch(); 138 | } 139 | 140 | render() { 141 | let profiles = _.map(this.state.results, profile => { 142 | // allow for overriding the import function 143 | let boundFunc; 144 | if (typeof this.props.importFunc === "function") { 145 | boundFunc = this.props.importFunc; 146 | } else { 147 | boundFunc = this._importKey; 148 | } 149 | 150 | const saveButton = ( 151 | 159 | ); 160 | 161 | // TODO improved deduping? tricky because of the kbprofile - email association 162 | if (profile.keyPath == null) { 163 | return ; 164 | } 165 | }); 166 | 167 | if (profiles == null || profiles.length < 1) { 168 | profiles = []; 169 | } 170 | 171 | let badSearch = null; 172 | let loading = null; 173 | const empty = null; 174 | 175 | if (profiles.length < 1 && this.state.searchedByEmail) { 176 | badSearch = ( 177 | 178 | Keybase cannot be searched by email address.
Try entering a 179 | name, or a username from GitHub, Keybase or Twitter. 180 | 181 | ); 182 | } 183 | 184 | if (this.state.loading) { 185 | loading = ( 186 |191 | ); 192 | } 193 | 194 | return ( 195 | 196 |212 | ); 213 | } 214 | }; 215 | KeybaseSearch.initClass(); 216 | return KeybaseSearch; 217 | })(); 218 | -------------------------------------------------------------------------------- /src/keybase-user.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * decaffeinate suggestions: 3 | * DS001: Remove Babel/TypeScript constructor workaround 4 | * DS102: Remove unnecessary code created because of implicit returns 5 | * DS206: Consider reworking classes to avoid initClass 6 | * DS207: Consider shorter variations of null checks 7 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 8 | */ 9 | let KeybaseUser; 10 | const { Utils, React, Actions } = require("mailspring-exports"); 11 | const { ParticipantsTextField } = require("mailspring-component-kit"); 12 | const PGPKeyStore = require("./pgp-key-store"); 13 | const EmailPopover = require("./email-popover"); 14 | const Identity = require("./identity"); 15 | const kb = require("./keybase"); 16 | const _ = require("underscore"); 17 | import PropTypes from 'prop-types'; 18 | 19 | module.exports = KeybaseUser = (function() { 20 | KeybaseUser = class KeybaseUser extends React.Component { 21 | static displayName = "KeybaseUserProfile"; 22 | static initClass() { 23 | this.propTypes = { 24 | profile: PropTypes.instanceOf(Identity).isRequired, 25 | actionButton: PropTypes.node, 26 | displayEmailList: PropTypes.bool 27 | }; 28 | 29 | this.defaultProps = { 30 | actionButton: false, 31 | displayEmailList: true 32 | }; 33 | } 34 | 35 | constructor(props) { 36 | super(props); 37 | this._addEmail = this._addEmail.bind(this); 38 | this._addEmailClick = this._addEmailClick.bind(this); 39 | this._popoverDone = this._popoverDone.bind(this); 40 | this._removeEmail = this._removeEmail.bind(this); 41 | this.render = this.render.bind(this); 42 | } 43 | 44 | componentDidMount() { 45 | return PGPKeyStore.getKeybaseData(this.props.profile); 46 | } 47 | 48 | _addEmail(email) { 49 | return PGPKeyStore.addAddressToKey(this.props.profile, email); 50 | } 51 | 52 | _addEmailClick(event) { 53 | const popoverTarget = event.target.getBoundingClientRect(); 54 | 55 | return Actions.openPopover( 56 |197 | 204 | {empty} 205 |207 |{loading}206 |208 | {profiles} 209 | {badSearch} 210 |211 |, 60 | { originRect: popoverTarget, direction: "left" } 61 | ); 62 | } 63 | 64 | _popoverDone(addresses, identity) { 65 | if (addresses.length < 1) { 66 | // no email addresses added, noop 67 | return; 68 | } else { 69 | return _.each(addresses, address => { 70 | return this._addEmail(address); 71 | }); 72 | } 73 | } 74 | 75 | _removeEmail(email) { 76 | return PGPKeyStore.removeAddressFromKey(this.props.profile, email); 77 | } 78 | 79 | render() { 80 | let abv, bgColor, emailList, hue, picture; 81 | const { profile } = this.props; 82 | 83 | let keybaseDetails = ; 84 | if (profile.keybase_profile != null) { 85 | let fullname, keybase_string, username; 86 | const keybase = profile.keybase_profile; 87 | 88 | // profile picture 89 | if (keybase.thumbnail != null) { 90 | picture = ; 91 | } else { 92 | hue = Utils.hueForString("Keybase"); 93 | bgColor = `hsl(${hue}, 50%, 45%)`; 94 | abv = "K"; 95 | picture = ( 96 |
100 | {abv} 101 |102 | ); 103 | } 104 | 105 | // full name 106 | if ( 107 | (keybase.components.full_name != null 108 | ? keybase.components.full_name.val 109 | : undefined) != null 110 | ) { 111 | fullname = keybase.components.full_name.val; 112 | } else { 113 | fullname = username; 114 | username = false; 115 | } 116 | 117 | // link to keybase profile 118 | const keybase_url = `keybase.io/${keybase.components.username.val}`; 119 | if (keybase_url.length > 25) { 120 | keybase_string = keybase_url.slice(0, 23).concat("..."); 121 | } else { 122 | keybase_string = keybase_url; 123 | } 124 | username = {keybase_string}; 125 | 126 | // TODO: potentially display confirmation on keybase-user objects 127 | /* 128 | possible_profiles = ["twitter", "github", "coinbase"] 129 | profiles = _.map(possible_profiles, (possible) => 130 | if keybase.components[possible]?.val? 131 | * TODO icon instead of weird "service: username" text 132 | return ({ possible }: { keybase.components[possible].val }) 133 | ) 134 | profiles = _.reject(profiles, (profile) -> profile is undefined) 135 | profiles = _.map(profiles, (profile) -> 136 | return { profile } ) 137 | profileList = ({ profiles }) 138 | */ 139 | 140 | keybaseDetails = ( 141 |142 |145 | ); 146 | } else { 147 | // if no keybase profile, default image is based on email address 148 | hue = Utils.hueForString(this.props.profile.addresses[0]); 149 | bgColor = `hsl(${hue}, 50%, 45%)`; 150 | abv = this.props.profile.addresses[0][0].toUpperCase(); 151 | picture = ( 152 |{fullname}143 |{username}144 |156 | {abv} 157 |158 | ); 159 | } 160 | 161 | // email addresses 162 | if ( 163 | (profile.addresses != null ? profile.addresses.length : undefined) > 0 164 | ) { 165 | const emails = _.map(profile.addresses, email => { 166 | // TODO make that remove button not terrible 167 | return ( 168 |169 | {email}{" "} 170 | 171 | this._removeEmail(email)}>(X) 172 | 173 | 174 | ); 175 | }); 176 | emailList = ( 177 |178 | {" "} 179 | {emails} 180 | 181 | + Add Email 182 | 183 |
184 | ); 185 | } 186 | 187 | const emailListDiv = ( 188 |189 |191 | ); 192 | 193 | return ( 194 |{emailList}
190 |195 |202 | ); 203 | } 204 | }; 205 | KeybaseUser.initClass(); 206 | return KeybaseUser; 207 | })(); 208 | -------------------------------------------------------------------------------- /src/keybase.js: -------------------------------------------------------------------------------- 1 | /* 2 | * decaffeinate suggestions: 3 | * DS102: Remove unnecessary code created because of implicit returns 4 | * DS207: Consider shorter variations of null checks 5 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 6 | */ 7 | const _ = require("underscore"); 8 | const request = require("request"); 9 | 10 | class KeybaseAPI { 11 | constructor() { 12 | this.getUser = this.getUser.bind(this); 13 | this.getKey = this.getKey.bind(this); 14 | this.autocomplete = this.autocomplete.bind(this); 15 | this._keybaseRequest = this._keybaseRequest.bind(this); 16 | this.baseUrl = "https://keybase.io"; 17 | } 18 | 19 | getUser(key, keyType, callback) { 20 | if ( 21 | [ 22 | "usernames", 23 | "domain", 24 | "twitter", 25 | "github", 26 | "reddit", 27 | "hackernews", 28 | "coinbase", 29 | "key_fingerprint" 30 | ].includes(!keyType) 31 | ) { 32 | console.error("keyType must be a supported Keybase query type."); 33 | } 34 | 35 | return this._keybaseRequest( 36 | `/_/api/1.0/user/lookup.json?${keyType}=${key}`, 37 | (err, resp, obj) => { 38 | if (err) { 39 | return callback(err, null); 40 | } 41 | if (obj == null || obj.them == null) { 42 | return callback(new Error("Empty response!"), null); 43 | } 44 | if (obj.status != null) { 45 | if (obj.status.name !== "OK") { 46 | return callback(new Error(obj.status.desc), null); 47 | } 48 | } 49 | 50 | return callback(null, _.map(obj.them, this._regularToAutocomplete)); 51 | } 52 | ); 53 | } 54 | 55 | getKey(username, callback) { 56 | return request( 57 | { 58 | url: this.baseUrl + `/${username}/key.asc`, 59 | headers: { "User-Agent": "request" } 60 | }, 61 | (err, resp, obj) => { 62 | if (err) { 63 | return callback(err, null); 64 | } 65 | if (obj == null) { 66 | return callback(new Error(`No key found for ${username}`), null); 67 | } 68 | if (!obj.startsWith("-----BEGIN PGP PUBLIC KEY BLOCK-----")) { 69 | return callback( 70 | new Error(`No key returned from keybase for ${username}`), 71 | null 72 | ); 73 | } 74 | return callback(null, obj); 75 | } 76 | ); 77 | } 78 | 79 | autocomplete(query, callback) { 80 | const url = "/_/api/1.0/user/autocomplete.json"; 81 | return request( 82 | { 83 | url: this.baseUrl + url, 84 | form: { q: query }, 85 | headers: { "User-Agent": "request" }, 86 | json: true 87 | }, 88 | (err, resp, obj) => { 89 | if (err) { 90 | return callback(err, null); 91 | } 92 | if (obj.status != null) { 93 | if (obj.status.name !== "OK") { 94 | return callback(new Error(obj.status.desc), null); 95 | } 96 | } 97 | 98 | return callback(null, obj.completions); 99 | } 100 | ); 101 | } 102 | 103 | _keybaseRequest(url, callback) { 104 | return request( 105 | { 106 | url: this.baseUrl + url, 107 | headers: { "User-Agent": "request" }, 108 | json: true 109 | }, 110 | callback 111 | ); 112 | } 113 | 114 | _regularToAutocomplete(profile) { 115 | // converts a keybase profile to the weird format used in the autocomplete 116 | // endpoint for backward compatability 117 | // (does NOT translate accounts - e.g. twitter, github - yet) 118 | // TODO this should be the other way around 119 | const cleanedProfile = { components: {} }; 120 | cleanedProfile.thumbnail = null; 121 | if ( 122 | (profile.pictures != null ? profile.pictures.primary : undefined) != null 123 | ) { 124 | cleanedProfile.thumbnail = profile.pictures.primary.url; 125 | } 126 | const safe_name = profile.profile != null ? profile.profile.full_name : ""; 127 | cleanedProfile.components = { 128 | full_name: { val: safe_name }, 129 | username: { val: profile.basics.username } 130 | }; 131 | _.each(profile.proofs_summary.all, connectedAccount => { 132 | const component = {}; 133 | component[connectedAccount.proof_type] = { 134 | val: connectedAccount.nametag 135 | }; 136 | return (cleanedProfile.components = _.extend( 137 | cleanedProfile.components, 138 | component 139 | )); 140 | }); 141 | return cleanedProfile; 142 | } 143 | } 144 | 145 | module.exports = new KeybaseAPI(); 146 | -------------------------------------------------------------------------------- /src/main.es6: -------------------------------------------------------------------------------- 1 | import {PreferencesUIStore, ComponentRegistry, ExtensionRegistry} from 'mailspring-exports'; 2 | 3 | import EncryptMessageButton from './encrypt-button'; 4 | import DecryptMessageButton from './decrypt-button'; 5 | import DecryptPGPExtension from './decryption-preprocess'; 6 | import RecipientKeyChip from './recipient-key-chip'; 7 | import PreferencesKeybase from './preferences-keybase'; 8 | 9 | const PREFERENCE_TAB_ID = 'Encryption' 10 | 11 | export function activate() { 12 | this.preferencesTab = new PreferencesUIStore.TabItem({ 13 | tabId: PREFERENCE_TAB_ID, 14 | displayName: 'Encryption', 15 | componentClassFn: () => PreferencesKeybase, 16 | }); 17 | ComponentRegistry.register(EncryptMessageButton, {role: 'Composer:ActionButton'}); 18 | ComponentRegistry.register(DecryptMessageButton, {role: 'message:BodyHeader'}); 19 | ComponentRegistry.register(RecipientKeyChip, {role: 'Composer:RecipientChip'}); 20 | ExtensionRegistry.MessageView.register(DecryptPGPExtension); 21 | PreferencesUIStore.registerPreferencesTab(this.preferencesTab); 22 | } 23 | 24 | export function deactivate() { 25 | ComponentRegistry.unregister(EncryptMessageButton); 26 | ComponentRegistry.unregister(DecryptMessageButton); 27 | ComponentRegistry.unregister(RecipientKeyChip); 28 | ExtensionRegistry.MessageView.unregister(DecryptPGPExtension); 29 | PreferencesUIStore.unregisterPreferencesTab(PREFERENCE_TAB_ID); 30 | } 31 | 32 | export function serialize() { 33 | return {}; 34 | } 35 | -------------------------------------------------------------------------------- /src/modal-key-recommender.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * decaffeinate suggestions: 3 | * DS001: Remove Babel/TypeScript constructor workaround 4 | * DS101: Remove unnecessary use of Array.from 5 | * DS102: Remove unnecessary code created because of implicit returns 6 | * DS205: Consider reworking code to avoid use of IIFEs 7 | * DS206: Consider reworking classes to avoid initClass 8 | * DS207: Consider shorter variations of null checks 9 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 10 | */ 11 | let ModalKeyRecommender; 12 | const { Utils, React, Actions } = require("mailspring-exports"); 13 | const PGPKeyStore = require("./pgp-key-store"); 14 | const KeybaseSearch = require("./keybase-search"); 15 | const KeybaseUser = require("./keybase-user"); 16 | const kb = require("./keybase"); 17 | const _ = require("underscore"); 18 | import PropTypes from 'prop-types'; 19 | //import {Component} from 'react'; 20 | 21 | module.exports = ModalKeyRecommender = (function() { 22 | ModalKeyRecommender = class ModalKeyRecommender extends React.Component { 23 | static displayName = "ModalKeyRecommender"; 24 | static initClass() { 25 | this.propTypes = { 26 | contacts: PropTypes.array.isRequired, 27 | emails: PropTypes.array, 28 | callback: PropTypes.func 29 | }; 30 | 31 | this.defaultProps = { callback() {} }; 32 | // NOP 33 | } 34 | 35 | constructor(props) { 36 | super(props); 37 | this._onKeystoreChange = this._onKeystoreChange.bind(this); 38 | this._getStateFromStores = this._getStateFromStores.bind(this); 39 | this._selectProfile = this._selectProfile.bind(this); 40 | this._onNext = this._onNext.bind(this); 41 | this._onPrev = this._onPrev.bind(this); 42 | this._setPage = this._setPage.bind(this); 43 | this._onDone = this._onDone.bind(this); 44 | this._onManageKeys = this._onManageKeys.bind(this); 45 | this.state = Object.assign( 46 | { 47 | currentContact: 0 48 | }, 49 | this._getStateFromStores() 50 | ); 51 | } 52 | 53 | componentDidMount() { 54 | return (this.unlistenKeystore = PGPKeyStore.listen( 55 | this._onKeystoreChange 56 | )); 57 | } 58 | 59 | componentWillUnmount() { 60 | return this.unlistenKeystore(); 61 | } 62 | 63 | _onKeystoreChange() { 64 | return this.setState(this._getStateFromStores()); 65 | } 66 | 67 | _getStateFromStores() { 68 | return { identities: PGPKeyStore.pubKeys(this.props.emails) }; 69 | } 70 | 71 | _selectProfile(address, identity) { 72 | // TODO this is an almost exact duplicate of keybase-search.cjsx:_save 73 | const keybaseUsername = identity.keybase_profile.components.username.val; 74 | identity.addresses.push(address); 75 | return kb.getKey(keybaseUsername, (error, key) => { 76 | if (error) { 77 | return console.error(error); 78 | } else { 79 | return PGPKeyStore.saveNewKey(identity, key); 80 | } 81 | }); 82 | } 83 | 84 | _onNext() { 85 | // NOTE: this doesn't do bounds checks! you must do that in render()! 86 | return this.setState({ currentContact: this.state.currentContact + 1 }); 87 | } 88 | 89 | _onPrev() { 90 | // NOTE: this doesn't do bounds checks! you must do that in render()! 91 | return this.setState({ currentContact: this.state.currentContact - 1 }); 92 | } 93 | 94 | _setPage(page) { 95 | // NOTE: this doesn't do bounds checks! you must do that in render()! 96 | return this.setState({ currentContact: page }); 97 | } 98 | // indexes from 0 because what kind of monster doesn't 99 | 100 | _onDone() { 101 | if (this.state.identities.length < this.props.emails.length) { 102 | if ( 103 | !PGPKeyStore._displayDialog( 104 | "Encrypt without keys for all recipients?", 105 | "Some recipients are missing PGP public keys. They will not be able to decrypt this message.", 106 | ["Encrypt", "Cancel"] 107 | ) 108 | ) { 109 | return; 110 | } 111 | } 112 | 113 | const emptyIdents = _.filter( 114 | this.state.identities, 115 | identity => identity.key == null 116 | ); 117 | if (emptyIdents.length === 0) { 118 | Actions.closePopover(); 119 | return this.props.callback(this.state.identities); 120 | } else { 121 | const newIdents = []; 122 | return (() => { 123 | const result = []; 124 | for (let idIndex in emptyIdents) { 125 | const identity = emptyIdents[idIndex]; 126 | if (idIndex < emptyIdents.length - 1) { 127 | result.push( 128 | PGPKeyStore.getKeyContents({ 129 | key: identity, 130 | callback: identity => newIdents.push(identity) 131 | }) 132 | ); 133 | } else { 134 | result.push( 135 | PGPKeyStore.getKeyContents({ 136 | key: identity, 137 | callback: identity => { 138 | newIdents.push(identity); 139 | this.props.callback(newIdents); 140 | return Actions.closePopover(); 141 | } 142 | }) 143 | ); 144 | } 145 | } 146 | return result; 147 | })(); 148 | } 149 | } 150 | 151 | _onManageKeys() { 152 | Actions.switchPreferencesTab("Encryption"); 153 | return Actions.openPreferences(); 154 | } 155 | 156 | render() { 157 | // find the email we're dealing with now 158 | let backButton, body, nextButton; 159 | const email = this.props.emails[this.state.currentContact]; 160 | // and a corresponding contact 161 | const contact = _.findWhere(this.props.contacts, { email: email }); 162 | const contactString = contact != null ? contact.toString() : email; 163 | // find the identity object that goes with this email (if any) 164 | const identity = _.find(this.state.identities, identity => 165 | Array.from(identity.addresses).includes(email) 166 | ); 167 | 168 | if (this.state.currentContact === this.props.emails.length - 1) { 169 | // last one 170 | if (this.props.emails.length === 1) { 171 | // only one 172 | backButton = false; 173 | } else { 174 | backButton = ( 175 | 178 | ); 179 | } 180 | nextButton = ( 181 | 184 | ); 185 | } else if (this.state.currentContact === 0) { 186 | // first one 187 | backButton = false; 188 | nextButton = ( 189 | 192 | ); 193 | } else { 194 | // somewhere in the middle 195 | backButton = ( 196 | 199 | ); 200 | nextButton = ( 201 | 204 | ); 205 | } 206 | 207 | if (identity != null) { 208 | const deleteButton = ( 209 | 217 | ); 218 | body = [ 219 |196 |198 | {keybaseDetails} 199 | {this.props.displayEmailList ? emailListDiv : undefined} 200 | {this.props.actionButton} 201 |{picture}197 |220 | This PGP public key has been saved for, 223 |
221 | {contactString}. 222 |224 |231 | ]; 232 | } else { 233 | let query; 234 | if (contact != null) { 235 | query = contact.fullName(); 236 | // don't search Keybase for emails, won't work anyways 237 | if (query.match(/\s/) == null) { 238 | query = ""; 239 | } 240 | } else { 241 | query = ""; 242 | } 243 | const importFunc = identity => this._selectProfile(email, identity); 244 | 245 | body = [ 246 |230 | 247 | There is no PGP public key saved for, 250 |
248 | {contactString}. 249 |255 | ]; 256 | } 257 | 258 | const prefsButton = ( 259 | 262 | ); 263 | 264 | return ( 265 | 266 | {body} 267 | 268 |274 | ); 275 | } 276 | }; 277 | ModalKeyRecommender.initClass(); 278 | return ModalKeyRecommender; 279 | })(); 280 | -------------------------------------------------------------------------------- /src/passphrase-popover.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * decaffeinate suggestions: 3 | * DS001: Remove Babel/TypeScript constructor workaround 4 | * DS102: Remove unnecessary code created because of implicit returns 5 | * DS205: Consider reworking code to avoid use of IIFEs 6 | * DS206: Consider reworking classes to avoid initClass 7 | * DS207: Consider shorter variations of null checks 8 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 9 | */ 10 | let PassphrasePopover; 11 | const { React, Actions } = require("mailspring-exports"); 12 | const Identity = require("./identity"); 13 | const PGPKeyStore = require("./pgp-key-store"); 14 | const _ = require("underscore"); 15 | const fs = require("fs"); 16 | const pgp = require("kbpgp"); 17 | import PropTypes from 'prop-types'; 18 | 19 | module.exports = PassphrasePopover = (function() { 20 | PassphrasePopover = class PassphrasePopover extends React.Component { 21 | static initClass() { 22 | this.propTypes = { 23 | identity: PropTypes.instanceOf(Identity), 24 | addresses: PropTypes.array 25 | }; 26 | } 27 | constructor(props) { 28 | super(props); 29 | this._onPassphraseChange = this._onPassphraseChange.bind(this); 30 | this._onKeyUp = this._onKeyUp.bind(this); 31 | this._validatePassphrase = this._validatePassphrase.bind(this); 32 | this._onDone = this._onDone.bind(this); 33 | this.state = { 34 | passphrase: "", 35 | placeholder: "PGP private key password", 36 | error: false, 37 | mounted: true 38 | }; 39 | } 40 | 41 | componentDidMount() { 42 | return (this._mounted = true); 43 | } 44 | 45 | componentWillUnmount() { 46 | return (this._mounted = false); 47 | } 48 | 49 | render() { 50 | const classNames = this.state.error 51 | ? "key-passphrase-input form-control bad-passphrase" 52 | : "key-passphrase-input form-control"; 53 | return ( 54 |269 |273 |{backButton}270 | {prefsButton} 271 |{nextButton}272 |55 | 63 | 69 |70 | ); 71 | } 72 | 73 | _onPassphraseChange(event) { 74 | return this.setState({ 75 | passphrase: event.target.value, 76 | placeholder: "PGP private key password", 77 | error: false 78 | }); 79 | } 80 | 81 | _onKeyUp(event) { 82 | if (event.keyCode === 13) { 83 | return this._validatePassphrase(); 84 | } 85 | } 86 | 87 | _validatePassphrase() { 88 | const { passphrase } = this.state; 89 | return (() => { 90 | const result = []; 91 | for (var emailIndex in this.props.addresses) { 92 | const email = this.props.addresses[emailIndex]; 93 | var privateKeys = PGPKeyStore.privKeys({ 94 | address: email, 95 | timed: false 96 | }); 97 | result.push( 98 | (() => { 99 | const result1 = []; 100 | for (var keyIndex in privateKeys) { 101 | // check to see if the password unlocks the key 102 | const key = privateKeys[keyIndex]; 103 | result1.push( 104 | fs.readFile(key.keyPath, (err, data) => { 105 | return pgp.KeyManager.import_from_armored_pgp( 106 | { 107 | armored: data 108 | }, 109 | (err, km) => { 110 | if (err) { 111 | return console.warn(err); 112 | } else { 113 | return km.unlock_pgp({ passphrase }, err => { 114 | if (err) { 115 | if ( 116 | parseInt(keyIndex, 10) === 117 | privateKeys.length - 1 118 | ) { 119 | if ( 120 | parseInt(emailIndex, 10) === 121 | this.props.addresses.length - 1 122 | ) { 123 | // every key has been tried, the password failed on all of them 124 | if (this._mounted) { 125 | return this.setState({ 126 | passphrase: "", 127 | placeholder: "Incorrect password", 128 | error: true 129 | }); 130 | } 131 | } 132 | } 133 | } else { 134 | // the password unlocked a key; that key should be used 135 | return this._onDone(); 136 | } 137 | }); 138 | } 139 | } 140 | ); 141 | }) 142 | ); 143 | } 144 | return result1; 145 | })() 146 | ); 147 | } 148 | return result; 149 | })(); 150 | } 151 | 152 | _onDone() { 153 | if (this.props.identity != null) { 154 | this.props.onPopoverDone(this.state.passphrase, this.props.identity); 155 | } else { 156 | this.props.onPopoverDone(this.state.passphrase); 157 | } 158 | return Actions.closePopover(); 159 | } 160 | }; 161 | PassphrasePopover.initClass(); 162 | return PassphrasePopover; 163 | })(); 164 | -------------------------------------------------------------------------------- /src/pgp-key-store.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * decaffeinate suggestions: 3 | * DS001: Remove Babel/TypeScript constructor workaround 4 | * DS101: Remove unnecessary use of Array.from 5 | * DS102: Remove unnecessary code created because of implicit returns 6 | * DS104: Avoid inline assignments 7 | * DS204: Change includes calls to have a more natural evaluation order 8 | * DS205: Consider reworking code to avoid use of IIFEs 9 | * DS207: Consider shorter variations of null checks 10 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 11 | */ 12 | import MailspringStore from "mailspring-store"; 13 | const { 14 | Actions, 15 | AttachmentStore, 16 | DraftStore, 17 | MessageBodyProcessor, 18 | RegExpUtils 19 | } = require("mailspring-exports"); 20 | const { remote, shell } = require("electron"); 21 | const Identity = require("./identity"); 22 | const kb = require("./keybase"); 23 | const pgp = require("kbpgp"); 24 | const _ = require("underscore"); 25 | const path = require("path"); 26 | const fs = require("fs"); 27 | const os = require("os"); 28 | 29 | class PGPKeyStore extends MailspringStore { 30 | constructor() { 31 | super(); 32 | this.validAddress = this.validAddress.bind(this); 33 | this.watch = this.watch.bind(this); 34 | this.unwatch = this.unwatch.bind(this); 35 | this._populate = this._populate.bind(this); 36 | this.getKeyContents = this.getKeyContents.bind(this); 37 | this.getKeybaseData = this.getKeybaseData.bind(this); 38 | this.saveNewKey = this.saveNewKey.bind(this); 39 | this.exportKey = this.exportKey.bind(this); 40 | this.deleteKey = this.deleteKey.bind(this); 41 | this.addAddressToKey = this.addAddressToKey.bind(this); 42 | this.removeAddressFromKey = this.removeAddressFromKey.bind(this); 43 | this.pubKeys = this.pubKeys.bind(this); 44 | this.privKeys = this.privKeys.bind(this); 45 | this.getDecrypted = this.getDecrypted.bind(this); 46 | this.decrypt = this.decrypt.bind(this); 47 | this.decryptAttachments = this.decryptAttachments.bind(this); 48 | 49 | this._identities = {}; 50 | 51 | this._msgCache = []; 52 | this._msgStatus = []; 53 | 54 | // Recursive subdir watching only works on OSX / Windows. annoying 55 | this._pubWatcher = null; 56 | this._privWatcher = null; 57 | 58 | this._keyDir = path.join(AppEnv.getConfigDirPath(), "keys"); 59 | this._pubKeyDir = path.join(this._keyDir, "public"); 60 | this._privKeyDir = path.join(this._keyDir, "private"); 61 | 62 | // Create the key storage file system if it doesn't already exist 63 | fs.access(this._keyDir, fs.R_OK | fs.W_OK, err => { 64 | if (err) { 65 | return fs.mkdir(this._keyDir, err => { 66 | if (err) { 67 | return console.warn(err); 68 | } else { 69 | return fs.mkdir(this._pubKeyDir, err => { 70 | if (err) { 71 | return console.warn(err); 72 | } else { 73 | return fs.mkdir(this._privKeyDir, err => { 74 | if (err) { 75 | return console.warn(err); 76 | } else { 77 | return this.watch(); 78 | } 79 | }); 80 | } 81 | }); 82 | } 83 | }); 84 | } else { 85 | fs.access(this._pubKeyDir, fs.R_OK | fs.W_OK, err => { 86 | if (err) { 87 | return fs.mkdir(this._pubKeyDir, err => { 88 | if (err) { 89 | return console.warn(err); 90 | } 91 | }); 92 | } 93 | }); 94 | fs.access(this._privKeyDir, fs.R_OK | fs.W_OK, err => { 95 | if (err) { 96 | return fs.mkdir(this._privKeyDir, err => { 97 | if (err) { 98 | return console.warn(err); 99 | } 100 | }); 101 | } 102 | }); 103 | this._populate(); 104 | return this.watch(); 105 | } 106 | }); 107 | } 108 | 109 | validAddress(address, isPub) { 110 | if (!address || address.length === 0) { 111 | this._displayError("You must provide an email address."); 112 | return false; 113 | } 114 | if (!RegExpUtils.emailRegex().test(address)) { 115 | this._displayError("Invalid email address."); 116 | return false; 117 | } 118 | const keys = isPub 119 | ? this.pubKeys(address) 120 | : this.privKeys({ address, timed: false }); 121 | const keystate = isPub ? "public" : "private"; 122 | if (keys.length > 0) { 123 | this._displayError( 124 | `A PGP ${keystate} key for that email address already exists.` 125 | ); 126 | return false; 127 | } 128 | return true; 129 | } 130 | 131 | /* I/O and File Tracking */ 132 | 133 | watch() { 134 | if (!this._pubWatcher) { 135 | this._pubWatcher = fs.watch(this._pubKeyDir, this._populate); 136 | } 137 | if (!this._privWatcher) { 138 | return (this._privWatcher = fs.watch(this._privKeyDir, this._populate)); 139 | } 140 | } 141 | 142 | unwatch() { 143 | if (this._pubWatcher) { 144 | this._pubWatcher.close(); 145 | } 146 | this._pubWatcher = null; 147 | if (this._privWatcher) { 148 | this._privWatcher.close(); 149 | } 150 | return (this._privWatcher = null); 151 | } 152 | 153 | _populate() { 154 | // add identity elements to later be populated with keys from disk 155 | // TODO if this function is called multiple times in quick succession it 156 | // will duplicate keys - need to do deduplication on add 157 | return fs.readdir(this._pubKeyDir, (err, pubFilenames) => { 158 | return fs.readdir(this._privKeyDir, (err, privFilenames) => { 159 | this._identities = {}; 160 | return _.each( 161 | [[pubFilenames, false], [privFilenames, true]], 162 | readresults => { 163 | const filenames = readresults[0]; 164 | let i = 0; 165 | if (filenames.length === 0) { 166 | this.trigger(this); 167 | } 168 | return (() => { 169 | const result = []; 170 | while (i < filenames.length) { 171 | const filename = filenames[i]; 172 | if (filename[0] === ".") { 173 | continue; 174 | } 175 | const ident = new Identity({ 176 | addresses: filename.split(" "), 177 | isPriv: readresults[1] 178 | }); 179 | this._identities[ident.clientId] = ident; 180 | this.trigger(this); 181 | result.push(i++); 182 | } 183 | return result; 184 | })(); 185 | } 186 | ); 187 | }); 188 | }); 189 | } 190 | 191 | getKeyContents({ key, passphrase, callback }) { 192 | // Reads an actual PGP key from disk and adds it to the preexisting metadata 193 | if (key.keyPath == null) { 194 | console.error("Identity has no path for key!", key); 195 | return; 196 | } 197 | return fs.readFile(key.keyPath, (err, data) => { 198 | return pgp.KeyManager.import_from_armored_pgp( 199 | { 200 | armored: data 201 | }, 202 | (err, km) => { 203 | if (err) { 204 | console.warn(err); 205 | } else { 206 | if (km.is_pgp_locked()) { 207 | // private key - check passphrase 208 | if (passphrase == null) { 209 | passphrase = ""; 210 | } 211 | km.unlock_pgp({ passphrase }, err => { 212 | if (err) { 213 | // decrypt checks all keys, so DON'T open an error dialog 214 | console.warn(err); 215 | return; 216 | } else { 217 | key.key = km; 218 | key.setTimeout(); 219 | if (callback != null) { 220 | return callback(key); 221 | } 222 | } 223 | }); 224 | } else { 225 | // public key - get keybase data 226 | key.key = km; 227 | key.setTimeout(); 228 | this.getKeybaseData(key); 229 | if (callback != null) { 230 | callback(key); 231 | } 232 | } 233 | } 234 | return this.trigger(this); 235 | } 236 | ); 237 | }); 238 | } 239 | 240 | getKeybaseData(identity) { 241 | // Given a key, fetches metadata from keybase about that key 242 | // TODO currently only works for public keys 243 | if (identity.key == null && !identity.isPriv && !identity.keybase_profile) { 244 | return this.getKeyContents({ key: identity }); 245 | } else { 246 | const fingerprint = identity.fingerprint(); 247 | if (fingerprint != null) { 248 | return kb.getUser(fingerprint, "key_fingerprint", (err, user) => { 249 | if (err) { 250 | console.error(err); 251 | } 252 | if ((user != null ? user.length : undefined) === 1) { 253 | identity.keybase_profile = user[0]; 254 | } 255 | return this.trigger(this); 256 | }); 257 | } 258 | } 259 | } 260 | 261 | saveNewKey(identity, contents) { 262 | // Validate the email address(es), then write to file. 263 | if (!identity instanceof Identity) { 264 | console.error("saveNewKey requires an identity object"); 265 | return; 266 | } 267 | const { addresses } = identity; 268 | if (addresses.length < 1) { 269 | console.error( 270 | "Identity must have at least one email address to save key" 271 | ); 272 | return; 273 | } 274 | if ( 275 | _.every(addresses, address => 276 | this.validAddress(address, !identity.isPriv) 277 | ) 278 | ) { 279 | // Just say no to trailing whitespace. 280 | if (contents.charAt(contents.length - 1) !== "-") { 281 | contents = contents.slice(0, -1); 282 | } 283 | return fs.writeFile(identity.keyPath, contents, err => { 284 | if (err) { 285 | return this._displayError(err); 286 | } 287 | }); 288 | } 289 | } 290 | 291 | exportKey({ identity, passphrase }) { 292 | const atIndex = identity.addresses[0].indexOf("@"); 293 | const suffix = identity.isPriv ? "-private.asc" : ".asc"; 294 | const shortName = identity.addresses[0].slice(0, atIndex).concat(suffix); 295 | if (AppEnv.savedState.lastKeybaseDownloadDirectory == null) { 296 | AppEnv.savedState.lastKeybaseDownloadDirectory = os.homedir(); 297 | } 298 | const savePath = path.join( 299 | AppEnv.savedState.lastKeybaseDownloadDirectory, 300 | shortName 301 | ); 302 | return this.getKeyContents({ 303 | key: identity, 304 | passphrase, 305 | callback: identity => { 306 | return AppEnv.showSaveDialog( 307 | { 308 | title: "Export PGP Key", 309 | defaultPath: savePath 310 | }, 311 | keyPath => { 312 | if (!keyPath) { 313 | return; 314 | } 315 | AppEnv.savedState.lastKeybaseDownloadDirectory = keyPath.slice( 316 | 0, 317 | keyPath.length - shortName.length 318 | ); 319 | if (passphrase != null) { 320 | return identity.key.export_pgp_private( 321 | { passphrase }, 322 | (err, pgp_private) => { 323 | if (err) { 324 | this._displayError(err); 325 | } 326 | return fs.writeFile(keyPath, pgp_private, err => { 327 | if (err) { 328 | this._displayError(err); 329 | } 330 | return shell.showItemInFolder(keyPath); 331 | }); 332 | } 333 | ); 334 | } else { 335 | return identity.key.export_pgp_public({}, (err, pgp_public) => { 336 | return fs.writeFile(keyPath, pgp_public, err => { 337 | if (err) { 338 | this._displayError(err); 339 | } 340 | return shell.showItemInFolder(keyPath); 341 | }); 342 | }); 343 | } 344 | } 345 | ); 346 | } 347 | }); 348 | } 349 | 350 | deleteKey(key) { 351 | if ( 352 | this._displayDialog( 353 | "Delete this key?", 354 | "The key will be permanently deleted.", 355 | ["Delete", "Cancel"] 356 | ) 357 | ) { 358 | return fs.unlink(key.keyPath, err => { 359 | if (err) { 360 | this._displayError(err); 361 | } 362 | return this._populate(); 363 | }); 364 | } 365 | } 366 | 367 | addAddressToKey(profile, address) { 368 | if (this.validAddress(address, !profile.isPriv)) { 369 | const oldPath = profile.keyPath; 370 | profile.addresses.push(address); 371 | return fs.rename(oldPath, profile.keyPath, err => { 372 | if (err) { 373 | return this._displayError(err); 374 | } 375 | }); 376 | } 377 | } 378 | 379 | removeAddressFromKey(profile, address) { 380 | if (profile.addresses.length > 1) { 381 | const oldPath = profile.keyPath; 382 | profile.addresses = _.without(profile.addresses, address); 383 | return fs.rename(oldPath, profile.keyPath, err => { 384 | if (err) { 385 | return this._displayError(err); 386 | } 387 | }); 388 | } else { 389 | return this.deleteKey(profile); 390 | } 391 | } 392 | 393 | /* Internal Key Management */ 394 | 395 | pubKeys(addresses) { 396 | // fetch public identity/ies for an address (synchronous) 397 | // if no address, return them all 398 | let identities = _.where(_.values(this._identities), { isPriv: false }); 399 | 400 | if (addresses == null) { 401 | return identities; 402 | } 403 | 404 | if (typeof addresses === "string") { 405 | addresses = [addresses]; 406 | } 407 | 408 | identities = _.filter( 409 | identities, 410 | identity => _.intersection(addresses, identity.addresses).length > 0 411 | ); 412 | return identities; 413 | } 414 | 415 | privKeys(param) { 416 | // fetch private identity/ies for an address (synchronous). 417 | // by default, only return non-timed-out keys 418 | // if no address, return them all 419 | if (param == null) { 420 | param = { timed: true }; 421 | } 422 | const { address, timed } = param; 423 | let identities = _.where(_.values(this._identities), { isPriv: true }); 424 | 425 | if (address != null) { 426 | identities = _.filter(identities, identity => 427 | Array.from(identity.addresses).includes(address) 428 | ); 429 | } 430 | 431 | if (timed) { 432 | identities = _.reject(identities, identity => identity.isTimedOut()); 433 | } 434 | 435 | return identities; 436 | } 437 | 438 | _displayError(err) { 439 | const { dialog } = remote; 440 | return dialog.showErrorBox("Key Management Error", err.toString()); 441 | } 442 | 443 | _displayDialog(title, message, buttons) { 444 | const { dialog } = remote; 445 | return ( 446 | dialog.showMessageBox({ 447 | title, 448 | message: title, 449 | detail: message, 450 | buttons, 451 | type: "info" 452 | }) === 0 453 | ); 454 | } 455 | 456 | msgStatus(msg) { 457 | // fetch the latest status of a message 458 | let status; 459 | if (msg == null) { 460 | return null; 461 | } else { 462 | const { clientId } = msg; 463 | const statuses = _.filter( 464 | this._msgStatus, 465 | status => status.clientId === clientId 466 | ); 467 | status = _.max(statuses, stat => stat.time); 468 | } 469 | return status.message; 470 | } 471 | 472 | isDecrypted(message) { 473 | // if the message is already decrypted, return true 474 | // if the message has no encrypted component, return true 475 | // if the message has an encrypted component that is not yet decrypted, return false 476 | if (!this.hasEncryptedComponent(message)) { 477 | return true; 478 | } else if (this.getDecrypted(message) != null) { 479 | return true; 480 | } else { 481 | return false; 482 | } 483 | } 484 | 485 | getDecrypted(message) { 486 | // Fetch a cached decrypted message 487 | // (synchronous) 488 | 489 | let needle; 490 | if ( 491 | ((needle = message.clientId), 492 | Array.from(_.pluck(this._msgCache, "clientId")).includes(needle)) 493 | ) { 494 | const msg = _.findWhere(this._msgCache, { clientId: message.clientId }); 495 | if (msg.timeout > Date.now()) { 496 | return msg.body; 497 | } 498 | } 499 | 500 | // otherwise 501 | return null; 502 | } 503 | 504 | hasEncryptedComponent(message) { 505 | if (message.body == null) { 506 | return false; 507 | } 508 | 509 | // find a PGP block 510 | const pgpStart = "-----BEGIN PGP MESSAGE-----"; 511 | const pgpEnd = "-----END PGP MESSAGE-----"; 512 | 513 | const blockStart = message.body.indexOf(pgpStart); 514 | const blockEnd = message.body.indexOf(pgpEnd); 515 | // if they're both present, assume an encrypted block 516 | return blockStart >= 0 && blockEnd >= 0; 517 | } 518 | 519 | fetchEncryptedAttachments(message) { 520 | const encrypted = _.map(message.files, file => { 521 | // calendars don't have filenames 522 | if (file.filename != null) { 523 | const tokenized = file.filename.split("."); 524 | const extension = tokenized[tokenized.length - 1]; 525 | if (extension === "asc" || extension === "pgp") { 526 | // something.asc or something.pgp -> assume encrypted attachment 527 | return file; 528 | } else { 529 | return null; 530 | } 531 | } else { 532 | return null; 533 | } 534 | }); 535 | // NOTE for now we don't verify that the .asc/.pgp files actually have a PGP 536 | // block inside 537 | 538 | return _.compact(encrypted); 539 | } 540 | 541 | decrypt(message) { 542 | // decrypt a message, cache the result 543 | // (asynchronous) 544 | 545 | // check to make sure we haven't already decrypted and cached the message 546 | // note: could be a race condition here causing us to decrypt multiple times 547 | // (not that that's a big deal other than minor resource wastage) 548 | if (this.getDecrypted(message) != null) { 549 | return; 550 | } 551 | 552 | if (!this.hasEncryptedComponent(message)) { 553 | return; 554 | } 555 | 556 | // fill our keyring with all possible private keys 557 | const ring = new pgp.keyring.KeyRing(); 558 | // (the unbox function will use the right one) 559 | 560 | for (let key of Array.from(this.privKeys({ timed: true }))) { 561 | if (key.key != null) { 562 | ring.add_key_manager(key.key); 563 | } 564 | } 565 | 566 | // find a PGP block 567 | const pgpStart = "-----BEGIN PGP MESSAGE-----"; 568 | const blockStart = message.body.indexOf(pgpStart); 569 | 570 | const pgpEnd = "-----END PGP MESSAGE-----"; 571 | const blockEnd = message.body.indexOf(pgpEnd) + pgpEnd.length; 572 | 573 | // if we don't find those, it isn't encrypted 574 | if (!(blockStart >= 0) || !(blockEnd >= 0)) { 575 | return; 576 | } 577 | 578 | let pgpMsg = message.body.slice(blockStart, blockEnd); 579 | 580 | // Some users may send messages from sources that pollute the encrypted block. 581 | pgpMsg = pgpMsg.replace(/+/gm, "+"); 582 | pgpMsg = pgpMsg.replace(/(
)/g, "\n"); 583 | pgpMsg = pgpMsg.replace( 584 | /<\/(blockquote|div|dl|dt|dd|form|h1|h2|h3|h4|h5|h6|hr|ol|p|pre|table|tr|td|ul|li|section|header|footer)>/g, 585 | "\n" 586 | ); 587 | pgpMsg = pgpMsg.replace(/<(.+?)>/g, ""); 588 | pgpMsg = pgpMsg.replace(/ /g, " "); 589 | 590 | return pgp.unbox( 591 | { keyfetch: ring, armored: pgpMsg }, 592 | (err, literals, warnings, subkey) => { 593 | if (err) { 594 | console.warn(err); 595 | let errMsg = "Unable to decrypt message."; 596 | if ( 597 | err.toString().indexOf("tailer found") >= 0 || 598 | err.toString().indexOf("checksum mismatch") >= 0 599 | ) { 600 | errMsg = "Unable to decrypt message. Encrypted block is malformed."; 601 | } else if (err.toString().indexOf("key not found:") >= 0) { 602 | errMsg = 603 | "Unable to decrypt message. Private key does not match encrypted block."; 604 | if (this.msgStatus(message) == null) { 605 | errMsg = "Decryption preprocessing failed."; 606 | } 607 | } 608 | //Actions.recordUserEvent("Email Decryption Errored", { 609 | error: errMsg; 610 | 611 | return this._msgStatus.push({ 612 | clientId: message.clientId, 613 | time: Date.now(), 614 | message: errMsg 615 | }); 616 | } else { 617 | if (warnings._w.length > 0) { 618 | console.warn(warnings._w); 619 | } 620 | 621 | if (literals.length > 0) { 622 | let plaintext = literals[0].toString("utf8"); 623 | 624 | //tag for consistent styling 625 | if (plaintext.indexOf("") === -1) { 626 | plaintext = `\n${plaintext}\n`; 627 | } 628 | 629 | // can't use _.template :( 630 | const body = 631 | message.body.slice(0, blockStart) + 632 | plaintext + 633 | message.body.slice(blockEnd); 634 | 635 | // TODO if message is already in the cache, consider updating its TTL 636 | const timeout = 1000 * 60 * 30; // 30 minutes in ms 637 | this._msgCache.push({ 638 | clientId: message.clientId, 639 | body, 640 | timeout: Date.now() + timeout 641 | }); 642 | const keyprint = subkey.get_fingerprint().toString("hex"); 643 | this._msgStatus.push({ 644 | clientId: message.clientId, 645 | time: Date.now(), 646 | message: `Message decrypted with key ${keyprint}` 647 | }); 648 | // re-render messages 649 | //Actions.recordUserEvent("Email Decrypted"); 650 | MessageBodyProcessor.resetCache(); 651 | return this.trigger(this); 652 | } else { 653 | console.warn("Unable to decrypt message."); 654 | return this._msgStatus.push({ 655 | clientId: message.clientId, 656 | time: Date.now(), 657 | message: "Unable to decrypt message." 658 | }); 659 | } 660 | } 661 | } 662 | ); 663 | } 664 | 665 | decryptAttachments(identity, files) { 666 | // fill our keyring with all possible private keys 667 | const keyring = new pgp.keyring.KeyRing(); 668 | // (the unbox function will use the right one) 669 | 670 | if (identity.key != null) { 671 | keyring.add_key_manager(identity.key); 672 | } 673 | 674 | return AttachmentStore._fetchAndSaveAll(files).then(filepaths => 675 | // open, decrypt, and resave each of the newly-downloaded files in place 676 | _.each(filepaths, filepath => { 677 | return fs.readFile(filepath, (err, data) => { 678 | // find a PGP block 679 | const pgpStart = "-----BEGIN PGP MESSAGE-----"; 680 | const blockStart = data.indexOf(pgpStart); 681 | 682 | const pgpEnd = "-----END PGP MESSAGE-----"; 683 | const blockEnd = data.indexOf(pgpEnd) + pgpEnd.length; 684 | 685 | // if we don't find those, it isn't encrypted 686 | if (!(blockStart >= 0) || !(blockEnd >= 0)) { 687 | return; 688 | } 689 | 690 | const pgpMsg = data.slice(blockStart, blockEnd); 691 | 692 | // decrypt the file 693 | return pgp.unbox( 694 | { keyfetch: keyring, armored: pgpMsg }, 695 | (err, literals, warnings, subkey) => { 696 | if (err) { 697 | console.warn(err); 698 | } else { 699 | if (warnings._w.length > 0) { 700 | console.warn(warnings._w); 701 | } 702 | } 703 | 704 | const literalLen = literals != null ? literals.length : undefined; 705 | // if we have no literals, failed to decrypt and should abort 706 | if (literalLen == null) { 707 | return; 708 | } 709 | 710 | if (literalLen === 1) { 711 | // success! replace old encrypted file with awesome decrypted file 712 | filepath = filepath.slice(0, filepath.length - 3).concat("txt"); 713 | return fs.writeFile(filepath, literals[0].toBuffer(), err => { 714 | if (err) { 715 | return console.warn(err); 716 | } 717 | }); 718 | } else { 719 | return console.warn( 720 | `Attempt to decrypt attachment failed: ${ 721 | literalLen 722 | } literals found, expected 1.` 723 | ); 724 | } 725 | } 726 | ); 727 | }); 728 | }) 729 | ); 730 | } 731 | } 732 | 733 | module.exports = new PGPKeyStore(); 734 | -------------------------------------------------------------------------------- /src/preferences-keybase.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * decaffeinate suggestions: 3 | * DS001: Remove Babel/TypeScript constructor workaround 4 | * DS102: Remove unnecessary code created because of implicit returns 5 | * DS206: Consider reworking classes to avoid initClass 6 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 7 | */ 8 | const { React, RegExpUtils } = require("mailspring-exports"); 9 | const PGPKeyStore = require("./pgp-key-store"); 10 | const KeybaseSearch = require("./keybase-search"); 11 | const KeyManager = require("./key-manager"); 12 | const KeyAdder = require("./key-adder"); 13 | 14 | class PreferencesKeybase extends React.Component { 15 | static displayName = "PreferencesKeybase"; 16 | 17 | constructor(props) { 18 | super(props); 19 | this.componentDidMount = this.componentDidMount.bind(this); 20 | this.componentWillUnmount = this.componentWillUnmount.bind(this); 21 | this._onChange = this._onChange.bind(this); 22 | this.render = this.render.bind(this); 23 | this.props = props; 24 | this._keySaveQueue = {}; 25 | 26 | const { pubKeys, privKeys } = this._getStateFromStores(); 27 | this.state = { 28 | pubKeys, 29 | privKeys 30 | }; 31 | } 32 | 33 | componentDidMount() { 34 | return (this.unlistenKeystore = PGPKeyStore.listen(this._onChange, this)); 35 | } 36 | 37 | componentWillUnmount() { 38 | return this.unlistenKeystore(); 39 | } 40 | 41 | _onChange() { 42 | return this.setState(this._getStateFromStores()); 43 | } 44 | 45 | _getStateFromStores() { 46 | const pubKeys = PGPKeyStore.pubKeys(); 47 | const privKeys = PGPKeyStore.privKeys({ timed: false }); 48 | return { pubKeys, privKeys }; 49 | } 50 | 51 | render() { 52 | const noKeysMessage = ( 53 |{`\ 54 | You have no saved PGP keys!\ 55 | `}56 | ); 57 | 58 | const keyManager = ( 59 |60 | ); 61 | 62 | return ( 63 | 64 |74 | ); 75 | } 76 | } 77 | 78 | module.exports = PreferencesKeybase; 79 | -------------------------------------------------------------------------------- /src/private-key-popover.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * decaffeinate suggestions: 3 | * DS001: Remove Babel/TypeScript constructor workaround 4 | * DS102: Remove unnecessary code created because of implicit returns 5 | * DS206: Consider reworking classes to avoid initClass 6 | * DS207: Consider shorter variations of null checks 7 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 8 | */ 9 | let PrivateKeyPopover; 10 | const { React, Actions, AccountStore } = require("mailspring-exports"); 11 | const { remote } = require("electron"); 12 | const Identity = require("./identity"); 13 | const PGPKeyStore = require("./pgp-key-store"); 14 | const PassphrasePopover = require("./passphrase-popover"); 15 | const _ = require("underscore"); 16 | const fs = require("fs"); 17 | const pgp = require("kbpgp"); 18 | import PropTypes from 'prop-types'; 19 | 20 | module.exports = PrivateKeyPopover = (function() { 21 | PrivateKeyPopover = class PrivateKeyPopover extends React.Component { 22 | static initClass() { 23 | this.propTypes = { addresses: PropTypes.array }; 24 | } 25 | constructor() { 26 | super(); 27 | this.render = this.render.bind(this); 28 | this._renderAddresses = this._renderAddresses.bind(this); 29 | this._onSelectAddress = this._onSelectAddress.bind(this); 30 | this._onClickAdvanced = this._onClickAdvanced.bind(this); 31 | this._onClickImport = this._onClickImport.bind(this); 32 | this._onClickPaste = this._onClickPaste.bind(this); 33 | this._onKeyChange = this._onKeyChange.bind(this); 34 | this._onDone = this._onDone.bind(this); 35 | this._onKeySaved = this._onKeySaved.bind(this); 36 | this.state = { 37 | selectedAddress: "0", 38 | keyBody: "", 39 | paste: false, 40 | import: false, 41 | validKeyBody: false 42 | }; 43 | } 44 | 45 | render() { 46 | const errorBar = ( 47 |65 | 67 |66 | 68 | 73 |69 | {this.state.pubKeys.length === 0 && this.state.privKeys.length === 0 70 | ? noKeysMessage 71 | : keyManager} 72 | Invalid key body.48 | ); 49 | const keyArea = ( 50 | 55 | ); 56 | 57 | const saveBtnClass = !this.state.validKeyBody 58 | ? "btn modal-done-button btn-disabled" 59 | : "btn modal-done-button"; 60 | const saveButton = ( 61 | 68 | ); 69 | 70 | return ( 71 |72 | 73 | 74 | No PGP private key found.116 | ); 117 | } 118 | 119 | _renderAddresses() { 120 | let addresses; 121 | const signedIn = _.pluck(AccountStore.accounts(), "emailAddress"); 122 | const suggestions = _.intersection(signedIn, this.props.addresses); 123 | 124 | if (suggestions.length === 1) { 125 | return (addresses = {suggestions[0]}.); 126 | } else if (suggestions.length > 1) { 127 | const options = suggestions.map(address => ( 128 | 134 | )); 135 | return (addresses = ( 136 | 143 | )); 144 | } else { 145 | throw new Error( 146 | "How did you receive a message that you're not in the TO field for?" 147 | ); 148 | } 149 | } 150 | 151 | _onSelectAddress(event) { 152 | return this.setState({ 153 | selectedAddress: parseInt(event.target.value, 10) 154 | }); 155 | } 156 | 157 | _displayError(err) { 158 | const { dialog } = remote; 159 | return dialog.showErrorBox("Private Key Error", err.toString()); 160 | } 161 | 162 | _onClickAdvanced() { 163 | Actions.switchPreferencesTab("Encryption"); 164 | return Actions.openPreferences(); 165 | } 166 | 167 | _onClickImport(event) { 168 | return AppEnv.showOpenDialog( 169 | { 170 | title: "Import PGP Key", 171 | buttonLabel: "Import", 172 | properties: ["openFile"] 173 | }, 174 | filepath => { 175 | if (filepath != null) { 176 | return fs.readFile(filepath[0], (err, data) => { 177 | return pgp.KeyManager.import_from_armored_pgp( 178 | { 179 | armored: data 180 | }, 181 | (err, km) => { 182 | if (err) { 183 | this._displayError("File is not a valid PGP private key."); 184 | return; 185 | } else { 186 | const privateStart = 187 | "-----BEGIN PGP PRIVATE KEY BLOCK-----"; 188 | if (km.armored_pgp_public.indexOf(privateStart) >= 0) { 189 | return this.setState({ 190 | paste: false, 191 | import: true, 192 | keyBody: km.armored_pgp_public, 193 | validKeyBody: true 194 | }); 195 | } else { 196 | return this._displayError( 197 | "File is not a valid PGP private key." 198 | ); 199 | } 200 | } 201 | } 202 | ); 203 | }); 204 | } 205 | } 206 | ); 207 | } 208 | 209 | _onClickPaste(event) { 210 | return this.setState({ 211 | paste: !this.state.paste, 212 | import: false, 213 | keyBody: "", 214 | validKeyBody: false 215 | }); 216 | } 217 | 218 | _onKeyChange(event) { 219 | this.setState({ 220 | keyBody: event.target.value 221 | }); 222 | return pgp.KeyManager.import_from_armored_pgp( 223 | { 224 | armored: event.target.value 225 | }, 226 | (err, km) => { 227 | let valid; 228 | if (err) { 229 | valid = false; 230 | } else { 231 | const privateStart = "-----BEGIN PGP PRIVATE KEY BLOCK-----"; 232 | if (km.armored_pgp_public.indexOf(privateStart) >= 0) { 233 | valid = true; 234 | } else { 235 | valid = false; 236 | } 237 | } 238 | return this.setState({ 239 | validKeyBody: valid 240 | }); 241 | } 242 | ); 243 | } 244 | 245 | _onDone() { 246 | const signedIn = _.pluck(AccountStore.accounts(), "emailAddress"); 247 | const suggestions = _.intersection(signedIn, this.props.addresses); 248 | const selectedAddress = suggestions[this.state.selectedAddress]; 249 | const ident = new Identity({ 250 | addresses: [selectedAddress], 251 | isPriv: true 252 | }); 253 | this.unlistenKeystore = PGPKeyStore.listen(this._onKeySaved, this); 254 | return PGPKeyStore.saveNewKey(ident, this.state.keyBody); 255 | } 256 | 257 | _onKeySaved() { 258 | this.unlistenKeystore(); 259 | Actions.closePopover(); 260 | return this.props.callback(); 261 | } 262 | }; 263 | PrivateKeyPopover.initClass(); 264 | return PrivateKeyPopover; 265 | })(); 266 | -------------------------------------------------------------------------------- /src/recipient-key-chip.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * decaffeinate suggestions: 3 | * DS102: Remove unnecessary code created because of implicit returns 4 | * DS206: Consider reworking classes to avoid initClass 5 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 6 | */ 7 | const { MessageStore, React } = require("mailspring-exports"); 8 | const { RetinaImg } = require("mailspring-component-kit"); 9 | const PGPKeyStore = require("./pgp-key-store"); 10 | const pgp = require("kbpgp"); 11 | const _ = require("underscore"); 12 | import PropTypes from 'prop-types'; 13 | 14 | 15 | // Sits next to recipient chips in the composer and turns them green/red 16 | // depending on whether or not there's a PGP key present for that user 17 | class RecipientKeyChip extends React.Component { 18 | static displayName = "RecipientKeyChip"; 19 | static initClass() { 20 | this.propTypes = { contact: PropTypes.object.isRequired }; 21 | } 22 | 23 | constructor(props) { 24 | super(props); 25 | this.state = this._getStateFromStores(); 26 | this.unlistenKeystore = PGPKeyStore.listen(this._onKeystoreChange, this); 27 | } 28 | 29 | componentDidMount() { 30 | // fetch the actual key(s) from disk 31 | const keys = PGPKeyStore.pubKeys(this.props.contact.email); 32 | return _.each(keys, key => PGPKeyStore.getKeyContents({ key })); 33 | } 34 | 35 | componentWillUnmount() { 36 | return this.unlistenKeystore(); 37 | } 38 | 39 | _getStateFromStores() { 40 | return { 41 | // true if there is at least one loaded key for the account 42 | keys: PGPKeyStore.pubKeys(this.props.contact.email).some( 43 | (cv, ind, arr) => { 44 | return cv.hasOwnProperty("key"); 45 | } 46 | ) 47 | }; 48 | } 49 | 50 | _onKeystoreChange() { 51 | return this.setState(this._getStateFromStores()); 52 | } 53 | 54 | render() { 55 | if (this.state.keys) { 56 | return ( 57 |
Add a key for{" "} 75 | {this._renderAddresses()} 76 | 77 | 78 |79 | 85 | 91 |92 | {(this.state.import || this.state.paste) && 93 | !this.state.validKeyBody && 94 | this.state.keyBody !== "" 95 | ? errorBar 96 | : undefined} 97 | {this.state.import || this.state.paste ? keyArea : undefined} 98 |99 |115 |100 | 106 |107 | 113 |{saveButton}114 |58 |64 | ); 65 | } else { 66 | return ( 67 |63 | 68 | 69 |70 | ); 71 | } 72 | } 73 | } 74 | RecipientKeyChip.initClass(); 75 | 76 | module.exports = RecipientKeyChip; 77 | -------------------------------------------------------------------------------- /stylesheets/main.less: -------------------------------------------------------------------------------- 1 | @import "ui-variables"; 2 | @import "ui-mixins"; 3 | 4 | @code-bg-color: #fcf4db; 5 | 6 | .keybase { 7 | 8 | .no-keys-message { 9 | text-align: center; 10 | } 11 | 12 | } 13 | 14 | .container-keybase { 15 | max-width: 640px; 16 | margin: 0 auto; 17 | } 18 | 19 | .keybase-profile { 20 | border: 1px solid @border-color-primary; 21 | border-top: 0; 22 | background: @background-primary; 23 | padding: 10px; 24 | overflow: auto; 25 | display: flex; 26 | 27 | .profile-photo-wrap { 28 | width: 50px; 29 | height: 50px; 30 | border-radius: @border-radius-base; 31 | padding: 3px; 32 | box-shadow: 0 0 1px rgba(0,0,0,0.5); 33 | background: @background-primary; 34 | 35 | .profile-photo { 36 | border-radius: @border-radius-small; 37 | overflow: hidden; 38 | text-align: center; 39 | width: 44px; 40 | height: 44px; 41 | 42 | img, .default-profile-image { 43 | width: 44px; 44 | height: 44px; 45 | } 46 | 47 | .default-profile-image { 48 | line-height: 44px; 49 | font-size: 18px; 50 | font-weight: 500; 51 | color: white; 52 | box-shadow: inset 0 0 1px rgba(0,0,0,0.18); 53 | background-image: linear-gradient(to bottom, rgba(255,255,255,0.15) 0%, rgba(255,255,255,0) 100%); 54 | } 55 | 56 | .user-picture { 57 | background: @background-secondary; 58 | width: 44px; 59 | height: 44px; 60 | } 61 | } 62 | } 63 | 64 | .key-actions { 65 | display: flex; 66 | flex-direction: column; 67 | 68 | button { 69 | margin: 2px 0 2px 10px; 70 | white-space: nowrap; 71 | display: inline-block; 72 | float: right; 73 | } 74 | } 75 | 76 | .details { 77 | margin-left: 10px; 78 | flex: 1; 79 | } 80 | 81 | button { 82 | margin: 10px 0 10px 10px; 83 | white-space: nowrap; 84 | display: inline-block; 85 | float: right; 86 | } 87 | 88 | keybase-participant-field { 89 | float: right; 90 | } 91 | 92 | ul { 93 | list-style-type: none; 94 | } 95 | 96 | .email-list { 97 | padding-left: 10px; 98 | word-break: break-all; 99 | flex-grow: 3; 100 | text-align: right; 101 | } 102 | } 103 | 104 | .keybase-profile:first-child { 105 | border-top: 1px solid @border-color-primary; 106 | } 107 | 108 | .fixed-popover-container, .email-list { 109 | .keybase-participant-field { 110 | margin-bottom: 10px; 111 | 112 | .n1-keybase-recipient-key-chip { 113 | display: none; 114 | } 115 | 116 | .tokenizing-field-label { 117 | display: none; 118 | padding-top: 0; 119 | } 120 | 121 | .tokenizing-field-input { 122 | padding-left: 0; 123 | padding-top: 0; 124 | 125 | input { 126 | border: none; 127 | } 128 | } 129 | } 130 | } 131 | 132 | .fixed-popover-container { 133 | .keybase-participant-field { 134 | width: 300px; 135 | background: @input-bg; 136 | border: 1px solid @input-border-color; 137 | 138 | .menu .content-container { 139 | background: @background-secondary; 140 | } 141 | } 142 | 143 | .passphrase-popover { 144 | margin: 10px; 145 | display: flex; 146 | 147 | button { 148 | margin-left: 5px; 149 | flex: 0; 150 | } 151 | 152 | input { 153 | min-width: 180px; 154 | flex: 1; 155 | } 156 | 157 | .bad-passphrase { 158 | border-color: @color-error; 159 | } 160 | } 161 | 162 | .keybase-import-popover { 163 | margin: 10px; 164 | 165 | button { 166 | width: 100%; 167 | } 168 | 169 | .title { 170 | margin: 0 auto; 171 | white-space: nowrap; 172 | } 173 | } 174 | 175 | .private-key-popover { 176 | display: flex; 177 | flex-direction: column; 178 | width: 300px; 179 | margin: 5px 10px; 180 | 181 | .picker-title { 182 | margin-left: auto; 183 | margin-right: auto; 184 | text-align: center; 185 | } 186 | 187 | textarea { 188 | margin-top: 5px; 189 | } 190 | 191 | .invalid-key-body { 192 | background-color: @code-bg-color; 193 | color: darken(@code-bg-color, 70%); 194 | border: 1.5px solid darken(@code-bg-color, 10%); 195 | border-radius: @border-radius-small; 196 | font-size: @font-size-small; 197 | margin: 5px 0 0 0; 198 | text-align: center; 199 | } 200 | 201 | .key-add-buttons { 202 | display: flex; 203 | flex-direction: row; 204 | 205 | button { 206 | width: 147px; 207 | margin: 5px 0 0 0; 208 | } 209 | 210 | .paste-btn { 211 | margin-right: 6px; 212 | } 213 | } 214 | 215 | .picker-controls { 216 | width: 100%; 217 | margin: 5px auto; 218 | display: flex; 219 | flex-shrink: 0; 220 | flex-direction: row; 221 | 222 | .modal-cancel-button { 223 | float: left; 224 | } 225 | 226 | .modal-prefs-button { 227 | flex: 1; 228 | margin: 0 35px; 229 | } 230 | 231 | .modal-done-button { 232 | float: right; 233 | } 234 | } 235 | } 236 | } 237 | 238 | .email-list { 239 | .keybase-participant-field { 240 | width: 200px; 241 | border-bottom: 1px solid @gray-light; 242 | } 243 | } 244 | 245 | .keybase-decrypt { 246 | 247 | div.line-w-label { 248 | display: flex; 249 | align-items: center; 250 | color: rgba(128, 128, 128, 0.5); 251 | } 252 | 253 | div.decrypt-bar { 254 | padding: 5px; 255 | border: 1.5px solid rgba(128, 128, 128, 0.5); 256 | border-radius: @border-radius-large; 257 | align-items: center; 258 | display: flex; 259 | 260 | .title-text { 261 | flex: 1; 262 | margin: auto 0; 263 | } 264 | 265 | .decryption-interface { 266 | button { 267 | margin-left: 5px; 268 | } 269 | } 270 | } 271 | 272 | div.error-decrypt-bar { 273 | border: 1.5px solid @color-error; 274 | 275 | .title-text { 276 | color: @color-error; 277 | } 278 | } 279 | 280 | div.done-decrypt-bar { 281 | border: 1.5px solid @color-success; 282 | 283 | .title-text { 284 | color: @color-success; 285 | } 286 | } 287 | 288 | div.border { 289 | height: 1px; 290 | background: rgba(128, 128, 128, 0.5); 291 | flex: 1; 292 | } 293 | 294 | div.error-border { 295 | background: @color-error; 296 | } 297 | 298 | div.done-border { 299 | background: @color-success; 300 | } 301 | } 302 | 303 | .key-manager { 304 | 305 | div.line-w-label { 306 | display: flex; 307 | align-items: center; 308 | color: rgba(128, 128, 128, 0.5); 309 | margin: 10px 0; 310 | } 311 | div.title-text { 312 | padding: 0 10px; 313 | } 314 | div.border { 315 | height: 1px; 316 | background: rgba(128, 128, 128, 0.5); 317 | flex: 1; 318 | } 319 | } 320 | 321 | .key-status-bar { 322 | background-color: @code-bg-color; 323 | color: darken(@code-bg-color, 70%); 324 | border: 1.5px solid darken(@code-bg-color, 10%); 325 | border-radius: @border-radius-small; 326 | font-size: @font-size-small; 327 | margin-bottom: 10px; 328 | } 329 | 330 | .key-add { 331 | padding-top:10px; 332 | 333 | .no-keys-message { 334 | text-align: center; 335 | } 336 | 337 | .key-adder { 338 | position: relative; 339 | border: 1px solid @input-border-color; 340 | padding: 10px; 341 | padding-top: 0; 342 | margin-bottom: 10px; 343 | 344 | .key-text { 345 | margin-top: 10px; 346 | min-height: 200px; 347 | display: flex; 348 | 349 | .loading { 350 | position: absolute; 351 | left: 50%; 352 | top: 50%; 353 | transform: translate(-50%, 50%); 354 | } 355 | 356 | textarea { 357 | border: 0; 358 | padding: 0; 359 | font-size: 0.9em; 360 | flex: 1; 361 | } 362 | } 363 | } 364 | 365 | .credentials { 366 | display: flex; 367 | flex-direction: row; 368 | 369 | .key-add-btn { 370 | margin: 10px 5px 0 0; 371 | flex: 0; 372 | } 373 | 374 | .key-email-input { 375 | margin: 10px 5px 0 0; 376 | flex: 1; 377 | } 378 | 379 | .key-passphrase-input { 380 | margin: 10px 5px 0 0; 381 | flex: 1; 382 | } 383 | 384 | .invalid-msg { 385 | color: #AAA; 386 | white-space: nowrap; 387 | text-align: right; 388 | margin: 12px 5px 0 0; 389 | flex: 1; 390 | } 391 | } 392 | 393 | .key-creation-button { 394 | display: inline-block; 395 | margin: 0 5px 10px 5px; 396 | } 397 | 398 | .editor-note { 399 | color: #AAA; 400 | } 401 | } 402 | 403 | .key-instructions { 404 | color: #333; 405 | font-size: small; 406 | margin-top: 20px; 407 | } 408 | 409 | .keybase-search { 410 | margin-top: 15px; 411 | margin-bottom: 15px; 412 | overflow: scroll; 413 | position: relative; 414 | 415 | input { 416 | padding: 10px; 417 | margin-bottom: 10px; 418 | } 419 | 420 | .empty { 421 | text-align: center; 422 | } 423 | 424 | .loading { 425 | position: absolute; 426 | right: 10px; 427 | top: 8px; // lol I wonder how long until this is a problem 428 | } 429 | 430 | .bad-search-msg { 431 | display: inline-block; 432 | width: 100%; 433 | text-align: center; 434 | color: rgba(128, 128, 128, 0.5); 435 | 436 | br { 437 | display: none; 438 | } 439 | } 440 | } 441 | 442 | .key-picker-modal { 443 | width: 400px; 444 | height: 400px; 445 | display: flex; 446 | flex-direction: column; 447 | 448 | .keybase-search { 449 | display: flex; 450 | flex-direction: column; 451 | height: 100%; 452 | max-width: 400px; 453 | overflow: hidden; 454 | margin-bottom: 0; 455 | margin-top: 10px; 456 | 457 | .searchbar { 458 | width: 380px; 459 | margin-left: auto; 460 | margin-right: auto; 461 | } 462 | 463 | .loading { 464 | right: 20px; 465 | } 466 | 467 | .results { 468 | overflow: auto; 469 | height: 100%; 470 | width: 100%; 471 | } 472 | 473 | .bad-search-msg { 474 | br { 475 | display: inline; 476 | } 477 | } 478 | } 479 | 480 | .picker-controls { 481 | width: 380px; 482 | margin: 5px auto 10px auto; 483 | display: flex; 484 | flex-shrink: 0; 485 | flex-direction: row; 486 | 487 | .modal-back-button { 488 | float: left; 489 | } 490 | 491 | .modal-prefs-button { 492 | flex: 1; 493 | margin: 0 35px; 494 | } 495 | 496 | .modal-next-button { 497 | float: right; 498 | } 499 | } 500 | 501 | .keybase-profile-solo { 502 | border: 1px solid @border-color-primary; 503 | margin-top: 10px; 504 | } 505 | 506 | .picker-title { 507 | margin-top: 10px; 508 | margin-left: auto; 509 | margin-right: auto; 510 | text-align: center; 511 | } 512 | } 513 | 514 | .decrypted { 515 | display: block; 516 | box-sizing: border-box; 517 | -webkit-print-color-adjust: exact; 518 | padding: 8px 12px; 519 | margin-bottom: 5px; 520 | border: 1px solid rgb(235, 204, 209); 521 | border-radius: 4px; 522 | background-color: rgb(121, 212, 91); 523 | white-space: nowrap; 524 | overflow: hidden; 525 | text-overflow: ellipsis; 526 | } 527 | --------------------------------------------------------------------------------